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
This commit is contained in:
Wayne Sutton
2025-12-26 12:31:33 -08:00
parent 50890e9153
commit bfe88d0217
34 changed files with 3867 additions and 245 deletions

View File

@@ -0,0 +1,269 @@
---
name: AI Chat Write Agent
overview: Implement an AI-powered chat write agent that integrates with the Write page (replacing textarea when active) and optionally appears in the RightSidebar on posts/pages. Uses Anthropic Claude API with anonymous sessions, per-page chat history stored in Convex, and optional page content loading.
todos:
- id: schema
content: Add aiChats table to convex/schema.ts with indexes
status: completed
- id: backend-mutations
content: Create convex/aiChats.ts with queries and mutations
status: completed
dependencies:
- schema
- id: backend-action
content: Create convex/aiChatActions.ts with Claude API integration
status: completed
dependencies:
- backend-mutations
- id: siteconfig
content: Add AIChatConfig interface and defaults to siteConfig.ts
status: completed
- id: chat-component
content: Create AIChatView.tsx component with full chat UI
status: completed
dependencies:
- backend-mutations
- backend-action
- id: chat-styles
content: Add AI chat CSS styles to global.css (theme-aware)
status: completed
dependencies:
- chat-component
- id: right-sidebar
content: Update RightSidebar.tsx to conditionally render AIChatView
status: completed
dependencies:
- chat-component
- siteconfig
- id: write-page
content: Update Write.tsx with AI chat mode toggle
status: completed
dependencies:
- chat-component
- siteconfig
- id: post-page
content: Update Post.tsx to pass content context to RightSidebar
status: completed
dependencies:
- right-sidebar
- id: frontmatter
content: Add aiChat field to sync-posts.ts and Write.tsx field definitions
status: completed
- id: dependencies
content: Add @anthropic-ai/sdk to package.json
status: completed
---
# AI Chat Write Agent Implementation
## Architecture Overview
```mermaid
flowchart TB
subgraph frontend [Frontend Components]
AIChatView[AIChatView Component]
WritePage[Write.tsx]
RightSidebar[RightSidebar.tsx]
PostPage[Post.tsx]
end
subgraph convex [Convex Backend]
aiChats[aiChats.ts - Queries/Mutations]
aiChatActions[aiChatActions.ts - Claude API Action]
schema[schema.ts - aiChats table]
end
subgraph external [External Services]
Claude[Anthropic Claude API]
end
WritePage -->|mode toggle| AIChatView
RightSidebar -->|when aiChat enabled| AIChatView
PostPage -->|passes content context| RightSidebar
AIChatView -->|mutations| aiChats
AIChatView -->|action| aiChatActions
aiChatActions -->|API call| Claude
aiChats -->|read/write| schema
```
## Key Design Decisions
- **Anonymous Sessions**: Uses localStorage `sessionId` (UUID) until auth is added
- **Chat Scope**: Per-page using slug as context identifier (e.g., "write-page", "about", "my-post-slug")
- **Page Context**: Optional button to load current page's markdown into chat context
- **Mode Toggle**: Write page switches between textarea and chat interface
- **Configuration**: Separate siteConfig toggles for Write page and RightSidebar
---
## 1. Database Schema Updates
Update [`convex/schema.ts`](convex/schema.ts) to add the `aiChats` table:
```typescript
aiChats: defineTable({
sessionId: v.string(),
contextId: v.string(), // slug or "write-page"
messages: v.array(
v.object({
role: v.union(v.literal("user"), v.literal("assistant")),
content: v.string(),
timestamp: v.number(),
}),
),
pageContext: v.optional(v.string()), // loaded page content
lastMessageAt: v.optional(v.number()),
})
.index("by_session_and_context", ["sessionId", "contextId"])
.index("by_session", ["sessionId"])
```
---
## 2. Convex Backend
### Create [`convex/aiChats.ts`](convex/aiChats.ts)
Queries and mutations for chat management:
- `getAIChatByContext` - Fetch chat for sessionId + contextId
- `getOrCreateAIChat` - Create chat if none exists
- `addUserMessage` - Add user message
- `addAssistantMessage` - Internal mutation for AI response
- `clearChat` - Clear messages
- `setPageContext` - Store loaded page content
### Create [`convex/aiChatActions.ts`](convex/aiChatActions.ts)
Node.js action for Claude API:
- `generateResponse` - Calls Claude API with conversation history
- Uses `ANTHROPIC_API_KEY` environment variable
- Loads system prompt from `CLAUDE_SYSTEM_PROMPT` or split `CLAUDE_PROMPT_*` variables
- Model: `claude-sonnet-4-20250514` with 2048 max tokens
- Includes last 20 messages as context
---
## 3. Frontend Components
### Create [`src/components/AIChatView.tsx`](src/components/AIChatView.tsx)
Main chat component with:
- Message list with markdown rendering (react-markdown)
- Auto-expanding textarea input
- Send button and keyboard shortcuts (Enter to send, Shift+Enter newline)
- Loading state with stop generation button
- Clear chat command ("clear")
- Copy message button on AI responses
- "Load Page Content" button (when page content available)
- Theme-aware styling matching existing UI
### Update [`src/components/RightSidebar.tsx`](src/components/RightSidebar.tsx)
Transform from empty placeholder to conditional chat container:
- Accept `aiChatEnabled`, `pageContent`, and `slug` props
- Render `AIChatView` when enabled
- Pass page context for "Load Content" feature
---
## 4. Page Updates
### Update [`src/pages/Write.tsx`](src/pages/Write.tsx)
Add AI chat mode toggle:
- New state: `isAIChatMode` (boolean)
- Add "AI Chat" button in Actions section (using `ChatCircle` from Phosphor Icons)
- When active: replace `<textarea>` with `<AIChatView contextId="write-page" />`
- Show "Back to Editor" button to switch back
- Respect `siteConfig.aiChat.enabledOnWritePage` setting
### Update [`src/pages/Post.tsx`](src/pages/Post.tsx)
Pass content to RightSidebar:
- Check `siteConfig.aiChat.enabledOnContent` AND frontmatter `aiChat: true`
- Pass `pageContent={content}` and `slug` to RightSidebar
- Add new frontmatter field `aiChat` to field definitions
---
## 5. Configuration
### Update [`src/config/siteConfig.ts`](src/config/siteConfig.ts)
Add AI chat configuration interface and defaults:
```typescript
export interface AIChatConfig {
enabledOnWritePage: boolean; // Show AI chat on /write
enabledOnContent: boolean; // Allow AI chat on posts/pages via frontmatter
}
// In SiteConfig interface:
aiChat: AIChatConfig;
// Default values:
aiChat: {
enabledOnWritePage: true,
enabledOnContent: true,
},
```
### Update Frontmatter Fields
Add `aiChat` field to sync scripts and field definitions:
- [`scripts/sync-posts.ts`](scripts/sync-posts.ts) - Add to PostFrontmatter and PageFrontmatter
- [`src/pages/Write.tsx`](src/pages/Write.tsx) - Add to POST_FIELDS and PAGE_FIELDS
---
## 6. Styling
### Update [`src/styles/global.css`](src/styles/global.css)
Add AI chat styles (approximately 300-400 lines):
- `.ai-chat-view` - Main container
- `.ai-chat-messages` - Scrollable message list
- `.ai-chat-message`, `.ai-chat-message-user`, `.ai-chat-message-assistant`
- `.ai-chat-input-container`, `.ai-chat-input`, `.ai-chat-send-button`
- `.ai-chat-loading`, `.ai-chat-stop-button`
- `.ai-chat-copy-button`, `.ai-chat-clear-button`
- `.ai-chat-load-context-button`
- Theme variants for dark, light, tan, cloud
- Mobile responsive styles
---
## 7. Dependencies
### Update [`package.json`](package.json)
Add required packages:
```json
"@anthropic-ai/sdk": "^0.71.2"
```
Note: `react-markdown` and `remark-gfm` already exist in the project.---
## 8. Environment Variables
Add to Convex deployment:
- `ANTHROPIC_API_KEY` (required) - Claude API key
- `CLAUDE_SYSTEM_PROMPT` (optional) - Custom system prompt for writing assistant
---
## File Summary

View File

@@ -0,0 +1,301 @@
---
name: Custom Homepage Configuration
overview: Add configuration to set any page or blog post as the homepage, with all Post component features (sidebar, copy dropdown, etc.) but without the featured section. Original homepage remains accessible at /home.
todos: []
---
# Cus
tom Homepage ConfigurationAllow configuring any page or blog post to serve as the homepage while preserving all Post component features (sidebar, copy dropdown, author info, etc.) and using the page/post's metadata for SEO.
## Architecture Overview
```mermaid
flowchart TD
A[User visits /] --> B{homepage.type?}
B -->|default| C[Home Component]
B -->|page| D[Post Component with page slug]
B -->|post| E[Post Component with post slug]
D --> F[Render page content]
E --> G[Render post content]
F --> H[No featured section]
G --> H
H --> I[All Post features: sidebar, copy dropdown, etc.]
J[User visits /home] --> C
```
## Implementation Steps
### 1. Update `src/config/siteConfig.ts`
Add homepage configuration interface and default:
```typescript
// Add to SiteConfig interface
export interface HomepageConfig {
type: "default" | "page" | "post";
slug?: string; // Required if type is "page" or "post"
originalHomeRoute?: string; // Route to access original homepage (default: "/home")
}
export interface SiteConfig {
// ... existing fields ...
homepage: HomepageConfig;
}
// Add to siteConfig object
export const siteConfig: SiteConfig = {
// ... existing config ...
homepage: {
type: "default", // Options: "default", "page", "post"
slug: undefined, // e.g., "about" or "welcome-post"
originalHomeRoute: "/home", // Route to access original homepage
},
};
```
### 2. Update `src/App.tsx`
Modify routing to conditionally render homepage:
```typescript
function App() {
usePageTracking();
const location = useLocation();
if (location.pathname === "/write") {
return <Write />;
}
// Determine if we should use a custom homepage
const useCustomHomepage =
siteConfig.homepage.type !== "default" &&
siteConfig.homepage.slug;
return (
<SidebarProvider>
<Layout>
<Routes>
{/* Homepage route - either default Home or custom page/post */}
<Route
path="/"
element={
useCustomHomepage ? (
<Post
slug={siteConfig.homepage.slug!}
isHomepage={true}
homepageType={siteConfig.homepage.type}
/>
) : (
<Home />
)
}
/>
{/* Original homepage route (when custom homepage is set) */}
{useCustomHomepage && (
<Route
path={siteConfig.homepage.originalHomeRoute || "/home"}
element={<Home />}
/>
)}
{/* ... rest of routes ... */}
</Routes>
</Layout>
</SidebarProvider>
);
}
```
### 3. Update `src/pages/Post.tsx`
Add props to support homepage mode and hide back button:
```typescript
interface PostProps {
slug?: string; // Optional slug prop when used as homepage
isHomepage?: boolean; // Flag to indicate this is the homepage
homepageType?: "page" | "post"; // Type of homepage content
}
export default function Post({
slug: propSlug,
isHomepage = false,
homepageType
}: PostProps = {}) {
const { slug: routeSlug } = useParams<{ slug: string }>();
const slug = propSlug || routeSlug;
// ... existing queries ...
// Conditionally hide back button when used as homepage
// In the render section:
{!isHomepage && (
<button onClick={() => navigate("/")} className="back-button">
<ArrowLeft size={16} />
<span>Back</span>
</button>
)}
// ... rest of component unchanged ...
}
```
### 4. Update `scripts/configure-fork.ts`
Add homepage configuration support:
```typescript
interface ForkConfig {
// ... existing fields ...
homepage?: {
type: "default" | "page" | "post";
slug?: string;
originalHomeRoute?: string;
};
}
// Add update function
function updateSiteConfig(config: ForkConfig): void {
// ... existing updates ...
if (config.homepage) {
const homepageConfig = JSON.stringify(config.homepage, null, 2)
.replace(/"/g, '"')
.replace(/\n/g, '\n ');
updateFile(
"src/config/siteConfig.ts",
[
{
search: /homepage:\s*\{[^}]*\},/s,
replace: `homepage: ${homepageConfig},`,
},
]
);
}
}
```
### 5. Update `FORK_CONFIG.md`
Add homepage configuration section:
````markdown
## Homepage Configuration
You can set any page or blog post to serve as your homepage.
### In fork-config.json
```json
{
"homepage": {
"type": "page",
"slug": "about",
"originalHomeRoute": "/home"
}
}
````
Options:
- `type`: `"default"` (standard homepage), `"page"` (use a static page), or `"post"` (use a blog post)
- `slug`: The slug of the page or post to use (required if type is "page" or "post")
- `originalHomeRoute`: Route to access the original homepage (default: "/home")
### Manual Configuration
In `src/config/siteConfig.ts`:
```typescript
homepage: {
type: "page", // or "post" or "default"
slug: "about", // slug of page/post to use
originalHomeRoute: "/home", // route to original homepage
},
```
### Notes
- Custom homepage uses the page/post's full content and features (sidebar, copy dropdown, etc.)
- Featured section is NOT shown on custom homepage
- SEO metadata comes from the page/post's frontmatter
- Original homepage remains accessible at `/home` when custom homepage is set
````javascript
### 6. Update `fork-config.json.example`
Add homepage configuration example:
```json
{
"homepage": {
"type": "default",
"slug": null,
"originalHomeRoute": "/home"
}
}
````
## Files to Modify
1. `src/config/siteConfig.ts` - Add HomepageConfig interface and default config
2. `src/App.tsx` - Conditional homepage routing
3. `src/pages/Post.tsx` - Add props for homepage mode, hide back button
4. `scripts/configure-fork.ts` - Add homepage config parsing and file updates
5. `FORK_CONFIG.md` - Document homepage configuration
6. `fork-config.json.example` - Add homepage config example
## Behavior Details
### Custom Homepage (page/post)
- Renders using Post component with all features
- Shows page/post content with markdown rendering
- Includes sidebar if `layout: "sidebar"` in frontmatter
- Includes copy dropdown, author info, tags (for posts)
- Does NOT show back button
- Does NOT show featured section
- Uses page/post metadata for SEO (title, description, OG image)
### Original Homepage
- Remains accessible at `/home` (or configured route)
- Shows all standard features (featured section, posts list, etc.)
- Unchanged functionality
### Default Behavior
- When `homepage.type === "default"`, standard Home component renders at `/`
- No `/home` route is created
## Testing Checklist
- [ ] Default homepage works at `/`
- [ ] Custom page homepage renders at `/` with all Post features
- [ ] Custom post homepage renders at `/` with all Post features
- [ ] Back button hidden on custom homepage
- [ ] Featured section not shown on custom homepage
- [ ] Original homepage accessible at `/home` when custom homepage set
- [ ] SEO metadata uses page/post frontmatter
- [ ] Sidebar works on custom homepage if layout is "sidebar"

View File

@@ -22,7 +22,7 @@ Your content is instantly available to browsers, LLMs, and AI agents.. Write mar
- **Total Posts**: 12
- **Total Pages**: 4
- **Latest Post**: 2025-12-25
- **Last Updated**: 2025-12-25T20:18:52.316Z
- **Last Updated**: 2025-12-26T20:30:35.290Z
## Tech stack

View File

@@ -153,6 +153,14 @@ export const siteConfig: SiteConfig = {
showOnBlogPage: true,
},
// Homepage configuration
// Set any page or blog post to serve as the homepage
homepage: {
type: "default", // Options: "default" (standard Home component), "page" (use a static page), or "post" (use a blog post)
slug: undefined, // Required if type is "page" or "post" - the slug of the page/post to use
originalHomeRoute: "/home", // Route to access the original homepage when custom homepage is set
},
links: {
docs: "/setup-guide",
convex: "https://convex.dev",
@@ -400,6 +408,79 @@ const DEFAULT_THEME: Theme = "tan"; // Options: dark, light, tan, cloud
---
## Homepage Configuration
You can set any page or blog post to serve as your homepage instead of the default Home component.
### In fork-config.json
```json
{
"homepage": {
"type": "page",
"slug": "about",
"originalHomeRoute": "/home"
}
}
```
### Manual Configuration
In `src/config/siteConfig.ts`:
```typescript
homepage: {
type: "page", // Options: "default", "page", or "post"
slug: "about", // Required if type is "page" or "post" - the slug of the page/post to use
originalHomeRoute: "/home", // Route to access the original homepage when custom homepage is set
},
```
### Options
- `type`: `"default"` (standard Home component), `"page"` (use a static page), or `"post"` (use a blog post)
- `slug`: The slug of the page or post to use (required if type is "page" or "post")
- `originalHomeRoute`: Route to access the original homepage (default: "/home")
### Behavior
- Custom homepage uses the page/post's full content and features (sidebar, copy dropdown, footer, etc.)
- Featured section is NOT shown on custom homepage (only on default Home component)
- SEO metadata comes from the page/post's frontmatter
- Original homepage remains accessible at `/home` (or configured route) when custom homepage is set
- Back button is hidden when a page/post is used as the homepage
### Examples
**Use a static page as homepage:**
```typescript
homepage: {
type: "page",
slug: "about",
originalHomeRoute: "/home",
},
```
**Use a blog post as homepage:**
```typescript
homepage: {
type: "post",
slug: "welcome-post",
originalHomeRoute: "/home",
},
```
**Switch back to default homepage:**
```typescript
homepage: {
type: "default",
slug: undefined,
originalHomeRoute: "/home",
},
```
---
## AI Agent Prompt
Copy this prompt to have an AI agent apply all changes:

View File

@@ -82,6 +82,7 @@ Follow the step-by-step guide in `FORK_CONFIG.md` to update each file manually.
- Static raw markdown files at `/raw/{slug}.md`
- Dedicated blog page with configurable navigation order
- Markdown writing page at `/write` with frontmatter reference
- AI Agent chat (powered by Anthropic Claude) on Write page and optionally in right sidebar
### SEO and Discovery
@@ -460,6 +461,32 @@ A public markdown writing page at `/write` (not linked in navigation).
Access directly at `yourdomain.com/write`. Content is stored in localStorage only (not synced to database). Use it to draft posts, then copy the content to a markdown file in `content/blog/` or `content/pages/` and run `npm run sync`.
**AI Agent mode:** When `siteConfig.aiChat.enabledOnWritePage` is enabled, a toggle button appears in the Actions section. Clicking it replaces the textarea with the AI Agent chat interface. The page title changes to "Agent" when in chat mode. Requires `ANTHROPIC_API_KEY` environment variable in Convex.
## AI Agent Chat
The site includes an AI writing assistant (Agent) powered by Anthropic Claude API. Agent can be enabled in two places:
**1. Write page (`/write`):** Enable via `siteConfig.aiChat.enabledOnWritePage`. Toggle replaces textarea with Agent chat interface.
**2. Right sidebar on posts/pages:** Enable via `aiChat: true` frontmatter field (requires `rightSidebar: true` and `siteConfig.aiChat.enabledOnContent: true`).
**Environment variables required:**
- `ANTHROPIC_API_KEY` (required): Your Anthropic API key
- `CLAUDE_PROMPT_STYLE`, `CLAUDE_PROMPT_COMMUNITY`, `CLAUDE_PROMPT_RULES` (optional): Split system prompts
- `CLAUDE_SYSTEM_PROMPT` (optional): Single system prompt fallback
Set these in [Convex Dashboard](https://dashboard.convex.dev) > Settings > Environment Variables.
**Features:**
- Per-page chat history stored in Convex
- Page content can be provided as context for AI responses
- Markdown rendering for AI responses
- User-friendly error messages when API key is not configured
- Anonymous session authentication using localStorage
## Source
Fork this project: [github.com/waynesutton/markdown-site](https://github.com/waynesutton/markdown-site)

44
TASK.md
View File

@@ -8,10 +8,52 @@
## Current Status
v1.31.1 ready. Footer component now supports images with size control via HTML attributes.
v1.33.0 ready. AI Chat Write Agent (Agent) integration complete with Anthropic Claude API support. Available on Write page and optionally in RightSidebar on posts/pages.
## Completed
- [x] AI Chat Write Agent (Agent) integration
- [x] AIChatView component created with Anthropic Claude API integration
- [x] Write page AI Agent mode toggle (replaces textarea when active)
- [x] RightSidebar AI chat support via frontmatter aiChat: true field
- [x] Per-session, per-context chat history stored in Convex (aiChats table)
- [x] Page content context support for AI responses
- [x] Markdown rendering for AI responses with copy functionality
- [x] Error handling for missing API keys with user-friendly messages
- [x] System prompt configurable via Convex environment variables
- [x] Anonymous session authentication using localStorage session ID
- [x] Chat history limited to last 20 messages for context efficiency
- [x] Title changes to "Agent" when in AI chat mode on Write page
- [x] Toggle button text changes between "Agent" and "Text Editor"
- [x] SiteConfig.aiChat configuration with enabledOnWritePage and enabledOnContent flags
- [x] Schema updated with aiChats table and aiChat fields on posts/pages tables
- [x] sync-posts.ts updated to handle aiChat frontmatter field
- [x] Documentation updated across all files
- [x] Fixed AI chat scroll prevention in Write page
- [x] Added viewport height constraints (100vh) to write-layout to prevent page-level scrolling
- [x] Updated write-main with max-height: 100vh and overflow: hidden when AI chat is active
- [x] Added min-height: 0 to flex children (write-ai-chat-container, ai-chat-view, ai-chat-messages) for proper flex behavior
- [x] Input container fixed at bottom with flex-shrink: 0
- [x] Sidebars (left and right) scroll internally with overflow-y: auto
- [x] Delayed focus in AIChatView (100ms setTimeout) to prevent scroll jump on mount
- [x] Added preventScroll: true to all focus() calls in AIChatView
- [x] Toggle button preserves scroll position using requestAnimationFrame
- [x] useEffect scrolls to top when switching to AI chat mode
- [x] Messages area scrolls internally while input stays fixed at bottom (ChatGPT-style behavior)
- [x] Custom homepage configuration feature
- [x] Added HomepageConfig interface to siteConfig.ts
- [x] Updated App.tsx to conditionally render homepage based on config
- [x] Updated Post.tsx to accept optional props for homepage mode (slug, isHomepage, homepageType)
- [x] Back button hidden when Post component is used as homepage
- [x] Original homepage route accessible at /home when custom homepage is set
- [x] SEO metadata uses page/post frontmatter when used as homepage
- [x] Updated configure-fork.ts to support homepage configuration
- [x] Updated FORK_CONFIG.md with homepage documentation
- [x] Updated fork-config.json.example with homepage option
- [x] All existing features (sidebar, footer, right sidebar) work correctly with custom homepage
- [x] Image support in footer component with size control
- [x] Footer sanitize schema updated to allow width, height, style, class attributes on images
- [x] Footer image component handler updated to pass through size attributes

View File

@@ -4,6 +4,82 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [1.33.0] - 2025-12-26
### Added
- AI Chat Write Agent integration with Anthropic Claude
- New `AIChatView` component (`src/components/AIChatView.tsx`) for AI-powered chat interface
- AI chat can be toggled on Write page via siteConfig.aiChat.enabledOnWritePage
- AI chat can appear in RightSidebar on posts/pages via frontmatter `aiChat: true` field
- Per-session, per-context chat history stored in Convex (aiChats table)
- Supports page content as context for AI responses
- Markdown rendering for AI responses with copy functionality
- Theme-aware styling that matches the site's design system
- Uses Phosphor Icons for all UI elements
- Convex backend for AI chat
- New `convex/aiChats.ts` with queries and mutations for chat history
- New `convex/aiChatActions.ts` with Claude API integration (requires ANTHROPIC_API_KEY environment variable)
- System prompt configurable via Convex environment variables:
- `CLAUDE_PROMPT_STYLE`, `CLAUDE_PROMPT_COMMUNITY`, `CLAUDE_PROMPT_RULES` (split prompts, joined with separators)
- `CLAUDE_SYSTEM_PROMPT` (single prompt, fallback if split prompts not set)
- Chat history limited to last 20 messages for context efficiency
- Error handling: displays "API key is not set" message when ANTHROPIC_API_KEY is missing in Convex environment variables
- New configuration options
- `siteConfig.aiChat` interface with `enabledOnWritePage` and `enabledOnContent` boolean flags
- Both flags default to false (opt-in feature)
- New `aiChat` frontmatter field for posts and pages (requires rightSidebar: true)
### Changed
- Write page now supports AI Agent mode toggle (replaces textarea when active)
- Title changes from "Blog Post" or "Page" to "Agent" when in AI chat mode
- Toggle button text changes between "Agent" and "Text Editor"
- Page scroll prevention when switching modes (no page jump)
- RightSidebar component updated to conditionally render AIChatView
- Post.tsx passes pageContent and slug to RightSidebar for AI context
- Schema updated with aiChats table and aiChat fields on posts/pages tables
- sync-posts.ts updated to handle aiChat frontmatter field
- AIChatView displays user-friendly error messages when API key is not configured
### Technical
- Added `@anthropic-ai/sdk` dependency for Claude API integration
- Anonymous session authentication using localStorage session ID
- AI chat CSS styles in global.css with theme variable support
- New convex schema: aiChats table with indexes (by_sessionId_contextId, by_contextId)
## [1.32.0] - 2025-12-25
### Added
- Custom homepage configuration
- Set any page or blog post to serve as the homepage instead of the default Home component
- Configure via `siteConfig.homepage` with `type` ("default", "page", or "post"), `slug` (required for page/post), and `originalHomeRoute` (default: "/home")
- Custom homepage retains all Post component features (sidebar, copy dropdown, author info, footer) but without the featured section
- Original homepage remains accessible at `/home` route (or configured `originalHomeRoute`) when custom homepage is set
- SEO metadata uses the page/post's frontmatter when used as homepage
- Back button hidden when Post component is used as homepage
- Fork configuration support for homepage
- Added `homepage` field to `fork-config.json.example`
- Updated `configure-fork.ts` to handle homepage configuration
- Documentation added to `FORK_CONFIG.md` with usage examples
### Changed
- `src/App.tsx`: Conditionally renders Home or Post component based on `siteConfig.homepage` configuration
- `src/pages/Post.tsx`: Added optional `slug`, `isHomepage`, and `homepageType` props to support homepage mode
- `src/config/siteConfig.ts`: Added `HomepageConfig` interface and default homepage configuration
### Technical
- New interface: `HomepageConfig` in `src/config/siteConfig.ts`
- Conditional routing in `App.tsx` checks `homepage.type` and `homepage.slug` to determine homepage component
- Post component accepts optional props for homepage mode (hides back button when `isHomepage` is true)
- Original homepage route dynamically added when custom homepage is active
## [1.31.1] - 2025-12-25
### Added

View File

@@ -1046,6 +1046,8 @@ Pages appear automatically in the navigation when published.
**Right sidebar:** When enabled in `siteConfig.rightSidebar.enabled`, posts and pages can display a right sidebar containing the CopyPageDropdown at 1135px+ viewport width. Add `rightSidebar: true` to frontmatter to enable. Without this field, pages render normally with CopyPageDropdown in the nav bar. When enabled, CopyPageDropdown moves from the navigation bar to the right sidebar on wide screens. The right sidebar is hidden below 1135px, and CopyPageDropdown returns to the nav bar automatically.
**AI Agent chat:** The site includes an AI writing assistant (Agent) powered by Anthropic Claude API. Enable Agent on the Write page via `siteConfig.aiChat.enabledOnWritePage` or in the right sidebar on posts/pages using `aiChat: true` frontmatter (requires `rightSidebar: true`). Requires `ANTHROPIC_API_KEY` environment variable in Convex. See the [AI Agent chat section](#ai-agent-chat) below for setup instructions.
### Update SEO Meta Tags
Edit `index.html` to update:
@@ -1293,6 +1295,72 @@ A markdown writing page is available at `/write` (not linked in navigation). Use
Content is stored in localStorage only and not synced to the database. Refreshing the page preserves your content, but clearing browser data will lose it.
**AI Agent mode:** When `siteConfig.aiChat.enabledOnWritePage` is enabled, a toggle button appears in the Actions section. Clicking it replaces the textarea with the AI Agent chat interface. The page title changes to "Agent" when in chat mode. Requires `ANTHROPIC_API_KEY` environment variable in Convex. See the [AI Agent chat section](#ai-agent-chat) below for setup instructions.
## AI Agent chat
The site includes an AI writing assistant (Agent) powered by Anthropic Claude API. Agent can be enabled in two places:
**1. Write page (`/write`)**
Enable Agent mode on the Write page via `siteConfig.aiChat.enabledOnWritePage`. When enabled, a toggle button appears in the Actions section. Clicking it replaces the textarea with the Agent chat interface. The page title changes to "Agent" when in chat mode.
**Configuration:**
```typescript
// src/config/siteConfig.ts
aiChat: {
enabledOnWritePage: true, // Enable Agent toggle on /write page
enabledOnContent: true, // Allow Agent on posts/pages via frontmatter
},
```
**2. Right sidebar on posts/pages**
Enable Agent in the right sidebar on individual posts or pages using the `aiChat` frontmatter field. Requires both `rightSidebar: true` and `siteConfig.aiChat.enabledOnContent: true`.
**Frontmatter example:**
```markdown
---
title: "My Post"
rightSidebar: true
aiChat: true # Enable Agent in right sidebar
---
```
**Environment variables:**
Agent requires the following Convex environment variables:
- `ANTHROPIC_API_KEY` (required): Your Anthropic API key for Claude API access
- `CLAUDE_PROMPT_STYLE` (optional): First part of system prompt
- `CLAUDE_PROMPT_COMMUNITY` (optional): Second part of system prompt
- `CLAUDE_PROMPT_RULES` (optional): Third part of system prompt
- `CLAUDE_SYSTEM_PROMPT` (optional): Single system prompt (fallback if split prompts not set)
**Setting environment variables:**
1. Go to [Convex Dashboard](https://dashboard.convex.dev)
2. Select your project
3. Navigate to Settings > Environment Variables
4. Add `ANTHROPIC_API_KEY` with your API key value
5. Optionally add system prompt variables (`CLAUDE_PROMPT_STYLE`, etc.)
6. Deploy changes
**How it works:**
- Agent uses anonymous session IDs stored in localStorage for chat history
- Each post/page has its own chat context (identified by slug)
- Chat history is stored per-session, per-context in Convex (aiChats table)
- Page content can be provided as context for AI responses
- Chat history limited to last 20 messages for efficiency
- If API key is not set, Agent displays "API key is not set" error message
**Error handling:**
If `ANTHROPIC_API_KEY` is not configured in Convex environment variables, Agent displays a user-friendly error message: "API key is not set". This helps identify when the API key is missing in production deployments.
## Next Steps
After deploying:

View File

@@ -9,6 +9,64 @@ layout: "sidebar"
All notable changes to this project.
![](https://img.shields.io/badge/License-MIT-yellow.svg)
## v1.33.0
Released December 26, 2025
**AI Chat Write Agent (Agent) integration**
- AI Agent chat interface powered by Anthropic Claude API
- New `AIChatView` component for AI-powered chat interface
- Available on Write page (replaces textarea when enabled) and optionally in RightSidebar on posts/pages
- Per-session, per-context chat history stored in Convex (aiChats table)
- Supports page content as context for AI responses
- Markdown rendering for AI responses with copy functionality
- Theme-aware styling matching the site's design system
- Uses Phosphor Icons for all UI elements
- Convex backend for AI chat
- New `convex/aiChats.ts` with queries and mutations for chat history
- New `convex/aiChatActions.ts` with Claude API integration (requires ANTHROPIC_API_KEY environment variable)
- System prompt configurable via Convex environment variables:
- `CLAUDE_PROMPT_STYLE`, `CLAUDE_PROMPT_COMMUNITY`, `CLAUDE_PROMPT_RULES` (split prompts, joined with separators)
- `CLAUDE_SYSTEM_PROMPT` (single prompt, fallback if split prompts not set)
- Chat history limited to last 20 messages for context efficiency
- Error handling: displays "API key is not set" message when ANTHROPIC_API_KEY is missing in Convex environment variables
- Configuration options
- `siteConfig.aiChat` interface with `enabledOnWritePage` and `enabledOnContent` boolean flags
- Both flags default to false (opt-in feature)
- New `aiChat` frontmatter field for posts and pages (requires rightSidebar: true)
- Write page AI Agent mode
- Title changes from "Blog Post" or "Page" to "Agent" when in AI chat mode
- Toggle button text changes between "Agent" and "Text Editor"
- Page scroll prevention when switching modes (no page jump)
- RightSidebar AI chat support
- Conditionally renders AIChatView when enabled via frontmatter `aiChat: true` field
- Requires both `siteConfig.aiChat.enabledOnContent` and frontmatter `aiChat: true`
- Passes page content as context for AI responses
Updated files: `src/components/AIChatView.tsx`, `src/components/RightSidebar.tsx`, `src/pages/Write.tsx`, `src/pages/Post.tsx`, `src/config/siteConfig.ts`, `convex/schema.ts`, `convex/aiChats.ts`, `convex/aiChatActions.ts`, `convex/posts.ts`, `convex/pages.ts`, `scripts/sync-posts.ts`, `src/styles/global.css`, `package.json`
Documentation updated: `files.md`, `changelog.md`, `README.md`, `content/blog/setup-guide.md`, `public/raw/docs.md`
## v1.32.0
Released December 25, 2025
**Custom homepage configuration**
- Set any page or blog post to serve as the homepage instead of the default Home component
- Configure via `siteConfig.homepage` with `type` ("default", "page", or "post"), `slug` (required for page/post), and `originalHomeRoute` (default: "/home")
- Custom homepage retains all Post component features (sidebar, copy dropdown, author info, footer) but without the featured section
- Original homepage remains accessible at `/home` route (or configured `originalHomeRoute`) when custom homepage is set
- SEO metadata uses the page/post's frontmatter when used as homepage
- Back button hidden when Post component is used as homepage
- Fork configuration support for homepage
- Added `homepage` field to `fork-config.json.example`
- Updated `configure-fork.ts` to handle homepage configuration
- Documentation added to `FORK_CONFIG.md` with usage examples
Updated files: `src/App.tsx`, `src/pages/Post.tsx`, `src/config/siteConfig.ts`, `scripts/configure-fork.ts`, `fork-config.json.example`, `FORK_CONFIG.md`
## v1.31.1
Released December 25, 2025

View File

@@ -5,9 +5,12 @@ published: true
order: 0
layout: "sidebar"
rightSidebar: true
aiChat: true
footer: true
---
## Getting Started
Reference documentation for setting up, customizing, and deploying this markdown framework.
**How publishing works:** Write posts in markdown, run `npm run sync` for development or `npm run sync:prod` for production, and they appear on your live site immediately. No rebuild or redeploy needed. Convex handles real-time data sync, so connected browsers update automatically.

View File

@@ -8,6 +8,8 @@
* @module
*/
import type * as aiChatActions from "../aiChatActions.js";
import type * as aiChats from "../aiChats.js";
import type * as crons from "../crons.js";
import type * as http from "../http.js";
import type * as pages from "../pages.js";
@@ -23,6 +25,8 @@ import type {
} from "convex/server";
declare const fullApi: ApiFromModules<{
aiChatActions: typeof aiChatActions;
aiChats: typeof aiChats;
crons: typeof crons;
http: typeof http;
pages: typeof pages;

307
convex/aiChatActions.ts Normal file
View File

@@ -0,0 +1,307 @@
"use node";
import { v } from "convex/values";
import { action } from "./_generated/server";
import { internal } from "./_generated/api";
import Anthropic from "@anthropic-ai/sdk";
import type {
ContentBlockParam,
TextBlockParam,
ImageBlockParam,
} from "@anthropic-ai/sdk/resources/messages/messages";
import FirecrawlApp from "@mendable/firecrawl-js";
import type { Id } from "./_generated/dataModel";
// Default system prompt for writing assistant
const DEFAULT_SYSTEM_PROMPT = `You are a helpful writing assistant. Help users write clearly and concisely.
Always apply the rule of one:
Focus on one person.
Address one specific problem they are facing.
Identify the single root cause of that problem.
Explain the one thing the solution does differently.
End by asking for one clear action.
Follow these guidelines:
Write in a clear and direct style.
Avoid jargon and unnecessary complexity.
Use short sentences and short paragraphs.
Be concise but thorough.
Do not use em dashes.
Format responses in markdown when appropriate.`;
/**
* Build system prompt from environment variables
* Supports split prompts (CLAUDE_PROMPT_STYLE, CLAUDE_PROMPT_COMMUNITY, CLAUDE_PROMPT_RULES)
* or single prompt (CLAUDE_SYSTEM_PROMPT)
*/
function buildSystemPrompt(): string {
// Try split prompts first
const part1 = process.env.CLAUDE_PROMPT_STYLE || "";
const part2 = process.env.CLAUDE_PROMPT_COMMUNITY || "";
const part3 = process.env.CLAUDE_PROMPT_RULES || "";
const parts = [part1, part2, part3].filter((p) => p.trim());
if (parts.length > 0) {
return parts.join("\n\n---\n\n");
}
// Fall back to single prompt
return process.env.CLAUDE_SYSTEM_PROMPT || DEFAULT_SYSTEM_PROMPT;
}
/**
* Scrape URL content using Firecrawl (optional)
*/
async function scrapeUrl(url: string): Promise<{
content: string;
title?: string;
} | null> {
const apiKey = process.env.FIRECRAWL_API_KEY;
if (!apiKey) {
return null; // Firecrawl not configured
}
try {
const firecrawl = new FirecrawlApp({ apiKey });
const result = await firecrawl.scrapeUrl(url, {
formats: ["markdown"],
});
if (!result.success || !result.markdown) {
return null;
}
return {
content: result.markdown,
title: result.metadata?.title,
};
} catch {
return null; // Silently fail if scraping fails
}
}
/**
* Generate AI response for a chat
* Calls Claude API and saves the response
*/
export const generateResponse = action({
args: {
chatId: v.id("aiChats"),
userMessage: v.string(),
pageContext: v.optional(v.string()),
attachments: v.optional(
v.array(
v.object({
type: v.union(v.literal("image"), v.literal("link")),
storageId: v.optional(v.id("_storage")),
url: v.optional(v.string()),
scrapedContent: v.optional(v.string()),
title: v.optional(v.string()),
}),
),
),
},
returns: v.string(),
handler: async (ctx, args) => {
// Get API key
const apiKey = process.env.ANTHROPIC_API_KEY;
if (!apiKey) {
throw new Error("API key is not set");
}
// Get chat history
const chat = await ctx.runQuery(internal.aiChats.getAIChatInternal, {
chatId: args.chatId,
});
if (!chat) {
throw new Error("Chat not found");
}
// Build system prompt with optional page context
let systemPrompt = buildSystemPrompt();
// Add page context if provided
const pageContent = args.pageContext || chat.pageContext;
if (pageContent) {
systemPrompt += `\n\n---\n\nThe user is viewing a page with the following content. Use this as context for your responses:\n\n${pageContent}`;
}
// Process attachments if provided
let processedAttachments = args.attachments;
if (processedAttachments && processedAttachments.length > 0) {
// Scrape link attachments
const processed = await Promise.all(
processedAttachments.map(async (attachment) => {
if (
attachment.type === "link" &&
attachment.url &&
!attachment.scrapedContent
) {
const scraped = await scrapeUrl(attachment.url);
if (scraped) {
return {
...attachment,
scrapedContent: scraped.content,
title: scraped.title || attachment.title,
};
}
}
return attachment;
}),
);
processedAttachments = processed;
}
// Build messages array from chat history (last 20 messages)
const recentMessages = chat.messages.slice(-20);
const claudeMessages: Array<{
role: "user" | "assistant";
content: string | Array<ContentBlockParam>;
}> = [];
// Convert chat messages to Claude format
for (const msg of recentMessages) {
if (msg.role === "assistant") {
claudeMessages.push({
role: "assistant",
content: msg.content,
});
} else {
// User message with potential attachments
const contentParts: Array<TextBlockParam | ImageBlockParam> = [];
// Add text content
if (msg.content) {
contentParts.push({
type: "text",
text: msg.content,
});
}
// Add attachments
if (msg.attachments) {
for (const attachment of msg.attachments) {
if (attachment.type === "image" && attachment.storageId) {
// Get image URL from storage
const imageUrl = await ctx.runQuery(
internal.aiChats.getStorageUrlInternal,
{ storageId: attachment.storageId },
);
if (imageUrl) {
contentParts.push({
type: "image",
source: {
type: "url",
url: imageUrl,
},
});
}
} else if (attachment.type === "link") {
// Add link context as text block
let linkText = attachment.url || "";
if (attachment.scrapedContent) {
linkText += `\n\nContent from ${attachment.url}:\n${attachment.scrapedContent}`;
}
if (linkText) {
contentParts.push({
type: "text",
text: linkText,
});
}
}
}
}
claudeMessages.push({
role: "user",
content:
contentParts.length === 1 && contentParts[0].type === "text"
? contentParts[0].text
: contentParts,
});
}
}
// Add the new user message with attachments
const newMessageContent: Array<TextBlockParam | ImageBlockParam> = [];
if (args.userMessage) {
newMessageContent.push({
type: "text",
text: args.userMessage,
});
}
// Process new message attachments
if (processedAttachments && processedAttachments.length > 0) {
for (const attachment of processedAttachments) {
if (attachment.type === "image" && attachment.storageId) {
const imageUrl = await ctx.runQuery(
internal.aiChats.getStorageUrlInternal,
{ storageId: attachment.storageId },
);
if (imageUrl) {
newMessageContent.push({
type: "image",
source: {
type: "url",
url: imageUrl,
},
});
}
} else if (attachment.type === "link") {
let linkText = attachment.url || "";
if (attachment.scrapedContent) {
linkText += `\n\nContent from ${attachment.url}:\n${attachment.scrapedContent}`;
}
if (linkText) {
newMessageContent.push({
type: "text",
text: linkText,
});
}
}
}
}
claudeMessages.push({
role: "user",
content:
newMessageContent.length === 1 && newMessageContent[0].type === "text"
? newMessageContent[0].text
: newMessageContent,
});
// Initialize Anthropic client
const anthropic = new Anthropic({
apiKey,
});
// Call Claude API
const response = await anthropic.messages.create({
model: "claude-sonnet-4-20250514",
max_tokens: 2048,
system: systemPrompt,
messages: claudeMessages,
});
// Extract text content from response
const textContent = response.content.find((block) => block.type === "text");
if (!textContent || textContent.type !== "text") {
throw new Error("No text content in Claude response");
}
const assistantMessage = textContent.text;
// Save the assistant message to the chat
await ctx.runMutation(internal.aiChats.addAssistantMessage, {
chatId: args.chatId,
content: assistantMessage,
});
return assistantMessage;
},
});

356
convex/aiChats.ts Normal file
View File

@@ -0,0 +1,356 @@
import { v } from "convex/values";
import {
query,
mutation,
internalQuery,
internalMutation,
} from "./_generated/server";
// Message validator for reuse
const messageValidator = v.object({
role: v.union(v.literal("user"), v.literal("assistant")),
content: v.string(),
timestamp: v.number(),
attachments: v.optional(
v.array(
v.object({
type: v.union(v.literal("image"), v.literal("link")),
storageId: v.optional(v.id("_storage")),
url: v.optional(v.string()),
scrapedContent: v.optional(v.string()),
title: v.optional(v.string()),
}),
),
),
});
/**
* Get storage URL for an image attachment
*/
export const getStorageUrl = query({
args: {
storageId: v.id("_storage"),
},
returns: v.union(v.string(), v.null()),
handler: async (ctx, args) => {
return await ctx.storage.getUrl(args.storageId);
},
});
/**
* Get AI chat by session and context
* Returns null if no chat exists
*/
export const getAIChatByContext = query({
args: {
sessionId: v.string(),
contextId: v.string(),
},
returns: v.union(
v.object({
_id: v.id("aiChats"),
_creationTime: v.number(),
sessionId: v.string(),
contextId: v.string(),
messages: v.array(messageValidator),
pageContext: v.optional(v.string()),
lastMessageAt: v.optional(v.number()),
}),
v.null(),
),
handler: async (ctx, args) => {
const chat = await ctx.db
.query("aiChats")
.withIndex("by_session_and_context", (q) =>
q.eq("sessionId", args.sessionId).eq("contextId", args.contextId),
)
.first();
return chat;
},
});
/**
* Internal query for use in actions
*/
export const getAIChatInternal = internalQuery({
args: {
chatId: v.id("aiChats"),
},
returns: v.union(
v.object({
_id: v.id("aiChats"),
_creationTime: v.number(),
sessionId: v.string(),
contextId: v.string(),
messages: v.array(messageValidator),
pageContext: v.optional(v.string()),
lastMessageAt: v.optional(v.number()),
}),
v.null(),
),
handler: async (ctx, args) => {
return await ctx.db.get(args.chatId);
},
});
/**
* Get storage URL for an image attachment (internal)
*/
export const getStorageUrlInternal = internalQuery({
args: {
storageId: v.id("_storage"),
},
returns: v.union(v.string(), v.null()),
handler: async (ctx, args) => {
return await ctx.storage.getUrl(args.storageId);
},
});
/**
* Get or create AI chat for session and context
* Returns the chat ID (creates new chat if needed)
*/
export const getOrCreateAIChat = mutation({
args: {
sessionId: v.string(),
contextId: v.string(),
},
returns: v.id("aiChats"),
handler: async (ctx, args) => {
// Check for existing chat
const existing = await ctx.db
.query("aiChats")
.withIndex("by_session_and_context", (q) =>
q.eq("sessionId", args.sessionId).eq("contextId", args.contextId),
)
.first();
if (existing) {
return existing._id;
}
// Create new chat
const chatId = await ctx.db.insert("aiChats", {
sessionId: args.sessionId,
contextId: args.contextId,
messages: [],
lastMessageAt: Date.now(),
});
return chatId;
},
});
/**
* Add user message to chat
* Returns the updated chat
*/
export const addUserMessage = mutation({
args: {
chatId: v.id("aiChats"),
content: v.string(),
},
returns: v.null(),
handler: async (ctx, args) => {
const chat = await ctx.db.get(args.chatId);
if (!chat) {
throw new Error("Chat not found");
}
const now = Date.now();
const newMessage = {
role: "user" as const,
content: args.content,
timestamp: now,
};
await ctx.db.patch(args.chatId, {
messages: [...chat.messages, newMessage],
lastMessageAt: now,
});
return null;
},
});
/**
* Add user message with attachments
* Used when sending images or links
*/
export const addUserMessageWithAttachments = mutation({
args: {
chatId: v.id("aiChats"),
content: v.string(),
attachments: v.optional(
v.array(
v.object({
type: v.union(v.literal("image"), v.literal("link")),
storageId: v.optional(v.id("_storage")),
url: v.optional(v.string()),
scrapedContent: v.optional(v.string()),
title: v.optional(v.string()),
}),
),
),
},
returns: v.null(),
handler: async (ctx, args) => {
const chat = await ctx.db.get(args.chatId);
if (!chat) {
throw new Error("Chat not found");
}
const now = Date.now();
const newMessage = {
role: "user" as const,
content: args.content,
timestamp: now,
attachments: args.attachments,
};
await ctx.db.patch(args.chatId, {
messages: [...chat.messages, newMessage],
lastMessageAt: now,
});
return null;
},
});
/**
* Generate upload URL for image attachments
*/
export const generateUploadUrl = mutation({
args: {},
returns: v.string(),
handler: async (ctx) => {
return await ctx.storage.generateUploadUrl();
},
});
/**
* Add assistant message to chat (internal - called from action)
*/
export const addAssistantMessage = internalMutation({
args: {
chatId: v.id("aiChats"),
content: v.string(),
},
returns: v.null(),
handler: async (ctx, args) => {
const chat = await ctx.db.get(args.chatId);
if (!chat) {
throw new Error("Chat not found");
}
const now = Date.now();
const newMessage = {
role: "assistant" as const,
content: args.content,
timestamp: now,
};
await ctx.db.patch(args.chatId, {
messages: [...chat.messages, newMessage],
lastMessageAt: now,
});
return null;
},
});
/**
* Clear all messages from a chat
*/
export const clearChat = mutation({
args: {
chatId: v.id("aiChats"),
},
returns: v.null(),
handler: async (ctx, args) => {
const chat = await ctx.db.get(args.chatId);
if (!chat) {
return null; // Idempotent - no error if chat doesn't exist
}
await ctx.db.patch(args.chatId, {
messages: [],
pageContext: undefined,
lastMessageAt: Date.now(),
});
return null;
},
});
/**
* Set page context for a chat (loads page markdown for AI context)
*/
export const setPageContext = mutation({
args: {
chatId: v.id("aiChats"),
pageContext: v.string(),
},
returns: v.null(),
handler: async (ctx, args) => {
const chat = await ctx.db.get(args.chatId);
if (!chat) {
throw new Error("Chat not found");
}
await ctx.db.patch(args.chatId, {
pageContext: args.pageContext,
});
return null;
},
});
/**
* Delete entire chat session
*/
export const deleteChat = mutation({
args: {
chatId: v.id("aiChats"),
},
returns: v.null(),
handler: async (ctx, args) => {
const chat = await ctx.db.get(args.chatId);
if (!chat) {
return null; // Idempotent
}
await ctx.db.delete(args.chatId);
return null;
},
});
/**
* Get all chats for a session (for potential future chat history feature)
*/
export const getChatsBySession = query({
args: {
sessionId: v.string(),
},
returns: v.array(
v.object({
_id: v.id("aiChats"),
_creationTime: v.number(),
sessionId: v.string(),
contextId: v.string(),
messages: v.array(messageValidator),
pageContext: v.optional(v.string()),
lastMessageAt: v.optional(v.number()),
}),
),
handler: async (ctx, args) => {
const chats = await ctx.db
.query("aiChats")
.withIndex("by_session", (q) => q.eq("sessionId", args.sessionId))
.collect();
return chats;
},
});

View File

@@ -127,6 +127,7 @@ export const getPageBySlug = query({
rightSidebar: v.optional(v.boolean()),
showFooter: v.optional(v.boolean()),
footer: v.optional(v.string()),
aiChat: v.optional(v.boolean()),
}),
v.null(),
),
@@ -158,6 +159,7 @@ export const getPageBySlug = query({
rightSidebar: page.rightSidebar,
showFooter: page.showFooter,
footer: page.footer,
aiChat: page.aiChat,
};
},
});
@@ -183,6 +185,7 @@ export const syncPagesPublic = mutation({
rightSidebar: v.optional(v.boolean()),
showFooter: v.optional(v.boolean()),
footer: v.optional(v.string()),
aiChat: v.optional(v.boolean()),
}),
),
},
@@ -225,6 +228,7 @@ export const syncPagesPublic = mutation({
rightSidebar: page.rightSidebar,
showFooter: page.showFooter,
footer: page.footer,
aiChat: page.aiChat,
lastSyncedAt: now,
});
updated++;

View File

@@ -130,6 +130,7 @@ export const getPostBySlug = query({
rightSidebar: v.optional(v.boolean()),
showFooter: v.optional(v.boolean()),
footer: v.optional(v.string()),
aiChat: v.optional(v.boolean()),
}),
v.null(),
),
@@ -164,6 +165,7 @@ export const getPostBySlug = query({
rightSidebar: post.rightSidebar,
showFooter: post.showFooter,
footer: post.footer,
aiChat: post.aiChat,
};
},
});
@@ -191,6 +193,7 @@ export const syncPosts = internalMutation({
rightSidebar: v.optional(v.boolean()),
showFooter: v.optional(v.boolean()),
footer: v.optional(v.string()),
aiChat: v.optional(v.boolean()),
}),
),
},
@@ -235,6 +238,7 @@ export const syncPosts = internalMutation({
rightSidebar: post.rightSidebar,
showFooter: post.showFooter,
footer: post.footer,
aiChat: post.aiChat,
lastSyncedAt: now,
});
updated++;
@@ -283,6 +287,7 @@ export const syncPostsPublic = mutation({
rightSidebar: v.optional(v.boolean()),
showFooter: v.optional(v.boolean()),
footer: v.optional(v.string()),
aiChat: v.optional(v.boolean()),
}),
),
},
@@ -327,6 +332,7 @@ export const syncPostsPublic = mutation({
rightSidebar: post.rightSidebar,
showFooter: post.showFooter,
footer: post.footer,
aiChat: post.aiChat,
lastSyncedAt: now,
});
updated++;

View File

@@ -22,6 +22,7 @@ export default defineSchema({
rightSidebar: v.optional(v.boolean()), // Enable right sidebar with CopyPageDropdown
showFooter: v.optional(v.boolean()), // Show footer on this post (overrides siteConfig default)
footer: v.optional(v.string()), // Footer markdown content (overrides siteConfig defaultContent)
aiChat: v.optional(v.boolean()), // Enable AI chat in right sidebar
lastSyncedAt: v.number(),
})
.index("by_slug", ["slug"])
@@ -55,6 +56,7 @@ export default defineSchema({
rightSidebar: v.optional(v.boolean()), // Enable right sidebar with CopyPageDropdown
showFooter: v.optional(v.boolean()), // Show footer on this page (overrides siteConfig default)
footer: v.optional(v.string()), // Footer markdown content (overrides siteConfig defaultContent)
aiChat: v.optional(v.boolean()), // Enable AI chat in right sidebar
lastSyncedAt: v.number(),
})
.index("by_slug", ["slug"])
@@ -105,4 +107,32 @@ export default defineSchema({
})
.index("by_sessionId", ["sessionId"])
.index("by_lastSeen", ["lastSeen"]),
// AI chat conversations for writing assistant
aiChats: defineTable({
sessionId: v.string(), // Anonymous session ID from localStorage
contextId: v.string(), // Slug or "write-page" identifier
messages: v.array(
v.object({
role: v.union(v.literal("user"), v.literal("assistant")),
content: v.string(),
timestamp: v.number(),
attachments: v.optional(
v.array(
v.object({
type: v.union(v.literal("image"), v.literal("link")),
storageId: v.optional(v.id("_storage")),
url: v.optional(v.string()),
scrapedContent: v.optional(v.string()),
title: v.optional(v.string()),
}),
),
),
}),
),
pageContext: v.optional(v.string()), // Loaded page markdown content
lastMessageAt: v.optional(v.number()),
})
.index("by_session_and_context", ["sessionId", "contextId"])
.index("by_session", ["sessionId"]),
});

320
files.md
View File

@@ -4,188 +4,196 @@ A brief description of each file in the codebase.
## Root Files
| File | Description |
| -------------------------- | ---------------------------------------------------- |
| `package.json` | Dependencies and scripts for the blog |
| `tsconfig.json` | TypeScript configuration |
| `vite.config.ts` | Vite bundler configuration |
| `index.html` | Main HTML entry with SEO meta tags and JSON-LD |
| `netlify.toml` | Netlify deployment and Convex HTTP redirects |
| `README.md` | Project documentation |
| `AGENTS.md` | AI coding agent instructions (agents.md spec) |
| `files.md` | This file - codebase structure |
| `changelog.md` | Version history and changes |
| `TASK.md` | Task tracking and project status |
| `FORK_CONFIG.md` | Fork configuration guide (manual + automated options)|
| `fork-config.json.example` | Template JSON config for automated fork setup |
| File | Description |
| -------------------------- | ----------------------------------------------------- |
| `package.json` | Dependencies and scripts for the blog |
| `tsconfig.json` | TypeScript configuration |
| `vite.config.ts` | Vite bundler configuration |
| `index.html` | Main HTML entry with SEO meta tags and JSON-LD |
| `netlify.toml` | Netlify deployment and Convex HTTP redirects |
| `README.md` | Project documentation |
| `AGENTS.md` | AI coding agent instructions (agents.md spec) |
| `files.md` | This file - codebase structure |
| `changelog.md` | Version history and changes |
| `TASK.md` | Task tracking and project status |
| `FORK_CONFIG.md` | Fork configuration guide (manual + automated options) |
| `fork-config.json.example` | Template JSON config for automated fork setup |
## Source Files (`src/`)
### Entry Points
| File | Description |
| --------------- | ------------------------------------------ |
| `main.tsx` | React app entry point with Convex provider |
| `App.tsx` | Main app component with routing |
| `vite-env.d.ts` | Vite environment type definitions |
| File | Description |
| --------------- | ------------------------------------------------------------------------------------------------ |
| `main.tsx` | React app entry point with Convex provider |
| `App.tsx` | Main app component with routing (supports custom homepage configuration via siteConfig.homepage) |
| `vite-env.d.ts` | Vite environment type definitions |
### Config (`src/config/`)
| File | Description |
| --------------- | --------------------------------------------------------------------------------------------------------- |
| `siteConfig.ts` | Centralized site configuration (name, logo, blog page, posts display with homepage post limit and read more link, GitHub contributions, nav order, inner page logo settings, hardcoded navigation items for React routes, GitHub repository config for AI service raw URLs, font family configuration, right sidebar configuration, footer configuration) |
| File | Description |
| --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `siteConfig.ts` | Centralized site configuration (name, logo, blog page, posts display with homepage post limit and read more link, GitHub contributions, nav order, inner page logo settings, hardcoded navigation items for React routes, GitHub repository config for AI service raw URLs, font family configuration, right sidebar configuration, footer configuration, homepage configuration, AI chat configuration) |
### Pages (`src/pages/`)
| File | Description |
| ----------- | ----------------------------------------------------------------- |
| `Home.tsx` | Landing page with featured content and optional post list. Supports configurable post limit (homePostsLimit) and optional "read more" link (homePostsReadMore) via siteConfig.postsDisplay |
| `Blog.tsx` | Dedicated blog page with post list or card grid view (configurable via siteConfig.blogPage, supports view toggle). Includes back button in navigation |
| `Post.tsx` | Individual blog post or page view with optional left sidebar (TOC) and right sidebar (CopyPageDropdown). Includes back button, tag links, and related posts section in footer for blog posts. Supports 3-column layout at 1135px+ (update SITE_URL/SITE_NAME when forking) |
| `Stats.tsx` | Real-time analytics dashboard with visitor stats and GitHub stars |
| `TagPage.tsx` | Tag archive page displaying posts filtered by a specific tag. Includes view mode toggle (list/cards) with localStorage persistence |
| `Write.tsx` | Three-column markdown writing page with Cursor docs-style UI, frontmatter reference with copy buttons, theme toggle, font switcher (serif/sans/monospace), and localStorage persistence (not linked in nav) |
| File | Description |
| ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `Home.tsx` | Landing page with featured content and optional post list. Supports configurable post limit (homePostsLimit) and optional "read more" link (homePostsReadMore) via siteConfig.postsDisplay |
| `Blog.tsx` | Dedicated blog page with post list or card grid view (configurable via siteConfig.blogPage, supports view toggle). Includes back button in navigation |
| `Post.tsx` | Individual blog post or page view with optional left sidebar (TOC) and right sidebar (CopyPageDropdown). Includes back button (hidden when used as homepage), tag links, and related posts section in footer for blog posts. Supports 3-column layout at 1135px+. Can be used as custom homepage via siteConfig.homepage (update SITE_URL/SITE_NAME when forking) |
| `Stats.tsx` | Real-time analytics dashboard with visitor stats and GitHub stars |
| `TagPage.tsx` | Tag archive page displaying posts filtered by a specific tag. Includes view mode toggle (list/cards) with localStorage persistence |
| `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. |
### Components (`src/components/`)
| File | Description |
| ------------------------- | ---------------------------------------------------------- |
| `Layout.tsx` | Page wrapper with logo in header (top-left), search button, theme toggle, mobile menu (left-aligned on mobile), and scroll-to-top. Combines Blog link, hardcoded nav items, and markdown pages for navigation. Logo reads from siteConfig.innerPageLogo |
| `ThemeToggle.tsx` | Theme switcher (dark/light/tan/cloud) |
| `PostList.tsx` | Year-grouped blog post list or card grid (supports list/cards view modes) |
| `BlogPost.tsx` | Markdown renderer with syntax highlighting, collapsible sections (details/summary), and text wrapping for plain text code blocks |
| `CopyPageDropdown.tsx` | Share dropdown with Copy page (markdown to clipboard), View as Markdown (opens raw .md file), Download as SKILL.md (Anthropic Agent Skills format), and Open in AI links (ChatGPT, Claude, Perplexity) using GitHub raw URLs with universal prompt |
| File | Description |
| ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `Layout.tsx` | Page wrapper with logo in header (top-left), search button, theme toggle, mobile menu (left-aligned on mobile), and scroll-to-top. Combines Blog link, hardcoded nav items, and markdown pages for navigation. Logo reads from siteConfig.innerPageLogo |
| `ThemeToggle.tsx` | Theme switcher (dark/light/tan/cloud) |
| `PostList.tsx` | Year-grouped blog post list or card grid (supports list/cards view modes) |
| `BlogPost.tsx` | Markdown renderer with syntax highlighting, collapsible sections (details/summary), and text wrapping for plain text code blocks |
| `CopyPageDropdown.tsx` | Share dropdown with Copy page (markdown to clipboard), View as Markdown (opens raw .md file), Download as SKILL.md (Anthropic Agent Skills format), and Open in AI links (ChatGPT, Claude, Perplexity) using GitHub raw URLs with universal prompt |
| `Footer.tsx` | Footer component that renders markdown content from frontmatter footer field or siteConfig.defaultContent. Can be enabled/disabled globally and per-page via frontmatter showFooter field. Renders inside article at bottom for posts/pages, and in current position on homepage. Supports images with size control via HTML attributes (width, height, style, class) |
| `SearchModal.tsx` | Full text search modal with keyboard navigation |
| `FeaturedCards.tsx` | Card grid for featured posts/pages with excerpts |
| `LogoMarquee.tsx` | Scrolling logo gallery with clickable links |
| `MobileMenu.tsx` | Slide-out drawer menu for mobile navigation with hamburger button, includes sidebar table of contents when page has sidebar layout |
| `ScrollToTop.tsx` | Configurable scroll-to-top button with Phosphor ArrowUp icon |
| `GitHubContributions.tsx` | GitHub activity graph with theme-aware colors and year navigation |
| `VisitorMap.tsx` | Real-time visitor location map with dotted world display and theme-aware colors |
| `PageSidebar.tsx` | Collapsible table of contents sidebar for pages/posts with sidebar layout, extracts headings (H1-H6), active heading highlighting, smooth scroll navigation, localStorage persistence for expanded/collapsed state |
| `RightSidebar.tsx` | Right sidebar component that displays CopyPageDropdown on posts/pages at 1135px+ viewport width, controlled by siteConfig.rightSidebar.enabled and frontmatter rightSidebar field |
| `SearchModal.tsx` | Full text search modal with keyboard navigation |
| `FeaturedCards.tsx` | Card grid for featured posts/pages with excerpts |
| `LogoMarquee.tsx` | Scrolling logo gallery with clickable links |
| `MobileMenu.tsx` | Slide-out drawer menu for mobile navigation with hamburger button, includes sidebar table of contents when page has sidebar layout |
| `ScrollToTop.tsx` | Configurable scroll-to-top button with Phosphor ArrowUp icon |
| `GitHubContributions.tsx` | GitHub activity graph with theme-aware colors and year navigation |
| `VisitorMap.tsx` | Real-time visitor location map with dotted world display and theme-aware colors |
| `PageSidebar.tsx` | Collapsible table of contents sidebar for pages/posts with sidebar layout, extracts headings (H1-H6), active heading highlighting, smooth scroll navigation, localStorage persistence for expanded/collapsed state |
| `RightSidebar.tsx` | Right sidebar component that displays CopyPageDropdown or AI chat on posts/pages at 1135px+ viewport width, controlled by siteConfig.rightSidebar.enabled and frontmatter rightSidebar/aiChat fields |
| `AIChatView.tsx` | AI chat interface component (Agent) using Anthropic Claude API. Supports per-page chat history, page content context, markdown rendering, and copy functionality. Used in Write page (replaces textarea when enabled) and optionally in RightSidebar. Requires ANTHROPIC_API_KEY environment variable in Convex. System prompt configurable via CLAUDE_PROMPT_STYLE, CLAUDE_PROMPT_COMMUNITY, CLAUDE_PROMPT_RULES, or CLAUDE_SYSTEM_PROMPT environment variables. Includes error handling for missing API keys. |
### Context (`src/context/`)
| File | Description |
| ------------------ | ---------------------------------------------------- |
| `ThemeContext.tsx` | Theme state management with localStorage persistence |
| `FontContext.tsx` | Font family state management (serif/sans/monospace) with localStorage persistence and siteConfig integration |
| `SidebarContext.tsx` | Shares sidebar headings and active ID between Post and Layout components for mobile menu integration |
| File | Description |
| -------------------- | ------------------------------------------------------------------------------------------------------------ |
| `ThemeContext.tsx` | Theme state management with localStorage persistence |
| `FontContext.tsx` | Font family state management (serif/sans/monospace) with localStorage persistence and siteConfig integration |
| `SidebarContext.tsx` | Shares sidebar headings and active ID between Post and Layout components for mobile menu integration |
### Utils (`src/utils/`)
| File | Description |
| --------------------- | -------------------------------------------------------------------- |
| `extractHeadings.ts` | Parses markdown content to extract headings (H1-H6), generates slugs, filters out headings inside code blocks |
| File | Description |
| -------------------- | ------------------------------------------------------------------------------------------------------------- |
| `extractHeadings.ts` | Parses markdown content to extract headings (H1-H6), generates slugs, filters out headings inside code blocks |
### Hooks (`src/hooks/`)
| File | Description |
| -------------------- | --------------------------------------------- |
| File | Description |
| -------------------- | ------------------------------------------------ |
| `usePageTracking.ts` | Page view recording and active session heartbeat |
### Styles (`src/styles/`)
| File | Description |
| ------------ | ------------------------------------------------------------------------------------ |
| `global.css` | Global CSS with theme variables, centralized font-size CSS variables for all themes, sidebar styling with alternate background colors, hidden scrollbar, and consistent borders using box-shadow for docs-style layout. Left sidebar (`.post-sidebar-wrapper`) and right sidebar (`.post-sidebar-right`) have separate, independent styles. Footer image styles (`.site-footer-image-wrapper`, `.site-footer-image`, `.site-footer-image-caption`) for responsive image display |
| File | Description |
| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `global.css` | Global CSS with theme variables, centralized font-size CSS variables for all themes, sidebar styling with alternate background colors, hidden scrollbar, and consistent borders using box-shadow for docs-style layout. Left sidebar (`.post-sidebar-wrapper`) and right sidebar (`.post-sidebar-right`) have separate, independent styles. Footer image styles (`.site-footer-image-wrapper`, `.site-footer-image`, `.site-footer-image-caption`) for responsive image display. Write page layout uses viewport height constraints (100vh) with overflow hidden to prevent page scroll, and AI chat uses flexbox with min-height: 0 for proper scrollable message area |
## Convex Backend (`convex/`)
| File | Description |
| ------------------ | -------------------------------------------------------------------- |
| `schema.ts` | Database schema (posts, pages, viewCounts, pageViews, activeSessions) with by_tags index for tag queries |
| `posts.ts` | Queries and mutations for blog posts, view counts, getAllTags, getPostsByTag, and getRelatedPosts |
| `pages.ts` | Queries and mutations for static pages |
| `search.ts` | Full text search queries across posts and pages |
| `stats.ts` | Real-time stats with aggregate component for O(log n) counts, page view recording, session heartbeat |
| `crons.ts` | Cron job for stale session cleanup |
| `http.ts` | HTTP endpoints: sitemap, API (update SITE_URL/SITE_NAME when forking, uses www.markdown.fast) |
| `rss.ts` | RSS feed generation (update SITE_URL/SITE_TITLE when forking, uses www.markdown.fast) |
| `convex.config.ts` | Convex app configuration with aggregate component registrations (pageViewsByPath, totalPageViews, uniqueVisitors) |
| `tsconfig.json` | Convex TypeScript configuration |
| File | Description |
| ------------------ | ------------------------------------------------------------------------------------------------------------------ |
| `schema.ts` | Database schema (posts, pages, viewCounts, pageViews, activeSessions, aiChats) with indexes for tag and AI queries |
| `posts.ts` | Queries and mutations for blog posts, view counts, getAllTags, getPostsByTag, and getRelatedPosts |
| `pages.ts` | Queries and mutations for static pages |
| `search.ts` | Full text search queries across posts and pages |
| `stats.ts` | Real-time stats with aggregate component for O(log n) counts, page view recording, session heartbeat |
| `crons.ts` | Cron job for stale session cleanup |
| `http.ts` | HTTP endpoints: sitemap, API (update SITE_URL/SITE_NAME when forking, uses www.markdown.fast) |
| `rss.ts` | RSS feed generation (update SITE_URL/SITE_TITLE when forking, uses www.markdown.fast) |
| `aiChats.ts` | Queries and mutations for AI chat history (per-session, per-context storage). Handles anonymous session IDs, per-page chat contexts, and message history management. Supports page content as context for AI responses. |
| `aiChatActions.ts` | Anthropic Claude API integration action for AI chat responses. Requires ANTHROPIC_API_KEY environment variable in Convex. Uses claude-sonnet-3-5-20240620 model. System prompt configurable via environment variables (CLAUDE_PROMPT_STYLE, CLAUDE_PROMPT_COMMUNITY, CLAUDE_PROMPT_RULES, or CLAUDE_SYSTEM_PROMPT). Includes error handling for missing API keys with user-friendly error messages. Supports page content context and chat history (last 20 messages). |
| `convex.config.ts` | Convex app configuration with aggregate component registrations (pageViewsByPath, totalPageViews, uniqueVisitors) |
| `tsconfig.json` | Convex TypeScript configuration |
### HTTP Endpoints (defined in `http.ts`)
| Route | Description |
| -------------------------- | -------------------------------------- |
| `/stats` | Real-time site analytics page |
| `/rss.xml` | RSS feed with descriptions |
| `/rss-full.xml` | RSS feed with full content for LLMs |
| `/sitemap.xml` | Dynamic XML sitemap for search engines (includes posts, pages, and tag pages) |
| `/api/posts` | JSON list of all posts |
| `/api/post` | Single post as JSON or markdown |
| `/api/export` | Batch export all posts with content |
| `/meta/post` | Open Graph HTML for social crawlers |
| `/.well-known/ai-plugin.json` | AI plugin manifest |
| `/openapi.yaml` | OpenAPI 3.0 specification |
| `/llms.txt` | AI agent discovery |
| Route | Description |
| ----------------------------- | ----------------------------------------------------------------------------- |
| `/stats` | Real-time site analytics page |
| `/rss.xml` | RSS feed with descriptions |
| `/rss-full.xml` | RSS feed with full content for LLMs |
| `/sitemap.xml` | Dynamic XML sitemap for search engines (includes posts, pages, and tag pages) |
| `/api/posts` | JSON list of all posts |
| `/api/post` | Single post as JSON or markdown |
| `/api/export` | Batch export all posts with content |
| `/meta/post` | Open Graph HTML for social crawlers |
| `/.well-known/ai-plugin.json` | AI plugin manifest |
| `/openapi.yaml` | OpenAPI 3.0 specification |
| `/llms.txt` | AI agent discovery |
## Content (`content/blog/`)
Markdown files with frontmatter for blog posts. Each file becomes a blog post.
| Field | Description |
| --------------- | ------------------------------------------- |
| `title` | Post title |
| `description` | Short description for SEO |
| `date` | Publication date (YYYY-MM-DD) |
| `slug` | URL path for the post |
| `published` | Whether post is public |
| `tags` | Array of topic tags |
| `readTime` | Estimated reading time |
| `image` | Header/Open Graph image URL (optional) |
| `excerpt` | Short excerpt for card view (optional) |
| `featured` | Show in featured section (optional) |
| `featuredOrder` | Order in featured section (optional) |
| `authorName` | Author display name (optional) |
| `authorImage` | Round author avatar image URL (optional) |
| `rightSidebar` | Enable right sidebar with CopyPageDropdown (optional) |
| `showFooter` | Show footer on this post (optional, overrides siteConfig default) |
| Field | Description |
| --------------- | ----------------------------------------------------------------------- |
| `title` | Post title |
| `description` | Short description for SEO |
| `date` | Publication date (YYYY-MM-DD) |
| `slug` | URL path for the post |
| `published` | Whether post is public |
| `tags` | Array of topic tags |
| `readTime` | Estimated reading time |
| `image` | Header/Open Graph image URL (optional) |
| `excerpt` | Short excerpt for card view (optional) |
| `featured` | Show in featured section (optional) |
| `featuredOrder` | Order in featured section (optional) |
| `authorName` | Author display name (optional) |
| `authorImage` | Round author avatar image URL (optional) |
| `rightSidebar` | Enable right sidebar with CopyPageDropdown (optional) |
| `showFooter` | Show footer on this post (optional, overrides siteConfig default) |
| `footer` | Footer markdown content (optional, overrides siteConfig.defaultContent) |
| `aiChat` | Enable AI Agent chat in right sidebar (optional, requires rightSidebar: true and siteConfig.aiChat.enabledOnContent: true) |
## Static Pages (`content/pages/`)
Markdown files for static pages like About, Projects, Contact, Changelog.
| Field | Description |
| --------------- | ----------------------------------------- |
| `title` | Page title |
| `slug` | URL path for the page |
| `published` | Whether page is public |
| `order` | Display order in navigation (lower first) |
| `showInNav` | Show in navigation menu (default: true) |
| `excerpt` | Short excerpt for card view (optional) |
| `featured` | Show in featured section (optional) |
| `featuredOrder` | Order in featured section (optional) |
| `authorName` | Author display name (optional) |
| `authorImage` | Round author avatar image URL (optional) |
| `rightSidebar` | Enable right sidebar with CopyPageDropdown (optional) |
| `showFooter` | Show footer on this page (optional, overrides siteConfig default) |
| Field | Description |
| --------------- | ----------------------------------------------------------------------- |
| `title` | Page title |
| `slug` | URL path for the page |
| `published` | Whether page is public |
| `order` | Display order in navigation (lower first) |
| `showInNav` | Show in navigation menu (default: true) |
| `excerpt` | Short excerpt for card view (optional) |
| `featured` | Show in featured section (optional) |
| `featuredOrder` | Order in featured section (optional) |
| `authorName` | Author display name (optional) |
| `authorImage` | Round author avatar image URL (optional) |
| `rightSidebar` | Enable right sidebar with CopyPageDropdown (optional) |
| `showFooter` | Show footer on this page (optional, overrides siteConfig default) |
| `footer` | Footer markdown content (optional, overrides siteConfig.defaultContent) |
| `aiChat` | Enable AI Agent chat in right sidebar (optional, requires rightSidebar: true and siteConfig.aiChat.enabledOnContent: true) |
## Scripts (`scripts/`)
| File | Description |
| -------------------------- | ---------------------------------------------------------- |
| `sync-posts.ts` | Syncs markdown files to Convex at build time |
| `sync-discovery-files.ts` | Updates AGENTS.md and llms.txt with current app data |
| `import-url.ts` | Imports external URLs as markdown posts (Firecrawl) |
| `configure-fork.ts` | Automated fork configuration (reads fork-config.json) |
| File | Description |
| ------------------------- | ----------------------------------------------------- |
| `sync-posts.ts` | Syncs markdown files to Convex at build time |
| `sync-discovery-files.ts` | Updates AGENTS.md and llms.txt with current app data |
| `import-url.ts` | Imports external URLs as markdown posts (Firecrawl) |
| `configure-fork.ts` | Automated fork configuration (reads fork-config.json) |
### Sync Commands
**Development:**
- `npm run sync` - Sync markdown content to development Convex
- `npm run sync:discovery` - Update discovery files (AGENTS.md, llms.txt) with development data
**Production:**
- `npm run sync:prod` - Sync markdown content to production Convex
- `npm run sync:discovery:prod` - Update discovery files with production data
**Sync everything together:**
- `npm run sync:all` - Run both content sync and discovery sync (development)
- `npm run sync:all:prod` - Run both content sync and discovery sync (production)
@@ -206,31 +214,31 @@ Frontmatter is the YAML metadata at the top of each markdown file. Here is how i
## Netlify (`netlify/edge-functions/`)
| File | Description |
| ------------ | ------------------------------------------------------------ |
| File | Description |
| ------------ | -------------------------------------------------------------------------------------------------------------- |
| `botMeta.ts` | Edge function for social media crawler detection, excludes `/raw/*` paths and AI crawlers from OG interception |
| `rss.ts` | Proxies `/rss.xml` and `/rss-full.xml` to Convex HTTP |
| `sitemap.ts` | Proxies `/sitemap.xml` to Convex HTTP |
| `api.ts` | Proxies `/api/posts`, `/api/post`, `/api/export` to Convex |
| `geo.ts` | Returns user geo location from Netlify's automatic geo headers for visitor map |
| `rss.ts` | Proxies `/rss.xml` and `/rss-full.xml` to Convex HTTP |
| `sitemap.ts` | Proxies `/sitemap.xml` to Convex HTTP |
| `api.ts` | Proxies `/api/posts`, `/api/post`, `/api/export` to Convex |
| `geo.ts` | Returns user geo location from Netlify's automatic geo headers for visitor map |
## Public Assets (`public/`)
| File | Description |
| -------------- | ---------------------------------------------- |
| `favicon.svg` | Site favicon |
| `_redirects` | SPA redirect rules for static files |
| File | Description |
| -------------- | ------------------------------------------------------------------------------------------------------ |
| `favicon.svg` | Site favicon |
| `_redirects` | SPA redirect rules for static files |
| `robots.txt` | Crawler rules for search engines and AI bots (update sitemap URL when forking, uses www.markdown.fast) |
| `llms.txt` | AI agent discovery file (update site name/URL when forking, uses www.markdown.fast) |
| `openapi.yaml` | OpenAPI 3.0 specification (update API title when forking, uses www.markdown.fast) |
| `llms.txt` | AI agent discovery file (update site name/URL when forking, uses www.markdown.fast) |
| `openapi.yaml` | OpenAPI 3.0 specification (update API title when forking, uses www.markdown.fast) |
### Raw Markdown Files (`public/raw/`)
Static markdown files generated during `npm run sync` or `npm run sync:prod`. Each published post and page gets a corresponding `.md` file for direct access by users, search engines, and AI agents.
| File Pattern | Description |
| -------------- | ---------------------------------------------- |
| `{slug}.md` | Static markdown file for each post/page |
| File Pattern | Description |
| ------------ | --------------------------------------- |
| `{slug}.md` | Static markdown file for each post/page |
Access via `/raw/{slug}.md` (e.g., `/raw/setup-guide.md`).
@@ -238,9 +246,9 @@ Files include a metadata header with type (post/page), date, reading time, and t
### AI Plugin (`public/.well-known/`)
| File | Description |
| ----------------- | ----------------------------------------------------- |
| `ai-plugin.json` | AI plugin manifest (update name/description when forking) |
| File | Description |
| ---------------- | --------------------------------------------------------- |
| `ai-plugin.json` | AI plugin manifest (update name/description when forking) |
### Images (`public/images/`)
@@ -252,23 +260,23 @@ Files include a metadata header with type (post/page), date, reading time, and t
### Logo Gallery (`public/images/logos/`)
| File | Description |
| -------------------- | ---------------------------------------- |
| `sample-logo-1.svg` | Sample logo (replace with your own) |
| `sample-logo-2.svg` | Sample logo (replace with your own) |
| `sample-logo-3.svg` | Sample logo (replace with your own) |
| `sample-logo-4.svg` | Sample logo (replace with your own) |
| `sample-logo-5.svg` | Sample logo (replace with your own) |
| File | Description |
| ------------------- | ----------------------------------- |
| `sample-logo-1.svg` | Sample logo (replace with your own) |
| `sample-logo-2.svg` | Sample logo (replace with your own) |
| `sample-logo-3.svg` | Sample logo (replace with your own) |
| `sample-logo-4.svg` | Sample logo (replace with your own) |
| `sample-logo-5.svg` | Sample logo (replace with your own) |
## Cursor Rules (`.cursor/rules/`)
| File | Description |
| -------------------------- | ------------------------------------------------ |
| `convex-write-conflicts.mdc` | Write conflict prevention patterns for Convex |
| `convex2.mdc` | Convex function syntax and examples |
| `dev2.mdc` | Development guidelines and best practices |
| `help.mdc` | Core development guidelines |
| `rulesforconvex.mdc` | Convex schema and function best practices |
| `sec-check.mdc` | Security guidelines and audit checklist |
| `task.mdc` | Task list management guidelines |
| `write.mdc` | Writing style guide (activate with @write) |
| File | Description |
| ---------------------------- | --------------------------------------------- |
| `convex-write-conflicts.mdc` | Write conflict prevention patterns for Convex |
| `convex2.mdc` | Convex function syntax and examples |
| `dev2.mdc` | Development guidelines and best practices |
| `help.mdc` | Core development guidelines |
| `rulesforconvex.mdc` | Convex schema and function best practices |
| `sec-check.mdc` | Security guidelines and audit checklist |
| `task.mdc` | Task list management guidelines |
| `write.mdc` | Writing style guide (activate with @write) |

View File

@@ -50,6 +50,11 @@
"featuredViewMode": "cards",
"showViewToggle": true,
"theme": "tan",
"fontFamily": "serif"
"fontFamily": "serif",
"homepage": {
"type": "default",
"slug": null,
"originalHomeRoute": "/home"
}
}

40
package-lock.json generated
View File

@@ -8,6 +8,7 @@
"name": "markdown-site",
"version": "1.0.0",
"dependencies": {
"@anthropic-ai/sdk": "^0.71.2",
"@convex-dev/aggregate": "^0.2.0",
"@mendable/firecrawl-js": "^1.21.1",
"@phosphor-icons/react": "^2.1.10",
@@ -43,6 +44,26 @@
"vite": "^5.1.4"
}
},
"node_modules/@anthropic-ai/sdk": {
"version": "0.71.2",
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.71.2.tgz",
"integrity": "sha512-TGNDEUuEstk/DKu0/TflXAEt+p+p/WhTlFzEnoosvbaDU2LTjm42igSdlL0VijrKpWejtOKxX0b8A7uc+XiSAQ==",
"license": "MIT",
"dependencies": {
"json-schema-to-ts": "^3.1.1"
},
"bin": {
"anthropic-ai-sdk": "bin/cli"
},
"peerDependencies": {
"zod": "^3.25.0 || ^4.0.0"
},
"peerDependenciesMeta": {
"zod": {
"optional": true
}
}
},
"node_modules/@babel/code-frame": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
@@ -3608,6 +3629,19 @@
"dev": true,
"license": "MIT"
},
"node_modules/json-schema-to-ts": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz",
"integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.18.3",
"ts-algebra": "^2.0.0"
},
"engines": {
"node": ">=16"
}
},
"node_modules/json-schema-traverse": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
@@ -5669,6 +5703,12 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/ts-algebra": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz",
"integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==",
"license": "MIT"
},
"node_modules/ts-api-utils": {
"version": "1.4.3",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz",

View File

@@ -22,6 +22,7 @@
"deploy:prod": "npx convex deploy && npm run sync:prod"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.71.2",
"@convex-dev/aggregate": "^0.2.0",
"@mendable/firecrawl-js": "^1.21.1",
"@phosphor-icons/react": "^2.1.10",

View File

@@ -1,6 +1,6 @@
# llms.txt - Information for AI assistants and LLMs
# Learn more: https://llmstxt.org/
# Last updated: 2025-12-25T20:18:52.317Z
# Last updated: 2025-12-26T20:30:35.292Z
> Your content is instantly available to browsers, LLMs, and AI agents.

View File

@@ -8,6 +8,64 @@ Date: 2025-12-26
All notable changes to this project.
![](https://img.shields.io/badge/License-MIT-yellow.svg)
## v1.33.0
Released December 26, 2025
**AI Chat Write Agent (Agent) integration**
- AI Agent chat interface powered by Anthropic Claude API
- New `AIChatView` component for AI-powered chat interface
- Available on Write page (replaces textarea when enabled) and optionally in RightSidebar on posts/pages
- Per-session, per-context chat history stored in Convex (aiChats table)
- Supports page content as context for AI responses
- Markdown rendering for AI responses with copy functionality
- Theme-aware styling matching the site's design system
- Uses Phosphor Icons for all UI elements
- Convex backend for AI chat
- New `convex/aiChats.ts` with queries and mutations for chat history
- New `convex/aiChatActions.ts` with Claude API integration (requires ANTHROPIC_API_KEY environment variable)
- System prompt configurable via Convex environment variables:
- `CLAUDE_PROMPT_STYLE`, `CLAUDE_PROMPT_COMMUNITY`, `CLAUDE_PROMPT_RULES` (split prompts, joined with separators)
- `CLAUDE_SYSTEM_PROMPT` (single prompt, fallback if split prompts not set)
- Chat history limited to last 20 messages for context efficiency
- Error handling: displays "API key is not set" message when ANTHROPIC_API_KEY is missing in Convex environment variables
- Configuration options
- `siteConfig.aiChat` interface with `enabledOnWritePage` and `enabledOnContent` boolean flags
- Both flags default to false (opt-in feature)
- New `aiChat` frontmatter field for posts and pages (requires rightSidebar: true)
- Write page AI Agent mode
- Title changes from "Blog Post" or "Page" to "Agent" when in AI chat mode
- Toggle button text changes between "Agent" and "Text Editor"
- Page scroll prevention when switching modes (no page jump)
- RightSidebar AI chat support
- Conditionally renders AIChatView when enabled via frontmatter `aiChat: true` field
- Requires both `siteConfig.aiChat.enabledOnContent` and frontmatter `aiChat: true`
- Passes page content as context for AI responses
Updated files: `src/components/AIChatView.tsx`, `src/components/RightSidebar.tsx`, `src/pages/Write.tsx`, `src/pages/Post.tsx`, `src/config/siteConfig.ts`, `convex/schema.ts`, `convex/aiChats.ts`, `convex/aiChatActions.ts`, `convex/posts.ts`, `convex/pages.ts`, `scripts/sync-posts.ts`, `src/styles/global.css`, `package.json`
Documentation updated: `files.md`, `changelog.md`, `README.md`, `content/blog/setup-guide.md`, `public/raw/docs.md`
## v1.32.0
Released December 25, 2025
**Custom homepage configuration**
- Set any page or blog post to serve as the homepage instead of the default Home component
- Configure via `siteConfig.homepage` with `type` ("default", "page", or "post"), `slug` (required for page/post), and `originalHomeRoute` (default: "/home")
- Custom homepage retains all Post component features (sidebar, copy dropdown, author info, footer) but without the featured section
- Original homepage remains accessible at `/home` route (or configured `originalHomeRoute`) when custom homepage is set
- SEO metadata uses the page/post's frontmatter when used as homepage
- Back button hidden when Post component is used as homepage
- Fork configuration support for homepage
- Added `homepage` field to `fork-config.json.example`
- Updated `configure-fork.ts` to handle homepage configuration
- Documentation added to `FORK_CONFIG.md` with usage examples
Updated files: `src/App.tsx`, `src/pages/Post.tsx`, `src/config/siteConfig.ts`, `scripts/configure-fork.ts`, `fork-config.json.example`, `FORK_CONFIG.md`
## v1.31.1
Released December 25, 2025

View File

@@ -5,6 +5,8 @@ Type: page
Date: 2025-12-26
---
## Getting Started
Reference documentation for setting up, customizing, and deploying this markdown framework.
**How publishing works:** Write posts in markdown, run `npm run sync` for development or `npm run sync:prod` for production, and they appear on your live site immediately. No rebuild or redeploy needed. Convex handles real-time data sync, so connected browsers update automatically.

View File

@@ -1040,6 +1040,8 @@ Pages appear automatically in the navigation when published.
**Right sidebar:** When enabled in `siteConfig.rightSidebar.enabled`, posts and pages can display a right sidebar containing the CopyPageDropdown at 1135px+ viewport width. Add `rightSidebar: true` to frontmatter to enable. Without this field, pages render normally with CopyPageDropdown in the nav bar. When enabled, CopyPageDropdown moves from the navigation bar to the right sidebar on wide screens. The right sidebar is hidden below 1135px, and CopyPageDropdown returns to the nav bar automatically.
**AI Agent chat:** The site includes an AI writing assistant (Agent) powered by Anthropic Claude API. Enable Agent on the Write page via `siteConfig.aiChat.enabledOnWritePage` or in the right sidebar on posts/pages using `aiChat: true` frontmatter (requires `rightSidebar: true`). Requires `ANTHROPIC_API_KEY` environment variable in Convex. See the [AI Agent chat section](#ai-agent-chat) below for setup instructions.
### Update SEO Meta Tags
Edit `index.html` to update:
@@ -1287,6 +1289,72 @@ A markdown writing page is available at `/write` (not linked in navigation). Use
Content is stored in localStorage only and not synced to the database. Refreshing the page preserves your content, but clearing browser data will lose it.
**AI Agent mode:** When `siteConfig.aiChat.enabledOnWritePage` is enabled, a toggle button appears in the Actions section. Clicking it replaces the textarea with the AI Agent chat interface. The page title changes to "Agent" when in chat mode. Requires `ANTHROPIC_API_KEY` environment variable in Convex. See the [AI Agent chat section](#ai-agent-chat) below for setup instructions.
## AI Agent chat
The site includes an AI writing assistant (Agent) powered by Anthropic Claude API. Agent can be enabled in two places:
**1. Write page (`/write`)**
Enable Agent mode on the Write page via `siteConfig.aiChat.enabledOnWritePage`. When enabled, a toggle button appears in the Actions section. Clicking it replaces the textarea with the Agent chat interface. The page title changes to "Agent" when in chat mode.
**Configuration:**
```typescript
// src/config/siteConfig.ts
aiChat: {
enabledOnWritePage: true, // Enable Agent toggle on /write page
enabledOnContent: true, // Allow Agent on posts/pages via frontmatter
},
```
**2. Right sidebar on posts/pages**
Enable Agent in the right sidebar on individual posts or pages using the `aiChat` frontmatter field. Requires both `rightSidebar: true` and `siteConfig.aiChat.enabledOnContent: true`.
**Frontmatter example:**
```markdown
---
title: "My Post"
rightSidebar: true
aiChat: true # Enable Agent in right sidebar
---
```
**Environment variables:**
Agent requires the following Convex environment variables:
- `ANTHROPIC_API_KEY` (required): Your Anthropic API key for Claude API access
- `CLAUDE_PROMPT_STYLE` (optional): First part of system prompt
- `CLAUDE_PROMPT_COMMUNITY` (optional): Second part of system prompt
- `CLAUDE_PROMPT_RULES` (optional): Third part of system prompt
- `CLAUDE_SYSTEM_PROMPT` (optional): Single system prompt (fallback if split prompts not set)
**Setting environment variables:**
1. Go to [Convex Dashboard](https://dashboard.convex.dev)
2. Select your project
3. Navigate to Settings > Environment Variables
4. Add `ANTHROPIC_API_KEY` with your API key value
5. Optionally add system prompt variables (`CLAUDE_PROMPT_STYLE`, etc.)
6. Deploy changes
**How it works:**
- Agent uses anonymous session IDs stored in localStorage for chat history
- Each post/page has its own chat context (identified by slug)
- Chat history is stored per-session, per-context in Convex (aiChats table)
- Page content can be provided as context for AI responses
- Chat history limited to last 20 messages for efficiency
- If API key is not set, Agent displays "API key is not set" error message
**Error handling:**
If `ANTHROPIC_API_KEY` is not configured in Convex environment variables, Agent displays a user-friendly error message: "API key is not set". This helps identify when the API key is missing in production deployments.
## Next Steps
After deploying:

View File

@@ -73,6 +73,11 @@ interface ForkConfig {
showViewToggle?: boolean;
theme?: "dark" | "light" | "tan" | "cloud";
fontFamily?: "serif" | "sans" | "monospace";
homepage?: {
type: "default" | "page" | "post";
slug?: string;
originalHomeRoute?: string;
};
}
// Get project root directory
@@ -261,6 +266,26 @@ function updateSiteConfig(config: ForkConfig): void {
);
}
// Update homepage configuration if specified
if (config.homepage) {
content = content.replace(
/type: ['"](?:default|page|post)['"],\s*\/\/ Options: "default" \(standard Home component\), "page" \(use a static page\), or "post" \(use a blog post\)/,
`type: "${config.homepage.type}", // Options: "default" (standard Home component), "page" (use a static page), or "post" (use a blog post)`,
);
if (config.homepage.slug !== undefined) {
content = content.replace(
/slug: (?:undefined|['"].*?['"]),\s*\/\/ Required if type is "page" or "post" - the slug of the page\/post to use/,
`slug: ${config.homepage.slug ? `"${config.homepage.slug}"` : "undefined"}, // Required if type is "page" or "post" - the slug of the page/post to use`,
);
}
if (config.homepage.originalHomeRoute !== undefined) {
content = content.replace(
/originalHomeRoute: ['"].*?['"],\s*\/\/ Route to access the original homepage when custom homepage is set/,
`originalHomeRoute: "${config.homepage.originalHomeRoute}", // Route to access the original homepage when custom homepage is set`,
);
}
}
// Update gitHubRepo config (for AI service raw URLs)
// Support both new gitHubRepoConfig and legacy githubUsername/githubRepo fields
const gitHubRepoOwner =

View File

@@ -38,6 +38,7 @@ interface PostFrontmatter {
authorImage?: string; // Author avatar image URL (round)
layout?: string; // Layout type: "sidebar" for docs-style layout
rightSidebar?: boolean; // Enable right sidebar with CopyPageDropdown (default: true when siteConfig.rightSidebar.enabled)
aiChat?: boolean; // Enable AI chat in right sidebar (requires rightSidebar: true)
}
interface ParsedPost {
@@ -59,6 +60,7 @@ interface ParsedPost {
rightSidebar?: boolean; // Enable right sidebar with CopyPageDropdown (default: true when siteConfig.rightSidebar.enabled)
showFooter?: boolean; // Show footer on this post (overrides siteConfig default)
footer?: string; // Footer markdown content (overrides siteConfig defaultContent)
aiChat?: boolean; // Enable AI chat in right sidebar (requires rightSidebar: true)
}
// Page frontmatter (for static pages like About, Projects, Contact)
@@ -77,6 +79,7 @@ interface PageFrontmatter {
layout?: string; // Layout type: "sidebar" for docs-style layout
rightSidebar?: boolean; // Enable right sidebar with CopyPageDropdown (default: true when siteConfig.rightSidebar.enabled)
showFooter?: boolean; // Show footer on this page (overrides siteConfig default)
aiChat?: boolean; // Enable AI chat in right sidebar (requires rightSidebar: true)
}
interface ParsedPage {
@@ -95,6 +98,7 @@ interface ParsedPage {
layout?: string; // Layout type: "sidebar" for docs-style layout
rightSidebar?: boolean; // Enable right sidebar with CopyPageDropdown (default: true when siteConfig.rightSidebar.enabled)
showFooter?: boolean; // Show footer on this page (overrides siteConfig default)
aiChat?: boolean; // Enable AI chat in right sidebar (requires rightSidebar: true)
}
// Calculate reading time based on word count
@@ -138,6 +142,7 @@ function parseMarkdownFile(filePath: string): ParsedPost | null {
rightSidebar: frontmatter.rightSidebar, // Enable right sidebar with CopyPageDropdown
showFooter: frontmatter.showFooter, // Show footer on this post
footer: frontmatter.footer, // Footer markdown content
aiChat: frontmatter.aiChat, // Enable AI chat in right sidebar
};
} catch (error) {
console.error(`Error parsing ${filePath}:`, error);
@@ -191,6 +196,7 @@ function parsePageFile(filePath: string): ParsedPage | null {
layout: frontmatter.layout, // Layout type: "sidebar" for docs-style layout
rightSidebar: frontmatter.rightSidebar, // Enable right sidebar with CopyPageDropdown
showFooter: frontmatter.showFooter, // Show footer on this page
aiChat: frontmatter.aiChat, // Enable AI chat in right sidebar
};
} catch (error) {
console.error(`Error parsing page ${filePath}:`, error);

View File

@@ -20,11 +20,40 @@ function App() {
return <Write />;
}
// Determine if we should use a custom homepage
const useCustomHomepage =
siteConfig.homepage.type !== "default" && siteConfig.homepage.slug;
return (
<SidebarProvider>
<Layout>
<Routes>
<Route path="/" element={<Home />} />
{/* Homepage route - either default Home or custom page/post */}
<Route
path="/"
element={
useCustomHomepage ? (
<Post
slug={siteConfig.homepage.slug!}
isHomepage={true}
homepageType={
siteConfig.homepage.type === "default"
? undefined
: siteConfig.homepage.type
}
/>
) : (
<Home />
)
}
/>
{/* Original homepage route (when custom homepage is set) */}
{useCustomHomepage && (
<Route
path={siteConfig.homepage.originalHomeRoute || "/home"}
element={<Home />}
/>
)}
<Route path="/stats" element={<Stats />} />
{/* Blog page route - only enabled when blogPage.enabled is true */}
{siteConfig.blogPage.enabled && (

View File

@@ -0,0 +1,719 @@
import { useState, useRef, useEffect, useCallback } from "react";
import { useQuery, useMutation, useAction } from "convex/react";
import { api } from "../../convex/_generated/api";
import type { Id } from "../../convex/_generated/dataModel";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import {
PaperPlaneTilt,
Copy,
Check,
Stop,
Trash,
FileText,
SpinnerGap,
Image,
Link,
X,
} from "@phosphor-icons/react";
// Generate a unique session ID for anonymous users
function getSessionId(): string {
const key = "ai_chat_session_id";
let sessionId = localStorage.getItem(key);
if (!sessionId) {
sessionId = crypto.randomUUID();
localStorage.setItem(key, sessionId);
}
return sessionId;
}
interface AIChatViewProps {
contextId: string; // Slug or "write-page"
pageContent?: string; // Optional page content for context
onClose?: () => void; // Optional close handler
hideAttachments?: boolean; // Hide image/link attachment buttons (for right sidebar)
}
export default function AIChatView({
contextId,
pageContent,
onClose,
hideAttachments = false,
}: AIChatViewProps) {
// State
const [inputValue, setInputValue] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [isStopped, setIsStopped] = useState(false);
const [copiedMessageIndex, setCopiedMessageIndex] = useState<number | null>(
null,
);
const [chatId, setChatId] = useState<Id<"aiChats"> | null>(null);
const [hasLoadedContext, setHasLoadedContext] = useState(false);
const [error, setError] = useState<string | null>(null);
const [attachments, setAttachments] = useState<
Array<{
type: "image" | "link";
storageId?: Id<"_storage">;
url?: string;
file?: File;
preview?: string;
scrapedContent?: string;
title?: string;
}>
>([]);
const [isUploading, setIsUploading] = useState(false);
const [linkInputValue, setLinkInputValue] = useState("");
const [showLinkModal, setShowLinkModal] = useState(false);
// Refs
const messagesEndRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const abortControllerRef = useRef<AbortController | null>(null);
// Session ID
const sessionId = getSessionId();
// Convex hooks
const chat = useQuery(
api.aiChats.getAIChatByContext,
chatId ? { sessionId, contextId } : "skip",
);
const getOrCreateChat = useMutation(api.aiChats.getOrCreateAIChat);
const addUserMessage = useMutation(api.aiChats.addUserMessage);
const addUserMessageWithAttachments = useMutation(
api.aiChats.addUserMessageWithAttachments,
);
const generateUploadUrl = useMutation(api.aiChats.generateUploadUrl);
const clearChatMutation = useMutation(api.aiChats.clearChat);
const setPageContext = useMutation(api.aiChats.setPageContext);
const generateResponse = useAction(api.aiChatActions.generateResponse);
// Initialize chat
useEffect(() => {
const initChat = async () => {
const id = await getOrCreateChat({ sessionId, contextId });
setChatId(id);
};
initChat();
}, [sessionId, contextId, getOrCreateChat]);
// Auto-scroll to bottom when new messages arrive
useEffect(() => {
if (messagesEndRef.current) {
messagesEndRef.current.scrollIntoView({ behavior: "smooth" });
}
}, [chat?.messages]);
// Prevent page scroll when clicking input container
const handleInputContainerClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
// Only prevent if clicking the container itself, not the textarea
if (e.target === e.currentTarget) {
e.preventDefault();
e.stopPropagation();
textareaRef.current?.focus({ preventScroll: true });
}
},
[],
);
// Focus input after mount with delay to prevent scroll jump
useEffect(() => {
// Use setTimeout to delay focus until after layout settles
const timeoutId = setTimeout(() => {
if (textareaRef.current) {
textareaRef.current.focus({ preventScroll: true });
}
}, 100);
return () => clearTimeout(timeoutId);
}, []);
// Auto-resize textarea
const adjustTextareaHeight = useCallback(() => {
const textarea = textareaRef.current;
if (textarea) {
textarea.style.height = "auto";
textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`;
}
}, []);
useEffect(() => {
adjustTextareaHeight();
}, [inputValue, adjustTextareaHeight]);
// Handle keyboard shortcuts
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// "/" to focus input (when not in input/textarea)
if (
e.key === "/" &&
document.activeElement?.tagName !== "INPUT" &&
document.activeElement?.tagName !== "TEXTAREA"
) {
e.preventDefault();
textareaRef.current?.focus({ preventScroll: true });
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, []);
// Handle image upload
const handleImageUpload = async (file: File) => {
if (!chatId) return;
// Check attachment limit (max 3 images)
const currentImageCount = attachments.filter(
(a) => a.type === "image",
).length;
if (currentImageCount >= 3) {
alert("Maximum 3 images per message");
return;
}
// Validate file type
const validTypes = ["image/png", "image/jpeg", "image/gif", "image/webp"];
if (!validTypes.includes(file.type)) {
alert("Please upload a PNG, JPEG, GIF, or WebP image");
return;
}
// Validate file size (3MB max)
const maxSize = 3 * 1024 * 1024;
if (file.size > maxSize) {
alert("Image must be smaller than 3MB");
return;
}
setIsUploading(true);
try {
// Get upload URL
const uploadUrl = await generateUploadUrl();
// Upload file
const result = await fetch(uploadUrl, {
method: "POST",
headers: { "Content-Type": file.type },
body: file,
});
if (!result.ok) {
throw new Error("Upload failed");
}
const response = await result.json();
const storageId = response.storageId;
// Add to attachments
const preview = URL.createObjectURL(file);
setAttachments((prev) => [
...prev,
{
type: "image",
storageId: storageId as Id<"_storage">,
file,
preview,
},
]);
} catch (error) {
console.error("Error uploading image:", error);
alert("Failed to upload image");
} finally {
setIsUploading(false);
}
};
// Handle link attachment
const handleAddLink = () => {
if (!linkInputValue.trim()) return;
// Check attachment limit (max 3 links)
const currentLinkCount = attachments.filter(
(a) => a.type === "link",
).length;
if (currentLinkCount >= 3) {
alert("Maximum 3 links per message");
return;
}
const url = linkInputValue.trim();
try {
new URL(url); // Validate URL
setAttachments((prev) => [
...prev,
{
type: "link",
url,
},
]);
setLinkInputValue("");
setShowLinkModal(false);
} catch {
alert("Please enter a valid URL");
}
};
// Remove attachment
const handleRemoveAttachment = (index: number) => {
setAttachments((prev) => {
const newAttachments = [...prev];
const removed = newAttachments[index];
if (removed.preview) {
URL.revokeObjectURL(removed.preview);
}
newAttachments.splice(index, 1);
return newAttachments;
});
};
// Handle send message
const handleSend = async () => {
if (
(!inputValue.trim() && attachments.length === 0) ||
!chatId ||
isLoading
)
return;
const message = inputValue.trim();
setInputValue("");
setIsStopped(false);
// Handle clear command
if (message.toLowerCase() === "clear") {
await clearChatMutation({ chatId });
setHasLoadedContext(false);
setAttachments([]);
return;
}
// Prepare attachments for sending
const attachmentsToSend = attachments.map((att) => ({
type: att.type as "image" | "link",
storageId: att.storageId,
url: att.url,
scrapedContent: att.scrapedContent,
title: att.title,
}));
// Add user message with attachments
if (attachmentsToSend.length > 0) {
await addUserMessageWithAttachments({
chatId,
content: message || "",
attachments: attachmentsToSend,
});
} else {
await addUserMessage({ chatId, content: message });
}
// Clear attachments
attachments.forEach((att) => {
if (att.preview) {
URL.revokeObjectURL(att.preview);
}
});
setAttachments([]);
// Generate AI response
setIsLoading(true);
setIsStopped(false);
setError(null);
abortControllerRef.current = new AbortController();
try {
await generateResponse({
chatId,
userMessage: message || "",
pageContext: hasLoadedContext ? undefined : pageContent,
attachments:
attachmentsToSend.length > 0 ? attachmentsToSend : undefined,
});
} catch (error) {
if ((error as Error).name !== "AbortError") {
const errorMessage =
(error as Error).message || "Failed to generate response";
setError(errorMessage);
console.error("Error generating response:", error);
}
} finally {
setIsLoading(false);
abortControllerRef.current = null;
}
};
// Handle stop generation
const handleStop = () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
setIsStopped(true);
setIsLoading(false);
}
};
// Handle key press in textarea
const handleKeyPress = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
// Handle copy message
const handleCopy = async (content: string, index: number) => {
await navigator.clipboard.writeText(content);
setCopiedMessageIndex(index);
setTimeout(() => setCopiedMessageIndex(null), 2000);
};
// Handle load page context
const handleLoadContext = async () => {
if (!chatId || !pageContent) return;
await setPageContext({ chatId, pageContext: pageContent });
setHasLoadedContext(true);
};
// Handle clear chat
const handleClear = async () => {
if (!chatId) return;
await clearChatMutation({ chatId });
setHasLoadedContext(false);
setError(null);
};
const messages = chat?.messages || [];
// Component to render image attachment with URL fetching
const ImageAttachment = ({ storageId }: { storageId: Id<"_storage"> }) => {
const imageUrl = useQuery(api.aiChats.getStorageUrl, { storageId });
if (!imageUrl) {
return <div className="ai-chat-attachment-loading">Loading image...</div>;
}
return (
<img
src={imageUrl}
alt="Attachment"
className="ai-chat-attachment-image"
loading="lazy"
/>
);
};
return (
<div className="ai-chat-view">
{/* Header with actions */}
<div className="ai-chat-header">
<span className="ai-chat-title">Agent</span>
<div className="ai-chat-header-actions">
{pageContent && !hasLoadedContext && (
<button
className="ai-chat-load-context-button"
onClick={handleLoadContext}
title="Load page content as context"
>
<FileText size={16} weight="bold" />
<span>Load Page</span>
</button>
)}
{pageContent && hasLoadedContext && (
<span className="ai-chat-context-loaded">
<Check size={14} weight="bold" />
Context loaded
</span>
)}
<button
className="ai-chat-clear-button"
onClick={handleClear}
title="Clear chat (or type 'clear')"
disabled={messages.length === 0}
>
<Trash size={16} weight="bold" />
</button>
{onClose && (
<button
className="ai-chat-close-button"
onClick={onClose}
title="Close chat"
>
Back to Editor
</button>
)}
</div>
</div>
{/* Messages */}
<div className="ai-chat-messages">
{messages.length === 0 && !isLoading && (
<div className="ai-chat-empty">
<p>Ask a question.</p>
<p className="ai-chat-empty-hint">
Press Enter to send, Shift+Enter for new line, or type
&quot;clear&quot; to reset.
</p>
</div>
)}
{messages.map((message, index) => (
<div
key={`${message.timestamp}-${index}`}
className={`ai-chat-message ai-chat-message-${message.role}`}
>
<div className="ai-chat-message-content">
{message.role === "assistant" ? (
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{message.content}
</ReactMarkdown>
) : (
<>
{message.content && <p>{message.content}</p>}
{message.attachments && message.attachments.length > 0 && (
<div className="ai-chat-attachments">
{message.attachments.map((att, attIndex) => (
<div key={attIndex} className="ai-chat-attachment">
{att.type === "image" && att.storageId && (
<ImageAttachment storageId={att.storageId} />
)}
{att.type === "link" && att.url && (
<a
href={att.url}
target="_blank"
rel="noopener noreferrer"
className="ai-chat-attachment-link"
>
<Link size={16} />
<span>{att.title || att.url}</span>
</a>
)}
</div>
))}
</div>
)}
</>
)}
</div>
{message.role === "assistant" && (
<button
className="ai-chat-copy-button"
onClick={() => handleCopy(message.content, index)}
title="Copy message"
>
{copiedMessageIndex === index ? (
<Check size={14} weight="bold" />
) : (
<Copy size={14} weight="bold" />
)}
</button>
)}
</div>
))}
{/* Loading state */}
{isLoading && (
<div className="ai-chat-message ai-chat-message-assistant ai-chat-loading">
<div className="ai-chat-loading-content">
<SpinnerGap size={18} weight="bold" className="ai-chat-spinner" />
<span>Thinking...</span>
</div>
<button
className="ai-chat-stop-button"
onClick={handleStop}
title="Stop generating"
>
<Stop size={16} weight="bold" />
<span>Stop</span>
</button>
</div>
)}
{/* Stopped state */}
{isStopped && !isLoading && (
<div className="ai-chat-stopped">Generation stopped</div>
)}
{/* Error state */}
{error && (
<div className="ai-chat-message ai-chat-message-assistant ai-chat-error">
<div className="ai-chat-message-content">
<p style={{ margin: 0 }}>{error}</p>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* Attachments preview */}
{attachments.length > 0 && (
<div className="ai-chat-attachments-preview">
{attachments.map((att, index) => (
<div key={index} className="ai-chat-attachment-preview">
{att.type === "image" && att.preview && (
<>
<img
src={att.preview}
alt="Preview"
className="ai-chat-attachment-preview-image"
/>
<button
className="ai-chat-attachment-remove"
onClick={() => handleRemoveAttachment(index)}
title="Remove attachment"
>
<X size={14} weight="bold" />
</button>
</>
)}
{att.type === "link" && (
<>
<Link size={16} />
<span className="ai-chat-attachment-preview-url">
{att.url}
</span>
<button
className="ai-chat-attachment-remove"
onClick={() => handleRemoveAttachment(index)}
title="Remove attachment"
>
<X size={14} weight="bold" />
</button>
</>
)}
</div>
))}
</div>
)}
{/* Input area */}
<div
className="ai-chat-input-container"
onClick={handleInputContainerClick}
>
<div className="ai-chat-input-wrapper">
{!hideAttachments && (
<div className="ai-chat-input-actions">
<label
className="ai-chat-attach-button"
title="Upload image (max 3)"
>
<input
type="file"
accept="image/png,image/jpeg,image/gif,image/webp"
style={{ display: "none" }}
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
handleImageUpload(file);
}
e.target.value = ""; // Reset input
}}
disabled={
isLoading ||
isUploading ||
attachments.filter((a) => a.type === "image").length >= 3
}
/>
{isUploading ? (
<SpinnerGap
size={18}
weight="bold"
className="ai-chat-spinner"
/>
) : (
<Image size={18} weight="regular" />
)}
</label>
<button
className="ai-chat-attach-button"
onClick={() => setShowLinkModal(true)}
disabled={
isLoading ||
isUploading ||
attachments.filter((a) => a.type === "link").length >= 3
}
title="Add link (max 3)"
>
<Link size={18} weight="regular" />
</button>
</div>
)}
<textarea
ref={textareaRef}
className="ai-chat-input"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyPress}
placeholder="Type your message..."
rows={1}
disabled={isLoading}
/>
<button
className="ai-chat-send-button"
onClick={handleSend}
disabled={
(!inputValue.trim() && attachments.length === 0) || isLoading
}
title="Send message (Enter)"
>
<PaperPlaneTilt size={18} weight="bold" />
</button>
</div>
</div>
{/* Link input modal */}
{showLinkModal && (
<div
className="ai-chat-link-modal"
onClick={(e) => {
if (e.target === e.currentTarget) {
setShowLinkModal(false);
setLinkInputValue("");
}
}}
>
<div className="ai-chat-link-modal-content">
<h3>Add Link</h3>
<input
type="url"
value={linkInputValue}
onChange={(e) => setLinkInputValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
handleAddLink();
} else if (e.key === "Escape") {
setShowLinkModal(false);
setLinkInputValue("");
}
}}
placeholder="https://example.com"
className="ai-chat-link-input"
autoFocus
/>
<div className="ai-chat-link-modal-actions">
<button
onClick={() => {
setShowLinkModal(false);
setLinkInputValue("");
}}
className="ai-chat-link-modal-cancel"
>
Cancel
</button>
<button
onClick={handleAddLink}
className="ai-chat-link-modal-add"
disabled={!linkInputValue.trim()}
>
Add
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -37,19 +37,23 @@ export default function LogoMarquee({ config }: LogoMarqueeProps) {
// Normalize images
const normalizedImages = config.images.map(normalizeImage);
// Check if scrolling mode (default true for backwards compatibility)
const isScrolling = config.scrolling !== false;
// home logos scrolling settings
const isScrolling = config.scrolling !== true;
// For static mode, limit to maxItems (default 4)
const maxItems = config.maxItems ?? 4;
const displayImages = isScrolling
const displayImages = isScrolling
? [...normalizedImages, ...normalizedImages] // Duplicate for seamless scroll
: normalizedImages.slice(0, maxItems); // Limit for static grid
// Render logo item (shared between modes)
const renderLogo = (logo: LogoItem, index: number) => (
<div key={`${logo.src}-${index}`} className={isScrolling ? "logo-marquee-item" : "logo-static-item"}>
<div
key={`${logo.src}-${index}`}
className={isScrolling ? "logo-marquee-item" : "logo-static-item"}
>
{logo.href ? (
<a
href={logo.href}
@@ -77,9 +81,7 @@ export default function LogoMarquee({ config }: LogoMarqueeProps) {
return (
<div className="logo-marquee-container">
{config.title && (
<p className="logo-marquee-title">{config.title}</p>
)}
{config.title && <p className="logo-marquee-title">{config.title}</p>}
{isScrolling ? (
// Scrolling marquee mode
<div
@@ -96,9 +98,7 @@ export default function LogoMarquee({ config }: LogoMarqueeProps) {
</div>
) : (
// Static grid mode
<div className="logo-static-grid">
{displayImages.map(renderLogo)}
</div>
<div className="logo-static-grid">{displayImages.map(renderLogo)}</div>
)}
</div>
);

View File

@@ -1,12 +1,44 @@
// Right sidebar component - maintains layout spacing when sidebars are enabled
// CopyPageDropdown is now rendered in the main content area instead
export default function RightSidebar() {
// Right sidebar component
// Conditionally renders AI chat when enabled via frontmatter and siteConfig
import AIChatView from "./AIChatView";
import siteConfig from "../config/siteConfig";
interface RightSidebarProps {
aiChatEnabled?: boolean; // From frontmatter aiChat: true
pageContent?: string; // Page markdown content for AI context
slug?: string; // Page/post slug for chat context ID
}
export default function RightSidebar({
aiChatEnabled = false,
pageContent,
slug,
}: RightSidebarProps) {
// Check if AI chat should be shown
// Requires both siteConfig.aiChat.enabledOnContent AND frontmatter aiChat: true
const showAIChat =
siteConfig.aiChat.enabledOnContent && aiChatEnabled && slug;
if (showAIChat) {
return (
<aside className="post-sidebar-right">
<div className="right-sidebar-ai-chat">
<AIChatView
contextId={slug}
pageContent={pageContent}
hideAttachments={true}
/>
</div>
</aside>
);
}
// Default empty sidebar for layout spacing
return (
<aside className="post-sidebar-right">
<div className="right-sidebar-content">
{/* Empty - CopyPageDropdown moved to main content area */}
{/* Empty - maintains layout spacing */}
</div>
</aside>
);
}

View File

@@ -98,6 +98,21 @@ export interface FooterConfig {
defaultContent?: string; // Default markdown content if no frontmatter footer field provided
}
// Homepage configuration
// Allows setting any page or blog post to serve as the homepage
export interface HomepageConfig {
type: "default" | "page" | "post"; // Type of homepage: default (standard Home component), page (static page), or post (blog post)
slug?: string; // Required if type is "page" or "post" - the slug of the page/post to use as homepage
originalHomeRoute?: string; // Route to access the original homepage when custom homepage is set (default: "/home")
}
// AI Chat configuration
// Controls the AI writing assistant feature on Write page and content pages
export interface AIChatConfig {
enabledOnWritePage: boolean; // Show AI chat toggle on /write page
enabledOnContent: boolean; // Allow AI chat on posts/pages via frontmatter aiChat: true
}
// Site configuration interface
export interface SiteConfig {
// Basic site info
@@ -150,6 +165,12 @@ export interface SiteConfig {
// Footer configuration
footer: FooterConfig;
// Homepage configuration
homepage: HomepageConfig;
// AI Chat configuration
aiChat: AIChatConfig;
}
// Default site configuration
@@ -186,7 +207,7 @@ export const siteConfig: SiteConfig = {
},
{
src: "/images/logos/netlify.svg",
href: "https://www.netlify.com/",
href: "https://www.netlify.com/utm_source=markdownfast",
},
{
src: "/images/logos/firecrawl.svg",
@@ -201,8 +222,8 @@ export const siteConfig: SiteConfig = {
href: "https://markdown.fast/setup-guide",
},
{
src: "/images/logos/sample-logo-5.svg",
href: "https://markdown.fast/setup-guide",
src: "/images/logos/agentmail.svg",
href: "https://agentmail.to/utm_source=markdownfast",
},
],
position: "above-footer",
@@ -321,6 +342,24 @@ export const siteConfig: SiteConfig = {
Created by [Wayne](https://x.com/waynesutton) with Convex, Cursor, and Claude Opus 4.5. Follow on [Twitter/X](https://x.com/waynesutton), [LinkedIn](https://www.linkedin.com/in/waynesutton/), and [GitHub](https://github.com/waynesutton). This project is licensed under the MIT [License](https://github.com/waynesutton/markdown-site?tab=MIT-1-ov-file).`,
},
// Homepage configuration
// Set any page or blog post to serve as the homepage
// Custom homepage uses the page/post's full content and features (sidebar, copy dropdown, etc.)
// Featured section is NOT shown on custom homepage (only on default Home component)
homepage: {
type: "default", // Options: "default" (standard Home component), "page" (use a static page), or "post" (use a blog post)
slug: "undefined", // Required if type is "page" or "post" - the slug of the page/post to use default is undefined
originalHomeRoute: "/home", // Route to access the original homepage when custom homepage is set
},
// AI Chat configuration
// Controls the AI writing assistant powered by Claude
// Requires ANTHROPIC_API_KEY environment variable in Convex dashboard
aiChat: {
enabledOnWritePage: true, // Show AI chat toggle on /write page
enabledOnContent: true, // Allow AI chat on posts/pages via frontmatter aiChat: true
},
};
// Export the config as default for easy importing

View File

@@ -18,11 +18,25 @@ const SITE_URL = "https://markdown.fast";
const SITE_NAME = "markdown sync framework";
const DEFAULT_OG_IMAGE = "/images/og-default.svg";
export default function Post() {
const { slug } = useParams<{ slug: string }>();
interface PostProps {
slug?: string; // Optional slug prop when used as homepage
isHomepage?: boolean; // Flag to indicate this is the homepage
homepageType?: "page" | "post"; // Type of homepage content
}
export default function Post({
slug: propSlug,
isHomepage = false,
homepageType,
}: PostProps = {}) {
const { slug: routeSlug } = useParams<{ slug: string }>();
const navigate = useNavigate();
const location = useLocation();
const { setHeadings, setActiveId } = useSidebar();
// Use prop slug if provided (for homepage), otherwise use route slug
const slug = propSlug || routeSlug;
// Check for page first, then post
const page = useQuery(api.pages.getPageBySlug, slug ? { slug } : "skip");
const post = useQuery(api.posts.getPostBySlug, slug ? { slug } : "skip");
@@ -196,8 +210,8 @@ export default function Post() {
return (
<div className={`post-page ${hasAnySidebar ? "post-page-with-sidebar" : ""}`}>
<nav className={`post-nav ${hasAnySidebar ? "post-nav-with-sidebar" : ""}`}>
{/* Hide back-button when sidebars are enabled */}
{!hasAnySidebar && (
{/* Hide back-button when sidebars are enabled or when used as homepage */}
{!hasAnySidebar && !isHomepage && (
<button onClick={() => navigate("/")} className="back-button">
<ArrowLeft size={16} />
<span>Back</span>
@@ -269,8 +283,14 @@ export default function Post() {
)}
</article>
{/* Right sidebar - empty when sidebars are enabled, CopyPageDropdown moved to main content */}
{hasRightSidebar && <RightSidebar />}
{/* Right sidebar - with optional AI chat support */}
{hasRightSidebar && (
<RightSidebar
aiChatEnabled={page.aiChat}
pageContent={page.content}
slug={page.slug}
/>
)}
</div>
</div>
);
@@ -319,8 +339,8 @@ export default function Post() {
return (
<div className={`post-page ${hasAnySidebar ? "post-page-with-sidebar" : ""}`}>
<nav className={`post-nav ${hasAnySidebar ? "post-nav-with-sidebar" : ""}`}>
{/* Hide back-button when sidebars are enabled */}
{!hasAnySidebar && (
{/* Hide back-button when sidebars are enabled or when used as homepage */}
{!hasAnySidebar && !isHomepage && (
<button onClick={() => navigate("/")} className="back-button">
<ArrowLeft size={16} />
<span>Back</span>
@@ -475,8 +495,14 @@ export default function Post() {
)}
</article>
{/* Right sidebar - empty when sidebars are enabled, CopyPageDropdown moved to main content */}
{hasRightSidebar && <RightSidebar />}
{/* Right sidebar - with optional AI chat support */}
{hasRightSidebar && (
<RightSidebar
aiChatEnabled={post.aiChat}
pageContent={post.content}
slug={post.slug}
/>
)}
</div>
</div>
);

View File

@@ -9,11 +9,14 @@ import {
File,
Warning,
TextAa,
ChatCircle,
} from "@phosphor-icons/react";
import { Moon, Sun, Cloud } from "lucide-react";
import { Half2Icon } from "@radix-ui/react-icons";
import { useTheme } from "../context/ThemeContext";
import { useFont } from "../context/FontContext";
import AIChatView from "../components/AIChatView";
import siteConfig from "../config/siteConfig";
// Frontmatter field definitions for blog posts
const POST_FIELDS = [
@@ -37,11 +40,20 @@ const POST_FIELDS = [
{ name: "featured", required: false, example: "true" },
{ name: "featuredOrder", required: false, example: "1" },
{ name: "authorName", required: false, example: '"Jane Doe"' },
{ name: "authorImage", required: false, example: '"/images/authors/jane.png"' },
{
name: "authorImage",
required: false,
example: '"/images/authors/jane.png"',
},
{ name: "layout", required: false, example: '"sidebar"' },
{ name: "rightSidebar", required: false, example: "true" },
{ name: "showFooter", required: false, example: "true" },
{ name: "footer", required: false, example: '"Built with [Convex](https://convex.dev)."' },
{
name: "footer",
required: false,
example: '"Built with [Convex](https://convex.dev)."',
},
{ name: "aiChat", required: false, example: "true" },
];
// Frontmatter field definitions for pages
@@ -56,11 +68,20 @@ const PAGE_FIELDS = [
{ name: "featured", required: false, example: "true" },
{ name: "featuredOrder", required: false, example: "1" },
{ name: "authorName", required: false, example: '"Jane Doe"' },
{ name: "authorImage", required: false, example: '"/images/authors/jane.png"' },
{
name: "authorImage",
required: false,
example: '"/images/authors/jane.png"',
},
{ name: "layout", required: false, example: '"sidebar"' },
{ name: "rightSidebar", required: false, example: "true" },
{ name: "showFooter", required: false, example: "true" },
{ name: "footer", required: false, example: '"Built with [Convex](https://convex.dev)."' },
{
name: "footer",
required: false,
example: '"Built with [Convex](https://convex.dev)."',
},
{ name: "aiChat", required: false, example: "true" },
];
// Generate frontmatter template based on content type
@@ -160,8 +181,12 @@ export default function Write() {
const [copied, setCopied] = useState(false);
const [copiedField, setCopiedField] = useState<string | null>(null);
const [font, setFont] = useState<"serif" | "sans" | "monospace">("serif");
const [isAIChatMode, setIsAIChatMode] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
// Check if AI chat is enabled for write page
const aiChatEnabled = siteConfig.aiChat.enabledOnWritePage;
// Load from localStorage on mount
useEffect(() => {
const savedContent = localStorage.getItem(STORAGE_KEY_CONTENT);
@@ -188,7 +213,9 @@ export default function Write() {
// Use saved font preference, or fall back to global font, or default to serif
if (
savedFont &&
(savedFont === "serif" || savedFont === "sans" || savedFont === "monospace")
(savedFont === "serif" ||
savedFont === "sans" ||
savedFont === "monospace")
) {
setFont(savedFont);
} else {
@@ -212,6 +239,12 @@ export default function Write() {
localStorage.setItem(STORAGE_KEY_FONT, font);
}, [font]);
// Prevent scroll when switching to AI chat mode
useEffect(() => {
// Lock scroll position to prevent jump when AI chat mounts
window.scrollTo(0, 0);
}, [isAIChatMode]);
// Toggle font between serif, sans-serif, and monospace
const toggleFont = useCallback(() => {
setFont((prev) => {
@@ -325,6 +358,34 @@ export default function Write() {
<div className="write-nav-section">
<span className="write-nav-label">Actions</span>
{/* AI Chat toggle - only show if enabled in siteConfig */}
{aiChatEnabled && (
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
// Prevent any scroll behavior during mode switch
const scrollX = window.scrollX;
const scrollY = window.scrollY;
setIsAIChatMode(!isAIChatMode);
// Restore scroll position immediately after state change
requestAnimationFrame(() => {
window.scrollTo(scrollX, scrollY);
});
}}
className={`write-nav-item ${isAIChatMode ? "active" : ""}`}
title={
isAIChatMode ? "Switch to text editor" : "Switch to AI Chat"
}
>
<ChatCircle
size={18}
weight={isAIChatMode ? "fill" : "regular"}
/>
<span>{isAIChatMode ? "Text Editor" : "Agent"}</span>
</button>
)}
<button onClick={handleClear} className="write-nav-item">
<Trash size={18} />
<span>Clear</span>
@@ -357,54 +418,68 @@ export default function Write() {
<main className="write-main">
<div className="write-main-header">
<h1 className="write-main-title">
{contentType === "post" ? "Blog Post" : "Page"}
{isAIChatMode
? "Agent"
: contentType === "post"
? "Blog Post"
: "Page"}
</h1>
<button
onClick={handleCopy}
className={`write-copy-btn ${copied ? "copied" : ""}`}
>
{copied ? (
<>
<Check size={16} weight="bold" />
<span>Copied</span>
</>
) : (
<>
<CopySimple size={16} />
<span>Copy All</span>
</>
)}
</button>
{!isAIChatMode && (
<button
onClick={handleCopy}
className={`write-copy-btn ${copied ? "copied" : ""}`}
>
{copied ? (
<>
<Check size={16} weight="bold" />
<span>Copied</span>
</>
) : (
<>
<CopySimple size={16} />
<span>Copy All</span>
</>
)}
</button>
)}
</div>
<textarea
ref={textareaRef}
value={content}
onChange={(e) => setContent(e.target.value)}
className="write-textarea"
placeholder="Start writing your markdown..."
spellCheck={true}
autoComplete="off"
autoCapitalize="sentences"
autoFocus
style={{ fontFamily: FONTS[font] }}
/>
{/* Conditionally render textarea or AI chat */}
{isAIChatMode ? (
<div className="write-ai-chat-container">
<AIChatView contextId="write-page" />
</div>
) : (
<textarea
ref={textareaRef}
value={content}
onChange={(e) => setContent(e.target.value)}
className="write-textarea"
placeholder="Start writing your markdown..."
spellCheck={true}
autoComplete="off"
autoCapitalize="sentences"
style={{ fontFamily: FONTS[font] }}
/>
)}
{/* Footer with stats */}
<div className="write-main-footer">
<div className="write-stats">
<span>{words} words</span>
<span className="write-stats-divider" />
<span>{lines} lines</span>
<span className="write-stats-divider" />
<span>{characters} chars</span>
{/* Footer with stats - only show in text editor mode */}
{!isAIChatMode && (
<div className="write-main-footer">
<div className="write-stats">
<span>{words} words</span>
<span className="write-stats-divider" />
<span>{lines} lines</span>
<span className="write-stats-divider" />
<span>{characters} chars</span>
</div>
<div className="write-save-hint">
Save to{" "}
<code>content/{contentType === "post" ? "blog" : "pages"}/</code>{" "}
then <code>npm run sync</code>
</div>
</div>
<div className="write-save-hint">
Save to{" "}
<code>content/{contentType === "post" ? "blog" : "pages"}/</code>{" "}
then <code>npm run sync</code>
</div>
</div>
)}
</main>
{/* Right Sidebar: Frontmatter fields */}

View File

@@ -318,6 +318,12 @@ body {
/* padding: 40px 24px; */
}
/* Expand main-content to full width when sidebar layout is used */
.main-content:has(.post-page-with-sidebar) {
max-width: 100%;
padding: 0 0px;
}
/* Wide content layout for pages that need more space (stats, etc.) */
.main-content-wide {
flex: 1;
@@ -341,6 +347,8 @@ body {
background-color: var(--bg-primary);
padding: 8px 12px;
border-radius: 8px;
/* nav bar border to match sidebar if sidebar is enabled
border-bottom: 1px solid var(--border-sidebar); */
}
/* Logo in top navigation */
@@ -655,13 +663,11 @@ body {
/* Full-width sidebar layout - breaks out of .main-content constraints */
.post-page-with-sidebar {
padding-top: 0px;
/* Break out of the 680px max-width container */
width: calc(100vw - 48px);
max-width: none;
margin-left: calc(-1 * (min(100vw - 48px, 1400px) - 680px) / 2);
width: 100%;
max-width: 100%;
margin-left: 0;
margin-right: 0;
position: relative;
/* Add left padding to align content with sidebar edge
padding-left: 24px;*/
}
.post-nav {
@@ -892,6 +898,7 @@ body {
@media (min-width: 1135px) {
.post-content-with-sidebar:has(.post-sidebar-right) {
grid-template-columns: 240px 1fr 280px;
max-width: 100%;
}
/* Adjust main content padding when right sidebar exists */
@@ -947,7 +954,7 @@ body {
-ms-overflow-style: none; /* IE */
scrollbar-width: none; /* Firefox */
background-color: var(--bg-sidebar);
margin-right: -24px;
margin-right: 0;
padding-left: 24px;
padding-right: 24px;
padding-top: 24px;
@@ -1104,6 +1111,7 @@ body {
width: 100%;
max-width: 100%;
margin-left: 0;
margin-right: 0;
}
.post-content-with-sidebar {
@@ -4465,7 +4473,9 @@ body {
display: grid;
grid-template-columns: 220px 1fr 280px;
min-height: 100vh;
height: 100vh;
background: var(--bg-primary);
overflow: hidden; /* Prevent any page-level scroll */
}
/* Left Sidebar */
@@ -4474,6 +4484,8 @@ body {
flex-direction: column;
border-right: 1px solid var(--border-color);
background: var(--bg-secondary);
overflow-y: auto;
max-height: 100vh;
}
.write-sidebar-header {
@@ -4577,7 +4589,14 @@ body {
display: flex;
flex-direction: column;
min-height: 100vh;
max-height: 100vh;
background: var(--bg-primary);
overflow: hidden;
}
/* When AI chat is active, ensure proper height constraints */
.write-main:has(.write-ai-chat-container) {
height: 100vh;
}
.write-main-header {
@@ -4699,6 +4718,8 @@ body {
flex-direction: column;
border-left: 1px solid var(--border-color);
background: var(--bg-primary);
overflow-y: auto;
max-height: 100vh;
}
.write-sidebar-right .write-sidebar-header {
@@ -4862,6 +4883,15 @@ body {
.write-main {
min-height: auto;
max-height: none;
overflow: visible;
}
/* On mobile, AI chat gets fixed height */
.write-main:has(.write-ai-chat-container) {
height: calc(100vh - 120px); /* Account for mobile nav */
max-height: calc(100vh - 120px);
overflow: hidden;
}
.write-main-header {
@@ -4932,3 +4962,830 @@ body {
grid-template-columns: 1fr;
}
}
/* AI Chat Styles */
/* Write page AI chat container */
.write-ai-chat-container {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0; /* Allow shrinking in flex container */
overflow: hidden;
}
.write-ai-chat-container .ai-chat-view {
flex: 1;
height: 100%;
min-height: 0; /* Allow shrinking in flex container */
}
.ai-chat-view {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0; /* Allow shrinking in flex container */
background-color: var(--bg-primary);
border-radius: 8px;
overflow: hidden;
}
.ai-chat-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid var(--border-color);
background-color: var(--bg-secondary);
flex-shrink: 0;
}
.ai-chat-title {
font-size: var(--font-size-sm);
font-weight: 600;
color: var(--text-primary);
}
.ai-chat-header-actions {
display: flex;
align-items: center;
gap: 8px;
}
.ai-chat-load-context-button {
display: flex;
align-items: center;
gap: 4px;
padding: 6px 10px;
font-size: var(--font-size-xs);
font-weight: 500;
color: var(--text-secondary);
background-color: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 6px;
cursor: pointer;
transition: all 0.15s ease;
}
.ai-chat-load-context-button:hover {
background-color: var(--bg-hover);
color: var(--text-primary);
}
.ai-chat-context-loaded {
display: flex;
align-items: center;
gap: 4px;
font-size: var(--font-size-xs);
color: #10b981;
}
.ai-chat-clear-button {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
padding: 0;
color: var(--text-muted);
background: none;
border: none;
border-radius: 6px;
cursor: pointer;
transition: all 0.15s ease;
}
.ai-chat-clear-button:hover:not(:disabled) {
background-color: var(--bg-hover);
color: var(--text-primary);
}
.ai-chat-clear-button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.ai-chat-close-button {
padding: 6px 12px;
font-size: var(--font-size-xs);
font-weight: 500;
color: var(--text-secondary);
background-color: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 6px;
cursor: pointer;
transition: all 0.15s ease;
}
.ai-chat-close-button:hover {
background-color: var(--bg-hover);
color: var(--text-primary);
}
.ai-chat-messages {
flex: 1;
min-height: 0; /* Critical: allows flex item to shrink below content size */
overflow-y: auto;
overflow-x: hidden;
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
scroll-behavior: smooth;
}
.ai-chat-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
text-align: center;
color: var(--text-muted);
padding: 32px;
}
.ai-chat-empty p {
margin: 0 0 8px;
}
.ai-chat-empty-hint {
font-size: var(--font-size-xs);
opacity: 0.8;
}
.ai-chat-message {
display: flex;
gap: 8px;
max-width: 85%;
animation: aiChatFadeIn 0.2s ease;
}
@keyframes aiChatFadeIn {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.ai-chat-message-user {
align-self: flex-end;
flex-direction: row-reverse;
}
.ai-chat-message-assistant {
align-self: flex-start;
}
.ai-chat-message-content {
padding: 10px 14px;
border-radius: 12px;
font-size: var(--font-size-sm);
line-height: 1.5;
word-wrap: break-word;
overflow-wrap: break-word;
}
.ai-chat-message-user .ai-chat-message-content {
background-color: var(--text-primary);
color: var(--bg-primary);
border-bottom-right-radius: 4px;
}
.ai-chat-message-assistant .ai-chat-message-content {
background-color: var(--bg-secondary);
color: var(--text-primary);
border-bottom-left-radius: 4px;
}
.ai-chat-message-content p {
margin: 0 0 8px;
}
.ai-chat-message-content p:last-child {
margin-bottom: 0;
}
.ai-chat-message-content h1,
.ai-chat-message-content h2,
.ai-chat-message-content h3,
.ai-chat-message-content h4,
.ai-chat-message-content h5,
.ai-chat-message-content h6 {
margin: 12px 0 8px;
font-size: var(--font-size-sm);
font-weight: 600;
}
.ai-chat-message-content h1:first-child,
.ai-chat-message-content h2:first-child,
.ai-chat-message-content h3:first-child {
margin-top: 0;
}
.ai-chat-message-content ul,
.ai-chat-message-content ol {
margin: 8px 0;
padding-left: 20px;
}
.ai-chat-message-content li {
margin-bottom: 4px;
}
.ai-chat-message-content code {
font-family:
"SF Mono", Monaco, "Cascadia Code", "Roboto Mono", Consolas, monospace;
font-size: 0.9em;
padding: 2px 6px;
background-color: var(--bg-hover);
border-radius: 4px;
}
.ai-chat-message-content pre {
margin: 8px 0;
padding: 12px;
background-color: var(--bg-hover);
border-radius: 6px;
overflow-x: auto;
}
.ai-chat-message-content pre code {
padding: 0;
background: none;
}
.ai-chat-message-content a {
color: var(--link-color);
text-decoration: none;
}
.ai-chat-message-content a:hover {
text-decoration: underline;
}
.ai-chat-message-content blockquote {
margin: 8px 0;
padding-left: 12px;
border-left: 3px solid var(--border-color);
color: var(--text-secondary);
}
.ai-chat-copy-button {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
color: var(--text-muted);
background: none;
border: none;
border-radius: 6px;
cursor: pointer;
opacity: 0;
transition: all 0.15s ease;
flex-shrink: 0;
align-self: flex-start;
margin-top: 4px;
}
.ai-chat-message:hover .ai-chat-copy-button {
opacity: 1;
}
.ai-chat-copy-button:hover {
background-color: var(--bg-hover);
color: var(--text-primary);
}
.ai-chat-loading {
display: flex;
align-items: center;
justify-content: space-between;
}
.ai-chat-loading-content {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
background-color: var(--bg-secondary);
border-radius: 12px;
border-bottom-left-radius: 4px;
font-size: var(--font-size-sm);
color: var(--text-secondary);
}
.ai-chat-spinner {
animation: aiChatSpin 1s linear infinite;
}
@keyframes aiChatSpin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.ai-chat-stop-button {
display: flex;
align-items: center;
gap: 4px;
padding: 6px 10px;
font-size: var(--font-size-xs);
font-weight: 500;
color: #ef4444;
background-color: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.2);
border-radius: 6px;
cursor: pointer;
transition: all 0.15s ease;
}
.ai-chat-stop-button:hover {
background-color: rgba(239, 68, 68, 0.15);
border-color: rgba(239, 68, 68, 0.3);
}
.ai-chat-stopped {
font-size: var(--font-size-xs);
color: var(--text-muted);
text-align: center;
padding: 8px;
}
.ai-chat-error {
background-color: transparent;
}
.ai-chat-error .ai-chat-message-content {
color: #ef4444;
}
.ai-chat-input-container {
padding: 12px 16px;
border-top: 1px solid var(--border-color);
background-color: var(--bg-secondary);
flex-shrink: 0;
flex-grow: 0;
}
.ai-chat-input-wrapper {
display: flex;
align-items: flex-end;
gap: 8px;
background-color: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 8px 12px;
transition: border-color 0.15s ease;
}
.ai-chat-input-actions {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
.ai-chat-attach-button {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
padding: 0;
color: var(--text-muted);
background: none;
border: none;
border-radius: 6px;
cursor: pointer;
transition: all 0.15s ease;
}
.ai-chat-attach-button:hover:not(:disabled) {
background-color: var(--bg-hover);
color: var(--text-primary);
}
.ai-chat-attach-button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.ai-chat-input-wrapper:focus-within {
border-color: var(--text-muted);
}
.ai-chat-input {
flex: 1;
min-height: 24px;
max-height: 200px;
padding: 0;
font-family: inherit;
font-size: var(--font-size-sm);
line-height: 1.5;
color: var(--text-primary);
background: none;
border: none;
resize: none;
outline: none;
/* Prevent scroll jump when focusing */
scroll-margin: 0;
}
.ai-chat-input::placeholder {
color: var(--text-muted);
}
.ai-chat-send-button {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
padding: 0;
color: var(--bg-primary);
background-color: var(--text-primary);
border: none;
border-radius: 8px;
cursor: pointer;
transition: all 0.15s ease;
flex-shrink: 0;
}
.ai-chat-send-button:hover:not(:disabled) {
opacity: 0.85;
}
.ai-chat-send-button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* Attachments preview */
.ai-chat-attachments-preview {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 8px 16px;
border-top: 1px solid var(--border-color);
background-color: var(--bg-secondary);
flex-shrink: 0;
}
.ai-chat-attachment-preview {
position: relative;
display: flex;
align-items: center;
gap: 8px;
padding: 8px;
background-color: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 6px;
}
.ai-chat-attachment-preview-image {
width: 60px;
height: 60px;
object-fit: cover;
border-radius: 4px;
}
.ai-chat-attachment-preview-url {
font-size: var(--font-size-xs);
color: var(--text-secondary);
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ai-chat-attachment-remove {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
padding: 0;
color: var(--text-muted);
background-color: var(--bg-hover);
border: none;
border-radius: 50%;
cursor: pointer;
transition: all 0.15s ease;
flex-shrink: 0;
}
.ai-chat-attachment-remove:hover {
background-color: var(--text-primary);
color: var(--bg-primary);
}
/* Link input modal */
.ai-chat-link-modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
animation: backdropFadeIn 0.2s ease;
}
.ai-chat-link-modal-content {
background-color: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 20px;
max-width: 400px;
width: 90%;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.ai-chat-link-modal-content h3 {
margin: 0 0 12px;
font-size: var(--font-size-base);
font-weight: 600;
color: var(--text-primary);
}
.ai-chat-link-input {
width: 100%;
padding: 8px 12px;
font-size: var(--font-size-sm);
color: var(--text-primary);
background-color: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 6px;
margin-bottom: 12px;
outline: none;
transition: border-color 0.15s ease;
}
.ai-chat-link-input:focus {
border-color: var(--text-muted);
}
.ai-chat-link-modal-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
}
.ai-chat-link-modal-cancel,
.ai-chat-link-modal-add {
padding: 6px 12px;
font-size: var(--font-size-sm);
font-weight: 500;
border-radius: 6px;
cursor: pointer;
transition: all 0.15s ease;
border: 1px solid var(--border-color);
}
.ai-chat-link-modal-cancel {
color: var(--text-secondary);
background-color: var(--bg-secondary);
}
.ai-chat-link-modal-cancel:hover {
background-color: var(--bg-hover);
color: var(--text-primary);
}
.ai-chat-link-modal-add {
color: var(--bg-primary);
background-color: var(--text-primary);
border-color: var(--text-primary);
}
.ai-chat-link-modal-add:hover:not(:disabled) {
opacity: 0.85;
}
.ai-chat-link-modal-add:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* Attachments in messages */
.ai-chat-attachments {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 8px;
}
.ai-chat-attachment {
display: flex;
align-items: center;
gap: 8px;
}
.ai-chat-attachment-image {
max-width: 200px;
max-height: 200px;
border-radius: 6px;
object-fit: contain;
display: block;
}
.ai-chat-attachment-loading {
padding: 8px;
font-size: var(--font-size-xs);
color: var(--text-muted);
font-style: italic;
}
.ai-chat-attachment-link {
display: flex;
align-items: center;
gap: 6px;
color: var(--text-primary);
text-decoration: none;
padding: 6px 10px;
background-color: var(--bg-secondary);
border-radius: 6px;
font-size: var(--font-size-xs);
transition: background-color 0.15s ease;
}
.ai-chat-attachment-link:hover {
background-color: var(--bg-hover);
}
/* AI Chat in Write Page - overrides for Write context */
.write-ai-chat-container .ai-chat-view {
border-radius: 0;
}
/* AI Chat toggle button in Write nav */
.write-nav-item.ai-chat-active {
background-color: var(--text-primary);
color: var(--bg-primary);
}
.write-nav-item.ai-chat-active:hover {
background-color: var(--text-primary);
opacity: 0.9;
}
/* AI Chat in Right Sidebar */
.right-sidebar-ai-chat {
height: 100%;
min-height: 300px;
}
.right-sidebar-ai-chat .ai-chat-view {
height: 100%;
border-radius: 0;
}
.right-sidebar-ai-chat .ai-chat-header {
padding: 10px 12px;
}
.right-sidebar-ai-chat .ai-chat-title {
font-size: var(--font-size-xs);
}
.right-sidebar-ai-chat .ai-chat-messages {
padding: 12px;
gap: 10px;
}
.right-sidebar-ai-chat .ai-chat-message {
max-width: 95%;
}
.right-sidebar-ai-chat .ai-chat-message-content {
padding: 8px 12px;
font-size: var(--font-size-xs);
}
.right-sidebar-ai-chat .ai-chat-input-container {
padding: 10px 12px;
}
.right-sidebar-ai-chat .ai-chat-input {
font-size: var(--font-size-xs);
}
.right-sidebar-ai-chat .ai-chat-send-button {
width: 32px;
height: 32px;
}
/* Theme-specific AI Chat styles */
:root[data-theme="dark"] .ai-chat-message-user .ai-chat-message-content {
background-color: #f5f5f5;
color: #171717;
}
:root[data-theme="dark"] .ai-chat-message-content code {
background-color: rgba(255, 255, 255, 0.1);
}
:root[data-theme="dark"] .ai-chat-message-content pre {
background-color: rgba(255, 255, 255, 0.05);
}
:root[data-theme="tan"] .ai-chat-message-user .ai-chat-message-content {
background-color: #1a1a1a;
color: #faf8f5;
}
:root[data-theme="cloud"] .ai-chat-message-user .ai-chat-message-content {
background-color: #1e293b;
color: #f8fafc;
}
/* Mobile responsive AI Chat */
@media (max-width: 768px) {
.ai-chat-header {
padding: 10px 12px;
}
.ai-chat-title {
font-size: var(--font-size-xs);
}
.ai-chat-messages {
padding: 12px;
gap: 10px;
}
.ai-chat-message {
max-width: 90%;
}
.ai-chat-message-content {
padding: 8px 12px;
font-size: var(--font-size-xs);
}
.ai-chat-input-container {
padding: 10px 12px;
}
.ai-chat-load-context-button span {
display: none;
}
.ai-chat-close-button {
padding: 6px 8px;
font-size: 10px;
}
.ai-chat-copy-button {
opacity: 1;
}
.ai-chat-attachments-preview {
padding: 8px 12px;
}
.ai-chat-attachment-preview-image {
width: 50px;
height: 50px;
}
.ai-chat-attachment-preview-url {
max-width: 150px;
}
.ai-chat-link-modal-content {
padding: 16px;
max-width: 90%;
}
.ai-chat-attachment-image {
max-width: 150px;
max-height: 150px;
}
}
@media (max-width: 480px) {
.ai-chat-header-actions {
gap: 4px;
}
.ai-chat-empty {
padding: 20px;
}
.ai-chat-empty p {
font-size: var(--font-size-xs);
}
}