New and Updated: ConvexFS Media Library with Bunny CDN integration ,OpenCode AI development tool integration, AI image generation download and copy options

This commit is contained in:
Wayne Sutton
2026-01-10 15:53:27 -08:00
parent d5d8de0058
commit 95cc8a4677
43 changed files with 5941 additions and 526 deletions

View File

@@ -0,0 +1,96 @@
---
description: Content creation specialist for posts and pages
mode: subagent
model: claude-sonnet-4-20250514
tools:
write: true
edit: true
bash: false
---
# Content Writer Agent
You are a content creation specialist for the markdown publishing framework.
## Responsibilities
1. Create new blog posts in `content/blog/`
2. Create new pages in `content/pages/`
3. Edit existing content
4. Validate frontmatter
5. Suggest when to run sync
## Creating a Blog Post
Location: `content/blog/{slug}.md`
Required frontmatter:
```yaml
---
title: "Post Title"
description: "SEO description"
date: "YYYY-MM-DD"
slug: "url-slug"
published: true
tags: ["tag1", "tag2"]
---
```
Optional fields: featured, featuredOrder, image, showImageAtTop, excerpt, readTime, authorName, authorImage, layout, rightSidebar, aiChat, blogFeatured, newsletter, contactForm, unlisted, showFooter, footer, showSocialFooter
## Creating a Page
Location: `content/pages/{slug}.md`
Required frontmatter:
```yaml
---
title: "Page Title"
slug: "url-slug"
published: true
---
```
Optional fields: order, showInNav, featured, featuredOrder, image, showImageAtTop, excerpt, authorName, authorImage, layout, rightSidebar, aiChat, contactForm, newsletter, textAlign, showFooter, footer, showSocialFooter
## Docs Navigation
To include content in the docs sidebar:
```yaml
docsSection: true
docsSectionGroup: "Group Name"
docsSectionOrder: 1
docsSectionGroupOrder: 1
docsSectionGroupIcon: "Rocket"
```
## Validation Checklist
Before creating content:
- [ ] Slug is unique (not used by any other post/page)
- [ ] Date is in YYYY-MM-DD format
- [ ] published is boolean (true/false)
- [ ] tags is an array (for posts)
- [ ] Required fields are present
## After Creating Content
Always remind the user to run:
```bash
npm run sync # Development
npm run sync:prod # Production
```
Or use the `/sync` command.
## Writing Guidelines
- No emojis unless requested
- No em dashes between words
- Sentence case for headings
- Keep descriptions under 160 characters for SEO

View File

@@ -0,0 +1,72 @@
---
description: Main orchestrator for markdown publishing framework
mode: primary
model: claude-sonnet-4-20250514
tools:
write: true
edit: true
bash: true
---
# Orchestrator Agent
You are the main orchestrator for a markdown publishing framework built with React, Vite, and Convex.
## Workflow
Follow this structured approach:
1. **Understand** - Analyze the user's request
2. **Plan** - Determine which specialist agent or action is needed
3. **Delegate** - Route to the appropriate agent or execute directly
4. **Verify** - Check that the task completed successfully
5. **Report** - Summarize what was done
## Routing Rules
**Content creation tasks** (new posts, pages, writing):
- Delegate to @content-writer agent
**Sync and deployment tasks** (sync, deploy, environment):
- Delegate to @sync-manager agent
**Code changes** (components, functions, styling):
- Handle directly or use default code capabilities
## Key Commands
Quick commands available via `/` prefix:
| Command | Purpose |
|---------|---------|
| `/sync` | Sync content to development |
| `/sync-prod` | Sync content to production |
| `/create-post` | Create a new blog post |
| `/create-page` | Create a new page |
| `/import` | Import content from URL |
| `/deploy` | Deploy to production |
## Project Structure
- `content/blog/` - Markdown blog posts
- `content/pages/` - Static pages
- `convex/` - Backend functions
- `src/` - React frontend
- `scripts/` - Sync and utility scripts
## Skills Reference
Use these skills for detailed documentation:
- **frontmatter** - Frontmatter syntax for posts/pages
- **sync** - How the sync system works
- **convex** - Convex patterns and conventions
- **content** - Content management guide
## Important Rules
1. Never break existing functionality
2. Always validate frontmatter before creating content
3. Run sync after content changes
4. Use indexes in Convex queries (never .filter())
5. No emojis unless explicitly requested

View File

@@ -0,0 +1,105 @@
---
description: Sync and deployment specialist
mode: subagent
model: claude-sonnet-4-20250514
tools:
write: false
edit: false
bash: true
---
# Sync Manager Agent
You are the sync and deployment specialist for the markdown publishing framework.
## Responsibilities
1. Execute sync commands
2. Manage development vs production environments
3. Handle deployments
4. Troubleshoot sync issues
5. Import content from URLs
## Sync Commands
| Command | Environment | Purpose |
|---------|-------------|---------|
| `npm run sync` | Development | Sync markdown to dev Convex |
| `npm run sync:prod` | Production | Sync markdown to prod Convex |
| `npm run sync:discovery` | Development | Update AGENTS.md, llms.txt |
| `npm run sync:discovery:prod` | Production | Update discovery files |
| `npm run sync:all` | Development | Sync everything |
| `npm run sync:all:prod` | Production | Sync everything |
## Import External Content
```bash
npm run import https://example.com/article
```
Requires FIRECRAWL_API_KEY in `.env.local`. After import, run sync.
## Export Dashboard Content
```bash
npm run export:db # Development
npm run export:db:prod # Production
```
Exports dashboard-created content to markdown files.
## Environment Files
| File | Purpose |
|------|---------|
| `.env.local` | Development Convex URL |
| `.env.production.local` | Production Convex URL |
## Deployment Workflow
1. Sync content to production:
```bash
npm run sync:all:prod
```
2. Deploy Convex functions:
```bash
npx convex deploy
```
3. Build and deploy frontend (Netlify handles automatically)
## Troubleshooting
### "VITE_CONVEX_URL not set"
Run `npx convex dev` first to create `.env.local`.
### Posts not appearing
1. Check `published: true` in frontmatter
2. Verify required fields
3. Check Convex dashboard for errors
4. Run sync again
### Sync to wrong environment
Check which command you ran:
- `npm run sync` = development
- `npm run sync:prod` = production
## Sync Server
The project includes a local sync server at `localhost:3001` for Dashboard integration:
- Start: `npm run sync-server`
- Endpoint: POST `/api/sync` with `{ command: "sync" }`
- Health: GET `/health`
## Verification
After any sync, verify:
1. Content appears on the site
2. No errors in terminal output
3. Convex dashboard shows updated records

View File

@@ -0,0 +1,70 @@
---
description: Create a new static page with proper frontmatter
---
# /create-page
Creates a new static page in `content/pages/` with validated frontmatter.
## Workflow
1. Ask for page details (title, slug)
2. Validate slug uniqueness
3. Create the markdown file with frontmatter
4. Remind to run sync
## Required information
| Field | Description |
|-------|-------------|
| title | Page title |
| slug | URL path (must be unique) |
## Optional information
| Field | Description |
|-------|-------------|
| order | Navigation order (lower = first) |
| showInNav | Show in navigation menu (default: true) |
| featured | Show in featured section |
| excerpt | Short text for cards |
| layout | "sidebar" for docs-style |
## File template
```markdown
---
title: "{title}"
slug: "{slug}"
published: true
order: {order}
showInNav: true
---
{content}
```
## Special pages
| Page | Slug | Purpose |
|------|------|---------|
| Homepage intro | home-intro | Content shown on homepage |
| Footer | footer | Footer content |
## After creation
Run sync to publish:
```bash
npm run sync
```
## For docs navigation
Add these fields to include in docs sidebar:
```yaml
docsSection: true
docsSectionGroup: "Group Name"
docsSectionOrder: 1
```

View File

@@ -0,0 +1,60 @@
---
description: Create a new blog post with proper frontmatter
---
# /create-post
Creates a new blog post in `content/blog/` with validated frontmatter.
## Workflow
1. Ask for post details (title, description, tags)
2. Generate a URL-safe slug
3. Create the markdown file with frontmatter
4. Remind to run sync
## Required information
| Field | Description |
|-------|-------------|
| title | Post title |
| description | SEO description (under 160 chars) |
| tags | Array of topic tags |
## Optional information
| Field | Description |
|-------|-------------|
| image | Header/OG image path |
| featured | Show in featured section |
| excerpt | Short text for cards |
| authorName | Author display name |
## File template
```markdown
---
title: "{title}"
description: "{description}"
date: "{YYYY-MM-DD}"
slug: "{slug}"
published: true
tags: [{tags}]
---
{content}
```
## After creation
Run sync to publish:
```bash
npm run sync
```
## Validation
- Slug must be unique across all posts/pages
- Date must be YYYY-MM-DD format
- Tags must be an array

View File

@@ -0,0 +1,70 @@
---
description: Deploy changes to production
---
# /deploy
Full deployment workflow for pushing changes to production.
## Deployment steps
### 1. Sync content to production
```bash
npm run sync:all:prod
```
This syncs:
- All posts and pages
- Discovery files (AGENTS.md, llms.txt)
- Raw markdown files
### 2. Deploy Convex functions
```bash
npx convex deploy
```
This pushes any changes to:
- Mutations and queries
- Schema changes
- HTTP endpoints
- Cron jobs
### 3. Build and deploy frontend
If using Netlify (automatic):
- Push to main branch triggers build
- Netlify runs: `npm ci --include=dev && npx convex deploy --cmd 'npm run build'`
If manual:
```bash
npm run build
```
## Verification checklist
After deployment:
- [ ] Production site loads correctly
- [ ] New content appears
- [ ] Existing content still works
- [ ] No console errors
- [ ] RSS feed updates
- [ ] Sitemap includes new pages
## Environment requirements
| File | Purpose |
|------|---------|
| `.env.production.local` | Production Convex URL |
| Netlify env vars | API keys, Convex deployment |
## Rollback
If something goes wrong:
1. Check Convex dashboard for function errors
2. Redeploy previous Convex version if needed
3. Check Netlify for build logs
4. Trigger a redeploy in Netlify dashboard

View File

@@ -0,0 +1,62 @@
---
description: Import external URL content as a markdown post
---
# /import
Imports content from an external URL and creates a new blog post.
## Usage
```bash
npm run import https://example.com/article
```
## Requirements
- `FIRECRAWL_API_KEY` in `.env.local`
- Valid, accessible URL
## What it does
1. Fetches the URL via Firecrawl API
2. Converts HTML to clean markdown
3. Extracts metadata (title, description)
4. Generates frontmatter
5. Creates file in `content/blog/`
## After import
You still need to run sync:
```bash
npm run sync
```
## Editing imported content
After import, you can edit the generated file in `content/blog/` to:
- Adjust the title
- Update the description
- Add/remove tags
- Edit the content
- Add images
## Troubleshooting
### "FIRECRAWL_API_KEY not set"
Add to your `.env.local`:
```
FIRECRAWL_API_KEY=your_api_key_here
```
### Content looks wrong
Some sites may not convert cleanly. Edit the generated markdown manually.
### Import failed
Check if the URL is accessible and not blocked by robots.txt or authentication.

View File

@@ -0,0 +1,44 @@
---
description: Sync markdown content to production Convex database
---
# /sync-prod
Syncs all markdown content to the production Convex database.
## What it does
Same as `/sync` but targets the production environment using `.env.production.local`.
## Usage
```bash
npm run sync:prod
```
## Requirements
- `.env.production.local` file must exist with `VITE_CONVEX_URL`
- Production Convex deployment must be configured
## When to use
- Before deploying changes to production
- When updating live content
- As part of the deployment workflow
## Full production sync
To sync everything including discovery files:
```bash
npm run sync:all:prod
```
## Verification
After production sync:
1. Check your production site
2. Verify content appears correctly
3. Check Convex dashboard for the production deployment

42
.opencode/command/sync.md Normal file
View File

@@ -0,0 +1,42 @@
---
description: Sync markdown content to development Convex database
---
# /sync
Syncs all markdown content from `content/blog/` and `content/pages/` to the development Convex database.
## What it does
1. Reads all `.md` files from content directories
2. Parses frontmatter with gray-matter
3. Validates required fields
4. Calculates reading time if not provided
5. Upserts content to Convex database
6. Generates raw markdown files in `public/raw/`
## Usage
```bash
npm run sync
```
## When to use
- After creating or editing markdown files
- After importing content from URLs
- When content is not appearing on the site
## Output
The command shows:
- Number of posts synced
- Number of pages synced
- Any validation warnings
- Generated raw files
## Next steps
After syncing, visit `http://localhost:5173` to see your content.
For production sync, use `/sync-prod` instead.

6
.opencode/config.json Normal file
View File

@@ -0,0 +1,6 @@
{
"$schema": "https://opencode.ai/schemas/config.json",
"name": "markdown-publishing-framework",
"description": "AI-first markdown publishing with Convex real-time sync. Write markdown, run sync, content appears instantly.",
"version": "1.0.0"
}

View File

@@ -0,0 +1,47 @@
/**
* Sync Helper Plugin for OpenCode
*
* Provides helpful reminders when content files are edited.
* Minimal implementation - just logging, no auto-sync.
*/
import type { Plugin } from "@opencode-ai/plugin";
export const SyncHelper: Plugin = async ({ client }) => {
// Track if we've reminded recently to avoid spam
let lastReminder = 0;
const REMINDER_COOLDOWN = 30000; // 30 seconds
return {
hooks: {
// When a file is edited
"file.edited": async (event) => {
const path = event.path;
// Check if it's a content file
if (
path.startsWith("content/blog/") ||
path.startsWith("content/pages/")
) {
const now = Date.now();
// Only remind if cooldown has passed
if (now - lastReminder > REMINDER_COOLDOWN) {
lastReminder = now;
await client.app.log(
"info",
`Content changed: ${path} - Run /sync to publish`
);
}
}
},
// When session becomes idle
"session.idle": async () => {
// Could add pending sync detection here in the future
},
},
};
};
export default SyncHelper;

187
.opencode/skill/content.md Normal file
View File

@@ -0,0 +1,187 @@
---
description: Content management guide
---
# Content Management Guide
How to manage content in the markdown publishing framework.
## Content locations
| Type | Location | Purpose |
|------|----------|---------|
| Blog posts | `content/blog/*.md` | Time-based articles |
| Pages | `content/pages/*.md` | Static pages |
| Raw files | `public/raw/*.md` | Generated static copies |
| Images | `public/images/` | Static images |
## Creating content
### New blog post
1. Create file: `content/blog/my-post.md`
2. Add required frontmatter:
```yaml
---
title: "My Post Title"
description: "SEO description"
date: "2025-01-15"
slug: "my-post"
published: true
tags: ["topic"]
---
```
3. Write content in markdown
4. Run `npm run sync`
5. View at `localhost:5173/my-post`
### New page
1. Create file: `content/pages/my-page.md`
2. Add required frontmatter:
```yaml
---
title: "My Page"
slug: "my-page"
published: true
---
```
3. Write content
4. Run `npm run sync`
5. View at `localhost:5173/my-page`
## Special pages
| Slug | Purpose |
|------|---------|
| `home-intro` | Homepage intro content |
| `footer` | Footer content |
These render in special locations. Set `showInNav: false` to hide from navigation.
## Content workflow
```
Write markdown --> npm run sync --> Convex DB --> Site
```
### Development
1. Edit markdown files
2. Run `npm run sync`
3. View changes at `localhost:5173`
### Production
1. Edit markdown files
2. Run `npm run sync:prod`
3. View changes on production site
## Images
### Local images
Place in `public/images/` and reference:
```markdown
![Alt text](/images/my-image.png)
```
### External images
Use full URLs:
```markdown
![Alt text](https://example.com/image.png)
```
### Image in frontmatter
```yaml
image: "/images/og-image.png"
showImageAtTop: true
```
## Markdown features
### Code blocks
````markdown
```typescript
const example = "code";
```
````
### Tables
```markdown
| Header | Header |
|--------|--------|
| Cell | Cell |
```
### Links
```markdown
[Link text](/internal-path)
[External](https://example.com)
```
### Headings
```markdown
# H1 (demoted to H2 in posts)
## H2
### H3
```
## Docs section
To add content to the docs sidebar:
```yaml
docsSection: true
docsSectionGroup: "Group Name"
docsSectionOrder: 1
```
## Unlisted content
To hide from listings but keep accessible:
```yaml
published: true
unlisted: true
```
## Draft content
To hide completely:
```yaml
published: false
```
## Import from URL
```bash
npm run import https://example.com/article
npm run sync
```
## Export from Dashboard
```bash
npm run export:db # Dev
npm run export:db:prod # Prod
```
Exports dashboard-created content to markdown files.
## Best practices
1. **Unique slugs** - Every post/page needs a unique slug
2. **SEO descriptions** - Keep under 160 characters
3. **Tags** - Use consistent tag names
4. **Images** - Optimize before uploading
5. **Sync after changes** - Always run sync

192
.opencode/skill/convex.md Normal file
View File

@@ -0,0 +1,192 @@
---
description: Convex patterns and conventions
---
# Convex Patterns Reference
Convex patterns and conventions for this markdown publishing framework.
## Function structure
Every Convex function needs argument and return validators:
```typescript
import { query } from "./_generated/server";
import { v } from "convex/values";
export const myQuery = query({
args: { slug: v.string() },
returns: v.union(v.object({...}), v.null()),
handler: async (ctx, args) => {
// implementation
},
});
```
If a function returns nothing, use `returns: v.null()` and `return null;`.
## Always use indexes
Never use `.filter()` on queries. Define indexes in schema and use `.withIndex()`:
```typescript
// Good
const post = await ctx.db
.query("posts")
.withIndex("by_slug", (q) => q.eq("slug", args.slug))
.first();
// Bad - causes table scans
const post = await ctx.db
.query("posts")
.filter((q) => q.eq(q.field("slug"), args.slug))
.first();
```
## Make mutations idempotent
Mutations should be safe to call multiple times:
```typescript
export const heartbeat = mutation({
args: { sessionId: v.string(), currentPath: v.string() },
returns: v.null(),
handler: async (ctx, args) => {
const now = Date.now();
const existing = await ctx.db
.query("activeSessions")
.withIndex("by_sessionId", (q) => q.eq("sessionId", args.sessionId))
.first();
if (existing) {
// Early return if recently updated with same data
if (existing.currentPath === args.currentPath &&
now - existing.lastSeen < 10000) {
return null;
}
await ctx.db.patch(existing._id, {
currentPath: args.currentPath,
lastSeen: now
});
return null;
}
await ctx.db.insert("activeSessions", { ...args, lastSeen: now });
return null;
},
});
```
## Patch directly without reading
When you only need to update fields, patch directly:
```typescript
// Good - patch directly
await ctx.db.patch(args.id, { content: args.content });
// Bad - unnecessary read creates conflict window
const doc = await ctx.db.get(args.id);
if (!doc) throw new Error("Not found");
await ctx.db.patch(args.id, { content: args.content });
```
## Use event records for counters
Never increment counters on documents. Use separate event records:
```typescript
// Good - insert event record
await ctx.db.insert("pageViews", {
path,
sessionId,
timestamp: Date.now()
});
// Bad - counter updates cause write conflicts
await ctx.db.patch(pageId, { views: page.views + 1 });
```
## Schema indexes in this project
Key indexes defined in `convex/schema.ts`:
```typescript
posts: defineTable({...})
.index("by_slug", ["slug"])
.index("by_published", ["published"])
.index("by_featured", ["featured"])
.searchIndex("search_title", { searchField: "title" })
.searchIndex("search_content", { searchField: "content" })
pages: defineTable({...})
.index("by_slug", ["slug"])
.index("by_published", ["published"])
.index("by_featured", ["featured"])
```
## Common query patterns
### Get post by slug
```typescript
const post = await ctx.db
.query("posts")
.withIndex("by_slug", (q) => q.eq("slug", args.slug))
.first();
```
### Get all published posts
```typescript
const posts = await ctx.db
.query("posts")
.withIndex("by_published", (q) => q.eq("published", true))
.order("desc")
.collect();
```
### Get featured items
```typescript
const featured = await ctx.db
.query("posts")
.withIndex("by_featured", (q) => q.eq("featured", true))
.collect();
```
### Full text search
```typescript
const results = await ctx.db
.query("posts")
.withSearchIndex("search_content", (q) => q.search("content", searchTerm))
.take(10);
```
## File locations
| File | Purpose |
|------|---------|
| `convex/schema.ts` | Database schema and indexes |
| `convex/posts.ts` | Post queries/mutations |
| `convex/pages.ts` | Page queries/mutations |
| `convex/stats.ts` | Analytics (heartbeat, pageViews) |
| `convex/search.ts` | Full text search |
| `convex/http.ts` | HTTP endpoints |
| `convex/rss.ts` | RSS feed generation |
| `convex/crons.ts` | Scheduled jobs |
## Write conflict prevention
This project uses specific patterns to avoid write conflicts:
**Backend (convex/stats.ts):**
- 10-second dedup window for heartbeats
- Early return when session was recently updated
- Indexed queries for efficient lookups
**Frontend (src/hooks/usePageTracking.ts):**
- 5-second debounce window using refs
- Pending state tracking prevents overlapping calls
- Path tracking skips redundant heartbeats

View File

@@ -0,0 +1,194 @@
---
description: Frontmatter syntax for posts and pages
---
# Frontmatter Reference
How to write frontmatter for markdown-blog posts and pages.
## Blog post frontmatter
Location: `content/blog/*.md`
### Required fields
| Field | Type | Description |
|-------|------|-------------|
| title | string | Post title |
| description | string | SEO description |
| date | string | YYYY-MM-DD format |
| slug | string | URL path (must be unique) |
| published | boolean | true to show publicly |
| tags | string[] | Array of topic tags |
### Optional fields
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| featured | boolean | false | Show in featured section |
| featuredOrder | number | - | Display order (lower = first) |
| image | string | - | OG/header image path |
| showImageAtTop | boolean | false | Display image at top of post |
| excerpt | string | - | Short text for card view |
| readTime | string | auto | Reading time (auto-calculated if omitted) |
| authorName | string | - | Author display name |
| authorImage | string | - | Author avatar URL (round) |
| layout | string | - | "sidebar" for docs-style layout |
| rightSidebar | boolean | true | Show right sidebar |
| aiChat | boolean | false | Enable AI chat in sidebar |
| blogFeatured | boolean | false | Hero featured on /blog page |
| newsletter | boolean | - | Override newsletter signup |
| contactForm | boolean | false | Enable contact form |
| unlisted | boolean | false | Hide from listings but allow direct access via slug |
| showFooter | boolean | - | Override footer display |
| footer | string | - | Custom footer markdown |
| showSocialFooter | boolean | - | Override social footer |
### Example blog post
```markdown
---
title: "How to write a blog post"
description: "A guide to writing posts with frontmatter"
date: "2025-01-15"
slug: "how-to-write-a-blog-post"
published: true
tags: ["tutorial", "markdown"]
featured: true
featuredOrder: 1
image: "/images/my-post.png"
excerpt: "Learn how to create blog posts"
authorName: "Your Name"
authorImage: "/images/authors/you.png"
---
Your content here...
```
## Page frontmatter
Location: `content/pages/*.md`
### Required fields
| Field | Type | Description |
|-------|------|-------------|
| title | string | Page title |
| slug | string | URL path (must be unique) |
| published | boolean | true to show publicly |
### Optional fields
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| order | number | - | Nav order (lower = first) |
| showInNav | boolean | true | Show in navigation menu |
| featured | boolean | false | Show in featured section |
| featuredOrder | number | - | Display order (lower = first) |
| image | string | - | Thumbnail/OG image for cards |
| showImageAtTop | boolean | false | Display image at top |
| excerpt | string | - | Short text for card view |
| authorName | string | - | Author display name |
| authorImage | string | - | Author avatar URL |
| layout | string | - | "sidebar" for docs-style |
| rightSidebar | boolean | true | Show right sidebar |
| aiChat | boolean | false | Enable AI chat |
| contactForm | boolean | false | Enable contact form |
| newsletter | boolean | - | Override newsletter signup |
| textAlign | string | "left" | "left", "center", "right" |
| showFooter | boolean | - | Override footer display |
| footer | string | - | Custom footer markdown |
| showSocialFooter | boolean | - | Override social footer |
### Example page
```markdown
---
title: "About"
slug: "about"
published: true
order: 1
showInNav: true
featured: true
featuredOrder: 2
excerpt: "Learn about this site"
---
Page content here...
```
## Common patterns
### Featured post on homepage
```yaml
featured: true
featuredOrder: 1
```
### Hero post on /blog page
```yaml
blogFeatured: true
image: "/images/hero.png"
```
### Docs-style page with sidebar
```yaml
layout: "sidebar"
rightSidebar: true
```
### Hide from navigation
```yaml
showInNav: false
```
### Unlisted post
```yaml
published: true
unlisted: true
```
## Docs section navigation
Posts and pages can appear in the docs sidebar:
| Field | Type | Description |
|-------|------|-------------|
| docsSection | boolean | Include in docs navigation |
| docsSectionGroup | string | Sidebar group name |
| docsSectionOrder | number | Order within group |
| docsSectionGroupOrder | number | Order of group itself |
| docsSectionGroupIcon | string | Phosphor icon name |
| docsLanding | boolean | Use as /docs landing page |
### Example docs post
```yaml
---
title: "Getting Started"
slug: "getting-started"
published: true
docsSection: true
docsSectionGroup: "Quick Start"
docsSectionOrder: 1
docsSectionGroupOrder: 1
docsSectionGroupIcon: "Rocket"
layout: "sidebar"
---
```
## Validation
The sync script validates:
- Required fields must be present
- `slug` must be unique
- `date` should be YYYY-MM-DD format
- `published` must be boolean
Missing required fields will cause the file to be skipped with a warning.

126
.opencode/skill/sync.md Normal file
View File

@@ -0,0 +1,126 @@
---
description: How the content sync system works
---
# Sync System Reference
How the content sync system works in this markdown publishing framework.
## Overview
Content flows from markdown files to Convex database via sync scripts. Changes appear instantly because Convex provides real-time updates.
```
content/blog/*.md --+
+--> npm run sync --> Convex DB --> Site
content/pages/*.md --+
```
## Sync commands
| Command | Environment | What it syncs |
|---------|-------------|---------------|
| `npm run sync` | Development | Posts + pages to dev Convex |
| `npm run sync:prod` | Production | Posts + pages to prod Convex |
| `npm run sync:discovery` | Development | AGENTS.md + llms.txt |
| `npm run sync:discovery:prod` | Production | AGENTS.md + llms.txt |
| `npm run sync:all` | Development | Everything |
| `npm run sync:all:prod` | Production | Everything |
## How sync works
### Content sync (sync-posts.ts)
1. Reads all `.md` files from `content/blog/` and `content/pages/`
2. Parses frontmatter with `gray-matter`
3. Validates required fields (title, slug, etc.)
4. Calculates reading time if not provided
5. Calls Convex mutations to upsert content
6. Generates raw markdown files in `public/raw/`
### Discovery sync (sync-discovery-files.ts)
1. Reads site configuration from `src/config/siteConfig.ts`
2. Queries Convex for post/page counts
3. Updates `AGENTS.md` with current status
4. Generates `public/llms.txt` with API info
## File locations
| Script | Purpose |
|--------|---------|
| `scripts/sync-posts.ts` | Syncs markdown content |
| `scripts/sync-discovery-files.ts` | Updates discovery files |
| `scripts/import-url.ts` | Imports external URLs |
## Environment variables
| File | Used by |
|------|---------|
| `.env.local` | Development sync (default) |
| `.env.production.local` | Production sync |
Both files contain `VITE_CONVEX_URL` pointing to the Convex deployment.
## What gets synced
### Posts (content/blog/)
- Frontmatter fields (title, description, date, tags, etc.)
- Full markdown content
- Calculated reading time
### Pages (content/pages/)
- Frontmatter fields (title, slug, order, etc.)
- Full markdown content
### Generated files (public/raw/)
For each published post/page, a static markdown file is generated at `public/raw/{slug}.md`. Also generates `public/raw/index.md` listing all content.
## Sync mutations
The sync scripts call these Convex mutations:
```typescript
// Posts
api.posts.syncPostsPublic({ posts: ParsedPost[] })
// Pages
api.pages.syncPagesPublic({ pages: ParsedPage[] })
```
## Adding a new frontmatter field
1. Add to interface in `scripts/sync-posts.ts`
2. Add to Convex schema in `convex/schema.ts`
3. Add to sync mutation in `convex/posts.ts` or `convex/pages.ts`
4. Add to return validators in queries
5. Run `npm run sync` to apply
## Import from URL
```bash
npm run import https://example.com/article
```
Requires `FIRECRAWL_API_KEY`. After import, run sync.
## Troubleshooting
### "VITE_CONVEX_URL not set"
Run `npx convex dev` first to create `.env.local`.
### Posts not appearing
1. Check `published: true` in frontmatter
2. Verify required fields are present
3. Check Convex dashboard for errors
4. Run `npm run sync` again
### Sync to wrong environment
- `npm run sync` = development
- `npm run sync:prod` = production

View File

@@ -19,10 +19,10 @@ Your content is instantly available to browsers, LLMs, and AI agents.. Write mar
- **Site Name**: markdown - **Site Name**: markdown
- **Site Title**: markdown sync framework - **Site Title**: markdown sync framework
- **Site URL**: https://yoursite.example.com - **Site URL**: https://yoursite.example.com
- **Total Posts**: 18 - **Total Posts**: 19
- **Total Pages**: 4 - **Total Pages**: 4
- **Latest Post**: 2026-01-07 - **Latest Post**: 2026-01-10
- **Last Updated**: 2026-01-09T07:02:12.472Z - **Last Updated**: 2026-01-10T23:49:21.881Z
## Tech stack ## Tech stack

View File

@@ -5,7 +5,7 @@ Project instructions for Claude Code.
## Project context ## Project context
<!-- Auto-updated by sync:discovery --> <!-- Auto-updated by sync:discovery -->
<!-- Site: markdown | Posts: 18 | Pages: 4 | Updated: 2026-01-09T07:02:12.473Z --> <!-- Site: markdown | Posts: 19 | Pages: 4 | Updated: 2026-01-10T23:49:21.882Z -->
Markdown sync framework. Write markdown in `content/`, run sync commands, content appears instantly via Convex real-time database. Built for developers and AI agents. Markdown sync framework. Write markdown in `content/`, run sync commands, content appears instantly via Convex real-time database. Built for developers and AI agents.

500
README.md
View File

@@ -43,13 +43,18 @@ npm run sync # dev
npm run sync:prod # production npm run sync:prod # production
``` ```
**MCP Server:** The site includes an HTTP-based Model Context Protocol (MCP) server at `/mcp` for AI tool integration. Connect Cursor, Claude Desktop, and other MCP clients to access blog content programmatically. See [How to Use the MCP Server](https://www.markdown.fast/how-to-use-mcp-server) for setup instructions.
## Documentation ## Documentation
- **[Setup Guide](https://www.markdown.fast/setup-guide)** - Complete fork and deployment guide Full documentation is available at **[markdown.fast/docs](https://www.markdown.fast/docs)**
- **[Configuration Guide](https://www.markdown.fast/fork-configuration-guide)** - Automated or manual fork setup
- **[Full Documentation](https://www.markdown.fast/docs)** - Docs for all features and configuration ### Guides
- [Setup Guide](https://www.markdown.fast/setup-guide) - Complete fork and deployment guide
- [Fork Configuration Guide](https://www.markdown.fast/fork-configuration-guide) - Automated or manual fork setup
- [Dashboard Guide](https://www.markdown.fast/how-to-use-the-markdown-sync-dashboard) - Content management and site configuration
- [WorkOS Setup](https://www.markdown.fast/how-to-setup-workos) - Authentication for dashboard protection
- [MCP Server](https://www.markdown.fast/how-to-use-mcp-server) - AI tool integration for Cursor and Claude Desktop
- [AgentMail Setup](https://www.markdown.fast/blog/how-to-use-agentmail) - Newsletter and contact form integration
### AI Development Tools ### AI Development Tools
@@ -57,6 +62,7 @@ The project includes documentation optimized for AI coding assistants:
- **CLAUDE.md** - Project instructions for Claude Code CLI with workflows, commands, and conventions - **CLAUDE.md** - Project instructions for Claude Code CLI with workflows, commands, and conventions
- **AGENTS.md** - General AI agent instructions for understanding the codebase structure - **AGENTS.md** - General AI agent instructions for understanding the codebase structure
- **llms.txt** - AI agent discovery file at `/llms.txt`
- **.claude/skills/** - Focused skill documentation: - **.claude/skills/** - Focused skill documentation:
- `frontmatter.md` - Complete frontmatter syntax and all field options - `frontmatter.md` - Complete frontmatter syntax and all field options
- `convex.md` - Convex patterns specific to this app - `convex.md` - Convex patterns specific to this app
@@ -64,144 +70,23 @@ The project includes documentation optimized for AI coding assistants:
These files are automatically updated during `npm run sync:discovery` with current site statistics. These files are automatically updated during `npm run sync:discovery` with current site statistics.
## Features
See the full feature list on the [About page](https://www.markdown.fast/about).
Key features include real-time sync, four theme options, full-text search, analytics dashboard, MCP server for AI tools, newsletter integration, and SEO optimization with RSS feeds, sitemaps, and structured data.
## Fork Configuration ## Fork Configuration
After forking this project, you have two options to configure your site: After forking, run the automated configuration:
### Option 1: Automated (Recommended)
Run a single command to configure all files automatically:
```bash ```bash
# Copy the example config
cp fork-config.json.example fork-config.json cp fork-config.json.example fork-config.json
# Edit fork-config.json with your site info
# Edit with your site information
# Open fork-config.json and update the values
# Apply all changes
npm run configure npm run configure
``` ```
This updates all 11 configuration files in one step. See the [Fork Configuration Guide](https://www.markdown.fast/fork-configuration-guide) for detailed instructions.
### Option 2: Manual
Follow the step-by-step guide in `FORK_CONFIG.md` to update each file manually. This guide includes code snippets and an AI agent prompt for assistance.
## Features
- Markdown-based blog posts with frontmatter
- Syntax highlighting for code blocks
- Four theme options: Dark, Light, Tan (default), Cloud
- Real-time data with Convex
- Fully responsive design
- Real-time analytics at `/stats` with visitor map
- Full text search with Command+K shortcut
- Featured section with list/card view toggle
- Logo gallery with continuous marquee scroll or static grid
- GitHub contributions graph with year navigation
- Static raw markdown files at `/raw/{slug}.md`
- Dedicated blog page with configurable navigation order and featured layout
- Markdown writing page at `/write` with frontmatter reference
- AI Agent chat (powered by Anthropic Claude) on Write page and optionally in right sidebar
- Tag pages at `/tags/[tag]` with view mode toggle
- Related posts based on shared tags
- Footer component with markdown support and images
- Social footer with customizable social links and copyright
- Right sidebar for CopyPageDropdown and AI chat
- Contact forms on any page or post
- Newsletter subscriptions and admin UI
- Homepage post limit with optional "read more" link
- Blog page featured layout with hero post
- Show image at top of posts/pages
- Version control with 3-day history for posts and pages (diff view, one-click restore)
### SEO and Discovery
- RSS feeds at `/rss.xml` and `/rss-full.xml` (with full content)
- Dynamic sitemap at `/sitemap.xml`
- JSON-LD structured data for Google rich results
- Open Graph and Twitter Card meta tags
- `robots.txt` with AI crawler rules
- `llms.txt` for AI agent discovery
### AI and LLM Access
- `/api/posts` - JSON list of all posts for agents
- `/api/post?slug=xxx` - Single post JSON or markdown
- `/api/export` - Batch export all posts with full content
- `/raw/{slug}.md` - Static raw markdown files for each post and page
- `/rss-full.xml` - Full content RSS for LLM ingestion
- `/.well-known/ai-plugin.json` - AI plugin manifest
- `/openapi.yaml` - OpenAPI 3.0 specification
- Copy Page dropdown for sharing to ChatGPT, Claude, Perplexity (uses raw markdown URLs for better AI parsing)
### MCP Server
The site includes an HTTP-based Model Context Protocol (MCP) server for AI tool integration. It allows AI assistants like Cursor and Claude Desktop to access blog content programmatically.
**Endpoint:** `https://www.markdown.fast/mcp`
**Features:**
- 24/7 availability via Netlify Edge Functions (no local machine required)
- Public access with rate limiting (50 req/min per IP)
- Optional API key for higher limits (1000 req/min)
- Seven tools: `list_posts`, `get_post`, `list_pages`, `get_page`, `get_homepage`, `search_content`, `export_all`
**Configuration:**
Add to your Cursor config (`~/.cursor/mcp.json`):
```json
{
"mcpServers": {
"markdown-fast": {
"url": "https://www.markdown.fast/mcp"
}
}
}
```
**For forks:** Set `VITE_CONVEX_URL` in Netlify environment variables. Optionally set `MCP_API_KEY` for authenticated access.
See [How to Use the MCP Server](https://www.markdown.fast/how-to-use-mcp-server) for full documentation.
### Content Import
- Import external URLs as markdown posts using Firecrawl
- Run `npm run import <url>` to scrape and create draft posts locally
- Then sync to dev or prod with `npm run sync` or `npm run sync:prod`
### Dashboard
The framework includes a centralized dashboard at `/dashboard` for managing content and configuring your site. Features include:
- Content management: Edit posts and pages with live preview
- Version control: View history, compare diffs, restore previous versions (3-day retention)
- Sync commands: Run sync operations from the browser
- Site configuration: Configure all settings via UI
- Newsletter management: Integrated subscriber and email management
- AI Agent: Writing assistance powered by Claude
- Analytics: Real-time stats dashboard
WorkOS authentication is recommended so no one has access to your dashboard if it's enabled. Configure it in `siteConfig.ts` to protect the dashboard in production. See [How to use the Markdown sync dashboard](https://www.markdown.fast/how-to-use-the-markdown-sync-dashboard) and [How to setup WorkOS](https://www.markdown.fast/how-to-setup-workos) for details.
### Newsletter and Email
The framework includes AgentMail integration for newsletter subscriptions and contact forms. Features include:
- Newsletter subscriptions and sending
- Contact forms on any post or page
- Automated weekly digests (Sundays 9am UTC)
- Developer notifications (new subscriber alerts, weekly stats summaries)
- Admin UI for subscriber management at `/newsletter-admin`
- CLI tools for sending newsletters and stats
- Custom email composition with markdown support
- Email statistics dashboard
See the [AgentMail setup guide](https://www.markdown.fast/blog/how-to-use-agentmail) for configuration instructions.
## Getting Started ## Getting Started
@@ -234,370 +119,31 @@ npm run dev
4. Open http://localhost:5173 4. Open http://localhost:5173
## Writing Blog Posts
Create markdown files in `content/blog/` with frontmatter:
## Static Pages (Optional)
Create optional pages like About, Projects, or Contact in `content/pages/`:
```markdown
---
title: "About"
slug: "about"
published: true
order: 1
---
Your page content here...
```
Pages appear as navigation links in the top right, next to the theme toggle. The `order` field controls display order (lower numbers first).
```markdown
---
title: "Your Post Title"
description: "A brief description"
date: "2025-01-15"
slug: "your-post-slug"
published: true
tags: ["tag1", "tag2"]
readTime: "5 min read"
image: "/images/my-header.png"
excerpt: "Short text for featured cards"
---
Your markdown content here...
```
## Logo Gallery
The homepage includes a scrolling logo gallery with sample logos. Configure in `siteConfig`:
### Disable the gallery
```typescript
logoGallery: {
enabled: false,
// ...
},
```
## GitHub Contributions Graph
Display your GitHub contribution activity on the homepage. Configure in `src/config/siteConfig.ts`:
```typescript
gitHubContributions: {
enabled: true, // Set to false to hide
username: "yourusername", // Your GitHub username
showYearNavigation: true, // Show arrows to navigate between years
linkToProfile: true, // Click graph to open GitHub profile
title: "GitHub Activity", // Optional title above the graph
},
```
## Visitor Map
Display real-time visitor locations on a world map on the stats page. Uses Netlify's built-in geo detection (no third-party API needed). Privacy friendly: only stores city, country, and coordinates. No IP addresses stored.
Configure in `src/config/siteConfig.ts`:
```typescript
visitorMap: {
enabled: true, // Set to false to hide the visitor map
title: "Live Visitors", // Optional title above the map
},
```
## Syncing Posts
Posts are synced to Convex. The sync script reads markdown files from `content/blog/` and `content/pages/`, then uploads them to your Convex database.
### Environment Files
| File | Purpose |
| ----------------------- | -------------------------------------------------------- |
| `.env.local` | Development deployment URL (created by `npx convex dev`) |
| `.env.production.local` | Production deployment URL (create manually) |
Both files are gitignored. Each developer creates their own.
### Sync Commands
Sync command scripts are located in `scripts/` (sync-posts.ts, sync-discovery-files.ts).
**Development:**
- `npm run sync` - Sync markdown content to development Convex
- `npm run sync:discovery` - Update discovery files (AGENTS.md, llms.txt) with development data
- `npm run sync:all` - Sync content + discovery files together
**Production:**
- `npm run sync:prod` - Sync markdown content to production Convex
- `npm run sync:discovery:prod` - Update discovery files with production data
- `npm run sync:all:prod` - Sync content + discovery files together
**Export dashboard content:**
- `npm run export:db` - Export dashboard posts/pages to content folders (development)
- `npm run export:db:prod` - Export dashboard posts/pages (production)
**Development sync:**
```bash
npm run sync
```
**Production sync:**
First, create `.env.production.local` with your production Convex URL:
```
VITE_CONVEX_URL=https://your-prod-deployment.convex.cloud
```
Then sync:
```bash
npm run sync:prod
```
## Deployment ## Deployment
### Netlify ### Netlify
[![Netlify Status](https://api.netlify.com/api/v1/badges/d8c4d83d-7486-42de-844b-6f09986dc9aa/deploy-status)](https://app.netlify.com/projects/markdowncms/deploys) [![Netlify Status](https://api.netlify.com/api/v1/badges/d8c4d83d-7486-42de-844b-6f09986dc9aa/deploy-status)](https://app.netlify.com/projects/markdowncms/deploys)
For detailed setup, see the [Convex Netlify Deployment Guide](https://docs.convex.dev/production/hosting/netlify).
1. Deploy Convex functions to production: 1. Deploy Convex functions to production:
```bash ```bash
npx convex deploy npx convex deploy
``` ```
Note the production URL (e.g., `https://your-deployment.convex.cloud`).
2. Connect your repository to Netlify 2. Connect your repository to Netlify
3. Configure build settings: 3. Configure build settings:
- Build command: `npm ci --include=dev && npx convex deploy --cmd 'npm run build'` - Build command: `npm ci --include=dev && npx convex deploy --cmd 'npm run build'`
- Publish directory: `dist` - Publish directory: `dist`
4. Add environment variables in Netlify dashboard: 4. Add environment variables in Netlify dashboard:
- `CONVEX_DEPLOY_KEY` - Generate from [Convex Dashboard](https://dashboard.convex.dev) > Project Settings > Deploy Key - `CONVEX_DEPLOY_KEY` - Generate from [Convex Dashboard](https://dashboard.convex.dev) > Project Settings > Deploy Key
- `VITE_CONVEX_URL` - Your production Convex URL (e.g., `https://your-deployment.convex.cloud`) - `VITE_CONVEX_URL` - Your production Convex URL
The `CONVEX_DEPLOY_KEY` deploys functions at build time. The `VITE_CONVEX_URL` is required for edge functions (RSS, sitemap, API) to proxy requests at runtime. For detailed setup, see the [Convex Netlify Deployment Guide](https://docs.convex.dev/production/hosting/netlify) and [netlify-deploy-fix.md](./netlify-deploy-fix.md) for troubleshooting.
**Build issues?** Netlify sets `NODE_ENV=production` which skips devDependencies. The `--include=dev` flag fixes this. See [netlify-deploy-fix.md](./netlify-deploy-fix.md) for detailed troubleshooting.
## Project Structure
```
markdown-site/
├── content/blog/ # Markdown blog posts
├── convex/ # Convex backend
│ ├── http.ts # HTTP endpoints (sitemap, API, RSS)
│ ├── posts.ts # Post queries and mutations
│ ├── rss.ts # RSS feed generation
│ └── schema.ts # Database schema
├── netlify/ # Netlify edge functions
│ └── edge-functions/
│ ├── rss.ts # RSS feed proxy
│ ├── sitemap.ts # Sitemap proxy
│ ├── api.ts # API endpoint proxy
│ └── botMeta.ts # OG crawler detection
├── public/ # Static assets
│ ├── images/ # Blog images and OG images
│ ├── robots.txt # Crawler rules
│ └── llms.txt # AI agent discovery
├── scripts/ # Build scripts
└── src/
├── components/ # React components
├── context/ # Theme context
├── pages/ # Page components
└── styles/ # Global CSS
```
## Scripts Reference
| Script | Description |
| ----------------------------- | ---------------------------------------------- |
| `npm run dev` | Start Vite dev server |
| `npm run dev:convex` | Start Convex dev backend |
| `npm run sync` | Sync posts to dev deployment |
| `npm run sync:prod` | Sync posts to production deployment |
| `npm run sync:discovery` | Update discovery files (development) |
| `npm run sync:discovery:prod` | Update discovery files (production) |
| `npm run sync:all` | Sync content + discovery (development) |
| `npm run sync:all:prod` | Sync content + discovery (production) |
| `npm run import` | Import URL as local markdown draft (then sync) |
| `npm run build` | Build for production |
| `npm run deploy` | Sync + build (for manual deploys) |
| `npm run deploy:prod` | Deploy Convex functions + sync to production |
## Tech Stack ## Tech Stack
- React 18 React 18, TypeScript, Vite, Convex, Netlify
- TypeScript
- Vite
- Convex
- react-markdown
- react-syntax-highlighter
- date-fns
- lucide-react
- @phosphor-icons/react
- Netlify
## Search
Press `Command+K` (Mac) or `Ctrl+K` (Windows/Linux) to open the search modal. The search uses Convex full text search to find posts and pages by title and content.
Features:
- Real-time results as you type
- Keyboard navigation (arrow keys, Enter, Escape)
- Result snippets with context around matches
- Distinguishes between posts and pages
- Works with all four themes
The search icon appears in the top navigation bar next to the theme toggle.
## Real-time Stats
The `/stats` page shows real-time analytics powered by Convex:
- **Active visitors**: Current visitors on the site with per-page breakdown
- **Total page views**: All-time view count
- **Unique visitors**: Based on anonymous session IDs
- **Views by page**: List of all pages sorted by view count
Stats update automatically via Convex subscriptions. No page refresh needed.
How it works:
- Page views are recorded as event records (not counters) to avoid write conflicts
- Active sessions use heartbeat presence (30s interval, 2min timeout)
- A cron job cleans up stale sessions every 5 minutes
- No PII stored (only anonymous session UUIDs)
## API Endpoints
| Endpoint | Description |
| ------------------------------ | ----------------------------------- |
| `/stats` | Real-time site analytics |
| `/rss.xml` | RSS feed with post descriptions |
| `/rss-full.xml` | RSS feed with full post content |
| `/sitemap.xml` | Dynamic XML sitemap |
| `/api/posts` | JSON list of all posts |
| `/api/post?slug=xxx` | Single post as JSON |
| `/api/post?slug=xxx&format=md` | Single post as markdown |
| `/api/export` | Batch export all posts with content |
| `/meta/post?slug=xxx` | Open Graph HTML for crawlers |
| `/.well-known/ai-plugin.json` | AI plugin manifest |
| `/openapi.yaml` | OpenAPI 3.0 specification |
| `/llms.txt` | AI agent discovery |
## Import External Content
Use Firecrawl to import articles from external URLs as markdown posts:
```bash
npm run import https://example.com/article
```
This will:
1. Scrape the URL using Firecrawl API
2. Convert to clean markdown
3. Create a draft post in `content/blog/` locally
4. Add frontmatter with title, description, and today's date
**Setup:**
1. Get an API key from [firecrawl.dev](https://firecrawl.dev)
2. Add to `.env.local`:
```
FIRECRAWL_API_KEY=fc-your-api-key
```
**Why no `npm run import:prod`?** The import command only creates local markdown files. It does not interact with Convex. After importing, sync to your target environment:
- `npm run sync` for development
- `npm run sync:prod` for production
- Use `npm run sync:all` or `npm run sync:all:prod` to sync content and update discovery files together
Imported posts are created as drafts (`published: false`). Review, edit, set `published: true`, then sync.
## Theme Configuration
The default theme is Tan. Users can cycle through themes using the toggle:
- Dark (Moon icon)
- Light (Sun icon)
- Tan (Half icon) - default
- Cloud (Cloud icon)
To change the default theme, edit `src/context/ThemeContext.tsx`:
```typescript
const DEFAULT_THEME: Theme = "tan"; // Change to "dark", "light", or "cloud"
```
## Font Configuration
The blog uses a serif font (New York) by default. To switch fonts, edit `src/styles/global.css`:
### Font Sizes
All font sizes use CSS variables defined in `:root`. Customize sizes by editing the variables:
## Write Page
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`.
**Features:**
- Three-column Cursor docs-style layout
- Content type selector (Blog Post or Page) with dynamic frontmatter templates
- Frontmatter field reference with individual copy buttons
- Font switcher (Serif/Sans-serif/Monospace)
- Theme toggle matching site themes
- Word, line, and character counts
- localStorage persistence for content, type, and font preference
**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 (per-session, per-context)
- Page content can be provided as context for AI responses
- Markdown rendering for AI responses with copy functionality
- User-friendly error messages when API key is not configured
- Anonymous session authentication using localStorage
- Chat history limited to last 20 messages for efficiency
- System prompt configurable via environment variables (split or single prompt)
## Source ## Source

44
TASK.md
View File

@@ -4,10 +4,52 @@
## Current Status ## Current Status
v2.16.3 ready. Social icons in hamburger menu and Dashboard Config alignment. v2.18.1 ready. README cleanup with docs links.
## Completed ## Completed
- [x] README.md streamlined with docs links (v2.18.1)
- [x] Reduced from 609 lines to 155 lines
- [x] Added Documentation section with links to markdown.fast/docs
- [x] Added Guides subsection with links to specific doc pages
- [x] Simplified Features section with link to About page
- [x] Simplified Fork Configuration with doc link
- [x] Removed detailed sections covered by live docs
- [x] OpenCode AI development tool integration (v2.18.0)
- [x] Created `.opencode/` directory structure
- [x] Created `opencode.json` root configuration
- [x] Created 3 agents: orchestrator, content-writer, sync-manager
- [x] Created 6 commands: sync, sync-prod, create-post, create-page, import, deploy
- [x] Adapted 4 skills from .claude/skills/: frontmatter, sync, convex, content
- [x] Created sync-helper plugin for content change reminders
- [x] Created docs-opencode.md documentation page
- [x] Updated files.md with OpenCode Configuration section
- [x] Works alongside Claude Code and Cursor without conflicts
- [x] ConvexFS Media Library with Bunny CDN (v2.17.0)
- [x] Installed convex-fs package and configured Convex component
- [x] Created convex/fs.ts with Bunny CDN configuration
- [x] Created convex/files.ts with file mutations and queries
- [x] Added ConvexFS routes to convex/http.ts
- [x] Created MediaLibrary component with upload, copy, delete
- [x] Added bulk select and delete functionality
- [x] Enhanced ImageUploadModal with Media Library tab
- [x] Added size presets (Original, Large, Medium, Small, Thumbnail, Custom)
- [x] Added image dimensions display with aspect ratio
- [x] Added file expiration support via setFileExpiration action
- [x] Created docs-media-setup.md with ConvexFS documentation links
- [x] Added ~400 lines of CSS for media library and modal styles
- [x] AI image generation download and copy options (v2.16.4)
- [x] Added Download button to save generated image to computer
- [x] Added MD button to copy Markdown code to clipboard
- [x] Added HTML button to copy HTML code to clipboard
- [x] Added code preview section showing Markdown and HTML snippets
- [x] Filename generated from prompt (sanitized and truncated)
- [x] Added CSS styles for action buttons and code preview
- [x] Social icons in hamburger menu and Dashboard Config (v2.16.3) - [x] Social icons in hamburger menu and Dashboard Config (v2.16.3)
- [x] Added social icons to MobileMenu below navigation links - [x] Added social icons to MobileMenu below navigation links
- [x] Removed social icons from mobile header (now only in hamburger menu) - [x] Removed social icons from mobile header (now only in hamburger menu)

View File

@@ -4,6 +4,108 @@ 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/). The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [2.18.1] - 2026-01-10
### Changed
- README.md streamlined from 609 lines to 155 lines
- Removed detailed feature documentation (now links to live docs)
- Kept sync commands, setup, and Netlify deployment sections
- Added Documentation section with links to markdown.fast/docs
- Added Guides subsection with links to specific doc pages
- Simplified Features section with link to About page
- Simplified Fork Configuration to quick commands with doc link
## [2.18.0] - 2026-01-10
### Added
- OpenCode AI development tool integration
- Full `.opencode/` directory structure for OpenCode CLI compatibility
- 3 specialized agents: orchestrator, content-writer, sync-manager
- 6 commands: /sync, /sync-prod, /create-post, /create-page, /import, /deploy
- 4 skills: frontmatter, sync, convex, content
- sync-helper plugin for content change reminders
- Works alongside Claude Code and Cursor without conflicts
- OpenCode documentation page at /docs-opencode
- How OpenCode integration works
- Directory structure reference
- Command and agent descriptions
- Getting started guide
### Technical
- `opencode.json` - Root OpenCode project configuration
- `.opencode/config.json` - OpenCode app configuration
- `.opencode/agent/orchestrator.md` - Main routing agent
- `.opencode/agent/content-writer.md` - Content creation specialist
- `.opencode/agent/sync-manager.md` - Sync and deployment specialist
- `.opencode/command/sync.md` - /sync command definition
- `.opencode/command/sync-prod.md` - /sync-prod command
- `.opencode/command/create-post.md` - /create-post command
- `.opencode/command/create-page.md` - /create-page command
- `.opencode/command/import.md` - /import command
- `.opencode/command/deploy.md` - /deploy command
- `.opencode/skill/frontmatter.md` - Frontmatter reference (adapted from .claude/skills/)
- `.opencode/skill/sync.md` - Sync system reference
- `.opencode/skill/convex.md` - Convex patterns reference
- `.opencode/skill/content.md` - Content management guide
- `.opencode/plugin/sync-helper.ts` - Minimal reminder plugin
- `content/pages/docs-opencode.md` - Documentation page
- `files.md` - Added OpenCode Configuration section
## [2.17.0] - 2026-01-10
### Added
- ConvexFS Media Library with Bunny CDN integration
- Upload images via drag-and-drop or click to upload
- Copy as Markdown, HTML, or direct URL
- Bulk select and delete multiple images
- File size display and pagination
- Configuration warning when Bunny CDN not configured
- Enhanced Image Insert Modal in Write Post/Page
- Two tabs: "Upload New" and "Media Library" for selecting existing images
- Image dimensions display (original size with aspect ratio)
- Size presets: Original, Large (1200px), Medium (800px), Small (400px), Thumbnail (200px), Custom
- Custom dimensions input with automatic aspect ratio preservation
- Alt text field for accessibility
- Calculated dimensions shown before insert
- File expiration support via ConvexFS
- `setFileExpiration` action to set time-based auto-deletion
- Pass `expiresInMs` for automatic cleanup after specified time
- Pass `null` to remove expiration and make file permanent
### Technical
- `convex/convex.config.ts` - Added ConvexFS component registration
- `convex/fs.ts` - ConvexFS instance with Bunny CDN configuration, conditional instantiation
- `convex/files.ts` - File mutations/queries: commitFile, listFiles, deleteFile, deleteFiles, setFileExpiration, isConfigured
- `convex/http.ts` - ConvexFS routes for /fs/upload and /fs/blobs/{blobId}
- `src/components/MediaLibrary.tsx` - Media library gallery with bulk select/delete
- `src/components/ImageUploadModal.tsx` - Enhanced modal with library selection and size presets
- `src/styles/global.css` - Added ~400 lines for media library and image modal styles
- `content/pages/docs-media-setup.md` - Setup documentation with ConvexFS links
## [2.16.4] - 2026-01-10
### Added
- AI image generation download and copy options
- Download button to save generated image to computer
- MD button to copy Markdown code (`![prompt](url)`) to clipboard
- HTML button to copy HTML code (`<img src="url" alt="prompt" />`) to clipboard
- Code preview section showing both Markdown and HTML snippets
- Filename generated from prompt (sanitized and truncated)
### Technical
- `src/pages/Dashboard.tsx` - Added copiedFormat state, getMarkdownCode/getHtmlCode helpers, handleCopyCode, handleDownloadImage functions, updated generated image display JSX
- `src/styles/global.css` - Added CSS for .ai-image-actions, .ai-image-action-btn, .ai-image-code-preview, .ai-image-code-block
## [2.16.3] - 2026-01-10 ## [2.16.3] - 2026-01-10
### Added ### Added

View File

@@ -0,0 +1,195 @@
---
title: "OpenCode Integration"
slug: "docs-opencode"
date: "2026-01-10"
published: true
tags: ["opencode", "plugins", "terminal"]
readTime: "4 min read"
order: 2
showInNav: false
layout: "sidebar"
featuredOrder: 2
blogFeatured: true
rightSidebar: true
showImageAtTop: false
image: /images/opencode.png
authorName: "Markdown"
authorImage: "/images/authors/markdown.png"
showFooter: true
docsSection: true
docsSectionOrder: 6
docsSectionGroup: "Setup"
docsSectionGroupIcon: "Rocket"
---
## OpenCode Integration
OpenCode is an AI-first development tool that works alongside Claude Code and Cursor. This framework includes full OpenCode support with agents, commands, skills, and plugins.
---
### How OpenCode works
```
+------------------+ +-------------------+ +------------------+
| User request |--->| Orchestrator |--->| Specialist |
| "Create a post" | | Agent routes | | Agent executes |
| | | to specialist | | the task |
+------------------+ +-------------------+ +--------+---------+
|
v
+------------------+ +-------------------+ +------------------+
| Result |<---| Skills provide |<---| Commands wrap |
| returned to | | context and | | npm scripts |
| user | | documentation | | for quick access|
+------------------+ +-------------------+ +------------------+
```
1. User makes a request in OpenCode
2. Orchestrator agent analyzes and routes the task
3. Specialist agent (content-writer or sync-manager) handles it
4. Skills provide documentation context
5. Commands offer quick keyboard shortcuts
6. Plugins automate common workflows
### Directory structure
OpenCode configuration lives in `.opencode/` alongside existing `.claude/` and `.cursor/` directories:
```
.opencode/
├── config.json # OpenCode app configuration
├── agent/
│ ├── orchestrator.md # Main routing agent
│ ├── content-writer.md # Content creation specialist
│ └── sync-manager.md # Sync and deployment specialist
├── command/
│ ├── sync.md # /sync command
│ ├── sync-prod.md # /sync-prod command
│ ├── create-post.md # /create-post command
│ ├── create-page.md # /create-page command
│ ├── import.md # /import command
│ └── deploy.md # /deploy command
├── skill/
│ ├── frontmatter.md # Frontmatter syntax reference
│ ├── sync.md # How sync works
│ ├── convex.md # Convex patterns
│ └── content.md # Content management guide
└── plugin/
└── sync-helper.ts # Reminder plugin for content changes
```
### Available commands
Quick commands accessible via `/` prefix in OpenCode:
| Command | Purpose |
| -------------- | ---------------------------------------------- |
| `/sync` | Sync markdown content to development Convex |
| `/sync-prod` | Sync markdown content to production Convex |
| `/create-post` | Create a new blog post with proper frontmatter |
| `/create-page` | Create a new static page |
| `/import` | Import content from an external URL |
| `/deploy` | Full deployment workflow to production |
### Agents
Three specialized agents handle different types of tasks:
**Orchestrator** (primary agent)
- Routes tasks to appropriate specialists
- Handles general code changes directly
- Coordinates multi-step workflows
**Content Writer** (subagent)
- Creates blog posts and pages
- Validates frontmatter
- Knows content directory structure
- Reminds you to run sync
**Sync Manager** (subagent)
- Executes sync commands
- Handles dev vs prod environments
- Troubleshoots sync issues
- Manages deployments
### Skills
Skills provide documentation context to agents:
| Skill | Purpose |
| ----------- | --------------------------------------------- |
| frontmatter | Complete frontmatter syntax for posts/pages |
| sync | How the sync system works end-to-end |
| convex | Convex patterns (indexes, mutations, queries) |
| content | Content management workflows |
### Plugins
The sync-helper plugin provides automation:
```typescript
// When content files change, log a reminder
"file.edited": async (event) => {
if (event.path.startsWith("content/")) {
await client.app.log("info", "Content changed - run /sync to publish")
}
}
```
Plugins hook into OpenCode events like file edits and session idle states.
### Getting started
1. Install OpenCode CLI (see [opencode.ai](https://opencode.ai))
2. Open your project directory
3. OpenCode automatically recognizes the `.opencode/` configuration
4. Use `/sync` after creating content
### Compatibility
This framework works with multiple AI development tools simultaneously:
| Tool | Configuration Directory |
| ----------- | ----------------------- |
| OpenCode | `.opencode/` |
| Claude Code | `.claude/skills/` |
| Cursor | `.cursor/rules/` |
All tools can be used without conflicts. Skills are duplicated (not shared) to ensure each tool works independently.
### Configuration files
| File | Purpose |
| ----------------------- | -------------------------- |
| `opencode.json` | Root project configuration |
| `.opencode/config.json` | App-level settings |
Example `opencode.json`:
```json
{
"name": "markdown-publishing-framework",
"description": "AI-first markdown publishing with Convex real-time sync",
"plugin": []
}
```
### When to use OpenCode vs other tools
| Task | Recommended Tool |
| ---------------------- | ----------------------------- |
| Quick content creation | OpenCode (`/create-post`) |
| Complex code changes | Claude Code or Cursor |
| Sync workflows | OpenCode (`/sync`, `/deploy`) |
| Debugging | Any tool with your preference |
### Resources
- [OpenCode Documentation](https://opencode.ai/docs/)
- [OpenCode Plugins](https://opencode.ai/docs/plugins/)
- [OpenCode SDK](https://opencode.ai/docs/sdk/)
- [OpenCode Workflow Examples](https://github.com/CloudAI-X/opencode-workflow)

View File

@@ -11,6 +11,114 @@ docsSectionOrder: 4
All notable changes to this project. All notable changes to this project.
## v2.18.1
Released January 10, 2026
**README.md streamlined with docs links**
Reduced README from 609 lines to 155 lines. Detailed documentation now lives on the live site at markdown.fast/docs.
**Changes:**
- Added Documentation section with link to markdown.fast/docs
- Added Guides subsection with links to Setup, Fork Configuration, Dashboard, WorkOS, MCP Server, and AgentMail guides
- Simplified Features section to brief summary with link to About page
- Simplified Fork Configuration to quick commands with doc link
- Kept sync commands, setup, and Netlify deployment sections
- Removed sections now covered by live docs
**Files changed:**
- `README.md` - Streamlined from 609 to 155 lines
---
## v2.18.0
Released January 10, 2026
**OpenCode AI development tool integration**
Added full OpenCode support to make the framework compatible with the OpenCode AI-first development tool. This works alongside existing Claude Code and Cursor integrations without conflicts.
**Changes:**
- Created `.opencode/` directory structure with config, agents, commands, skills, and plugins
- 3 specialized agents: orchestrator (main router), content-writer, sync-manager
- 6 commands: /sync, /sync-prod, /create-post, /create-page, /import, /deploy
- 4 skills adapted from .claude/skills/: frontmatter, sync, convex, content
- sync-helper plugin logs reminders when content files change
- Documentation page at /docs-opencode
**Files changed:**
- `opencode.json` - Root OpenCode project configuration
- `.opencode/config.json` - OpenCode app configuration
- `.opencode/agent/*.md` - Agent definitions (3 files)
- `.opencode/command/*.md` - Command definitions (6 files)
- `.opencode/skill/*.md` - Skill documentation (4 files)
- `.opencode/plugin/sync-helper.ts` - Reminder plugin
- `content/pages/docs-opencode.md` - Documentation page
- `files.md` - Added OpenCode Configuration section
---
## v2.17.0
Released January 10, 2026
**ConvexFS Media Library with Bunny CDN integration**
Added a full-featured media library for uploading and managing images in the dashboard. Images are stored on Bunny.net Edge Storage and served via their global CDN. The Image Insert modal now supports selecting from existing media and choosing image sizes.
**Changes:**
- Upload images via drag-and-drop or click to upload
- Copy images as Markdown, HTML, or direct URL
- Bulk select and delete multiple images at once
- Media Library tab in Image Insert modal for selecting existing images
- Size presets: Original, Large (1200px), Medium (800px), Small (400px), Thumbnail (200px), Custom
- Image dimensions displayed before insert with aspect ratio preserved
- File expiration support for automatic cleanup
- Configuration warning when Bunny CDN not set up
**Files changed:**
- `convex/convex.config.ts` - Added ConvexFS component
- `convex/fs.ts` - ConvexFS instance with Bunny CDN config
- `convex/files.ts` - File mutations and queries
- `convex/http.ts` - ConvexFS routes for upload/download
- `src/components/MediaLibrary.tsx` - Media library gallery
- `src/components/ImageUploadModal.tsx` - Enhanced modal with library and sizes
- `src/styles/global.css` - Media library and modal styles
- `content/pages/docs-media-setup.md` - Setup documentation
---
## v2.16.4
Released January 10, 2026
**AI image generation download and copy options**
Added download and copy functionality to the Dashboard AI Agent image generation section. After generating an image with Nano Banana, users can now download the image or copy Markdown/HTML code for embedding.
**Changes:**
- Download button saves generated image to computer with filename from prompt
- MD button copies Markdown code (`![prompt](url)`) to clipboard
- HTML button copies HTML code (`<img src="url" alt="prompt" />`) to clipboard
- Code preview section displays both Markdown and HTML snippets
- Visual feedback when code is copied (button changes to "Copied")
**Files changed:**
- `src/pages/Dashboard.tsx` - Added download/copy handlers and updated UI
- `src/styles/global.css` - Added CSS for action buttons and code preview
---
## v2.16.3 ## v2.16.3
Released January 10, 2026 Released January 10, 2026

View File

@@ -0,0 +1,234 @@
---
title: "Media Upload Setup"
slug: docs-media-setup
published: true
showInNav: false
docsSection: true
docsSectionGroup: "Setup"
docsSectionOrder: 5
docsSectionGroupOrder: 1
docsSectionGroupIcon: "Gear"
---
Set up image uploads for the dashboard using ConvexFS and Bunny.net CDN.
## ConvexFS Documentation
This media library is powered by [ConvexFS](https://convexfs.dev/), a virtual filesystem for Convex with CDN integration.
**Resources:**
- [ConvexFS Documentation](https://convexfs.dev/) - Complete setup guides, API reference, and examples
- [ConvexFS GitHub](https://github.com/jamwt/convex-fs) - Source code and issues
For detailed setup instructions including app configuration, garbage collection, file expiration, and advanced features, follow the official ConvexFS documentation.
## Overview
The media library allows you to upload images directly from the dashboard and insert them into your content. Images are stored on Bunny.net Edge Storage and served via their global CDN for fast delivery.
## Prerequisites
- A Bunny.net account
- Convex project deployed
- Access to Convex Dashboard environment variables
## Create Bunny.net account
1. Go to [bunny.net](https://bunny.net) and sign up
2. Bunny offers a 14-day free trial with no credit card required
3. After trial, storage costs around $0.01/GB/month
## Create storage zone
1. In the Bunny Dashboard, go to **Storage** in the sidebar
2. Click **Add Storage Zone**
3. Configure your storage zone:
- **Name**: Choose a unique name (e.g., `mysite-media`)
- **Main Storage Region**: Select the region closest to your users
- **Replication Regions**: Optional, select additional regions for redundancy
4. Click **Create Storage Zone**
## Set up Pull Zone (CDN)
1. After creating the storage zone, click **Connected Pull Zone**
2. Create a new pull zone or connect to an existing one
3. Note your **Pull Zone Hostname** (e.g., `mysite-media.b-cdn.net`)
4. Enable **Token Authentication** under Security settings for signed URLs
## Get API credentials
From your Bunny Dashboard, collect these values:
| Credential | Location |
|------------|----------|
| **API Key** | Account > API > API Key (password icon) |
| **Storage Zone Name** | Storage > [Your Zone] > Name |
| **CDN Hostname** | Storage > [Your Zone] > Connected Pull Zone hostname |
| **Token Key** | Pull Zone > Security > Token Authentication > Token Key |
## Add environment variables
### Local development (.env.local)
Create or edit `.env.local` in your project root:
```bash
BUNNY_API_KEY=your-api-key-here
BUNNY_STORAGE_ZONE=your-zone-name
BUNNY_CDN_HOSTNAME=your-zone.b-cdn.net
BUNNY_TOKEN_KEY=your-token-key
```
### Convex Dashboard
1. Go to your project in the [Convex Dashboard](https://dashboard.convex.dev)
2. Navigate to **Settings** > **Environment Variables**
3. Add each of these variables:
- `BUNNY_API_KEY`
- `BUNNY_STORAGE_ZONE`
- `BUNNY_CDN_HOSTNAME`
- `BUNNY_TOKEN_KEY` (optional, for signed URLs)
4. Click **Save**
## Deploy changes
After setting environment variables:
```bash
npx convex deploy
```
This pushes the ConvexFS configuration with your Bunny credentials.
## Test upload workflow
1. Go to Dashboard > Create > Media
2. Click the upload zone or drag an image
3. Verify the image appears in the grid
4. Click **MD** to copy markdown and paste in a post
## Using in content
### Media Library
Access the Media Library from Dashboard sidebar under **Create > Media**. From here you can:
- Upload multiple images via drag-and-drop
- Copy markdown, HTML, or direct URL
- Select multiple images for bulk delete
- View file sizes
### Bulk delete
To delete multiple images at once:
1. Click the **Select** button in the toolbar
2. Click images to select them (or use **Select All**)
3. Click **Delete (N)** to remove selected images
4. Confirm deletion in the dialog
### Insert in editor
When writing a post or page in the dashboard:
1. Click the **Image** button in the toolbar
2. Choose **Upload New** to upload a new image, or **Media Library** to select an existing image
3. After selecting/uploading, you'll see:
- Image preview with original dimensions (e.g., 1920 x 1080px)
- Alt text field for accessibility
- Size presets: Original, Large (1200px), Medium (800px), Small (400px), Thumbnail (200px), or Custom
4. Choose a size - the display shows the calculated dimensions with aspect ratio preserved
5. Click **Insert** to add the image to your content
### Size options
| Preset | Max Width | Use Case |
|--------|-----------|----------|
| Original | Full size | High-resolution displays |
| Large | 1200px | Hero images, full-width content |
| Medium | 800px | Standard content images |
| Small | 400px | Thumbnails, sidebars |
| Thumbnail | 200px | Grids, galleries |
| Custom | Any | Specific dimensions needed |
When using a size other than Original, images are inserted with explicit width/height attributes to prevent layout shift.
### Frontmatter images
For post header images, upload via Media Library then copy the URL:
```yaml
---
title: "My Post"
image: https://your-zone.b-cdn.net/uploads/12345-image.png
---
```
## Configuration options
In `siteConfig.ts`:
```typescript
media: {
enabled: true, // Toggle media features
maxFileSize: 10, // Max file size in MB
allowedTypes: [ // Allowed MIME types
"image/png",
"image/jpeg",
"image/gif",
"image/webp"
],
}
```
## Troubleshooting
### "Upload failed" error
- Verify all four environment variables are set in Convex Dashboard
- Check that the API key has write permissions
- Ensure the storage zone name matches exactly
### Images not loading
- Verify the CDN hostname is correct
- Check the Pull Zone is connected to your Storage Zone
- Try accessing the image URL directly in browser
### 403 Forbidden errors
- Token authentication may be blocking unsigned requests
- Either disable token auth in Pull Zone settings
- Or ensure `BUNNY_TOKEN_KEY` is set correctly
### Files uploading but not visible
- Check browser console for errors
- Verify the `/fs/upload` route is registered in `http.ts`
- Run `npx convex deploy` to sync configuration
## File organization
Uploaded files are stored at `/uploads/{timestamp}-{filename}`:
```
/uploads/
1704067200000-hero-image.png
1704067201000-screenshot.jpg
1704067202000-diagram.webp
```
Timestamps ensure unique filenames and provide chronological ordering.
## Cost estimate
Bunny.net pricing (as of 2024):
| Service | Cost |
|---------|------|
| Storage | $0.01/GB/month |
| Bandwidth (EU/US) | $0.01/GB |
| Bandwidth (APAC) | $0.03/GB |
For a typical blog with 1GB of images and 10GB monthly bandwidth: ~$0.11/month.

View File

@@ -18,6 +18,8 @@ import type * as contactActions from "../contactActions.js";
import type * as crons from "../crons.js"; import type * as crons from "../crons.js";
import type * as embeddings from "../embeddings.js"; import type * as embeddings from "../embeddings.js";
import type * as embeddingsQueries from "../embeddingsQueries.js"; import type * as embeddingsQueries from "../embeddingsQueries.js";
import type * as files from "../files.js";
import type * as fs from "../fs.js";
import type * as http from "../http.js"; import type * as http from "../http.js";
import type * as importAction from "../importAction.js"; import type * as importAction from "../importAction.js";
import type * as newsletter from "../newsletter.js"; import type * as newsletter from "../newsletter.js";
@@ -48,6 +50,8 @@ declare const fullApi: ApiFromModules<{
crons: typeof crons; crons: typeof crons;
embeddings: typeof embeddings; embeddings: typeof embeddings;
embeddingsQueries: typeof embeddingsQueries; embeddingsQueries: typeof embeddingsQueries;
files: typeof files;
fs: typeof fs;
http: typeof http; http: typeof http;
importAction: typeof importAction; importAction: typeof importAction;
newsletter: typeof newsletter; newsletter: typeof newsletter;
@@ -682,4 +686,568 @@ export declare const components: {
>; >;
}; };
}; };
fs: {
lib: {
commitFiles: FunctionReference<
"mutation",
"internal",
{
config: {
blobGracePeriod?: number;
downloadUrlTtl?: number;
storage:
| {
apiKey: string;
cdnHostname: string;
region?: string;
storageZoneName: string;
tokenKey?: string;
type: "bunny";
}
| { type: "test" };
};
files: Array<{
attributes?: { expiresAt?: number };
basis?: null | string;
blobId: string;
path: string;
}>;
},
null
>;
copyByPath: FunctionReference<
"mutation",
"internal",
{
config: {
blobGracePeriod?: number;
downloadUrlTtl?: number;
storage:
| {
apiKey: string;
cdnHostname: string;
region?: string;
storageZoneName: string;
tokenKey?: string;
type: "bunny";
}
| { type: "test" };
};
destPath: string;
sourcePath: string;
},
null
>;
deleteByPath: FunctionReference<
"mutation",
"internal",
{
config: {
blobGracePeriod?: number;
downloadUrlTtl?: number;
storage:
| {
apiKey: string;
cdnHostname: string;
region?: string;
storageZoneName: string;
tokenKey?: string;
type: "bunny";
}
| { type: "test" };
};
path: string;
},
null
>;
getDownloadUrl: FunctionReference<
"action",
"internal",
{
blobId: string;
config: {
blobGracePeriod?: number;
downloadUrlTtl?: number;
storage:
| {
apiKey: string;
cdnHostname: string;
region?: string;
storageZoneName: string;
tokenKey?: string;
type: "bunny";
}
| { type: "test" };
};
extraParams?: Record<string, string>;
},
string
>;
list: FunctionReference<
"query",
"internal",
{
config: {
blobGracePeriod?: number;
downloadUrlTtl?: number;
storage:
| {
apiKey: string;
cdnHostname: string;
region?: string;
storageZoneName: string;
tokenKey?: string;
type: "bunny";
}
| { type: "test" };
};
paginationOpts: {
cursor: string | null;
endCursor?: string | null;
id?: number;
maximumBytesRead?: number;
maximumRowsRead?: number;
numItems: number;
};
prefix?: string;
},
{
continueCursor: string;
isDone: boolean;
page: Array<{
attributes?: { expiresAt?: number };
blobId: string;
contentType: string;
path: string;
size: number;
}>;
}
>;
moveByPath: FunctionReference<
"mutation",
"internal",
{
config: {
blobGracePeriod?: number;
downloadUrlTtl?: number;
storage:
| {
apiKey: string;
cdnHostname: string;
region?: string;
storageZoneName: string;
tokenKey?: string;
type: "bunny";
}
| { type: "test" };
};
destPath: string;
sourcePath: string;
},
null
>;
registerPendingUpload: FunctionReference<
"mutation",
"internal",
{
blobId: string;
config: {
blobGracePeriod?: number;
downloadUrlTtl?: number;
storage:
| {
apiKey: string;
cdnHostname: string;
region?: string;
storageZoneName: string;
tokenKey?: string;
type: "bunny";
}
| { type: "test" };
};
contentType: string;
size: number;
},
null
>;
stat: FunctionReference<
"query",
"internal",
{
config: {
blobGracePeriod?: number;
downloadUrlTtl?: number;
storage:
| {
apiKey: string;
cdnHostname: string;
region?: string;
storageZoneName: string;
tokenKey?: string;
type: "bunny";
}
| { type: "test" };
};
path: string;
},
null | {
attributes?: { expiresAt?: number };
blobId: string;
contentType: string;
path: string;
size: number;
}
>;
transact: FunctionReference<
"mutation",
"internal",
{
config: {
blobGracePeriod?: number;
downloadUrlTtl?: number;
storage:
| {
apiKey: string;
cdnHostname: string;
region?: string;
storageZoneName: string;
tokenKey?: string;
type: "bunny";
}
| { type: "test" };
};
ops: Array<
| {
dest: { basis?: null | string; path: string };
op: "move";
source: {
attributes?: { expiresAt?: number };
blobId: string;
contentType: string;
path: string;
size: number;
};
}
| {
dest: { basis?: null | string; path: string };
op: "copy";
source: {
attributes?: { expiresAt?: number };
blobId: string;
contentType: string;
path: string;
size: number;
};
}
| {
op: "delete";
source: {
attributes?: { expiresAt?: number };
blobId: string;
contentType: string;
path: string;
size: number;
};
}
| {
attributes: { expiresAt?: null | number };
op: "setAttributes";
source: {
attributes?: { expiresAt?: number };
blobId: string;
contentType: string;
path: string;
size: number;
};
}
>;
},
null
>;
};
ops: {
basics: {
copyByPath: FunctionReference<
"mutation",
"internal",
{
config: {
blobGracePeriod?: number;
downloadUrlTtl?: number;
storage:
| {
apiKey: string;
cdnHostname: string;
region?: string;
storageZoneName: string;
tokenKey?: string;
type: "bunny";
}
| { type: "test" };
};
destPath: string;
sourcePath: string;
},
null
>;
deleteByPath: FunctionReference<
"mutation",
"internal",
{
config: {
blobGracePeriod?: number;
downloadUrlTtl?: number;
storage:
| {
apiKey: string;
cdnHostname: string;
region?: string;
storageZoneName: string;
tokenKey?: string;
type: "bunny";
}
| { type: "test" };
};
path: string;
},
null
>;
list: FunctionReference<
"query",
"internal",
{
config: {
blobGracePeriod?: number;
downloadUrlTtl?: number;
storage:
| {
apiKey: string;
cdnHostname: string;
region?: string;
storageZoneName: string;
tokenKey?: string;
type: "bunny";
}
| { type: "test" };
};
paginationOpts: {
cursor: string | null;
endCursor?: string | null;
id?: number;
maximumBytesRead?: number;
maximumRowsRead?: number;
numItems: number;
};
prefix?: string;
},
{
continueCursor: string;
isDone: boolean;
page: Array<{
attributes?: { expiresAt?: number };
blobId: string;
contentType: string;
path: string;
size: number;
}>;
}
>;
moveByPath: FunctionReference<
"mutation",
"internal",
{
config: {
blobGracePeriod?: number;
downloadUrlTtl?: number;
storage:
| {
apiKey: string;
cdnHostname: string;
region?: string;
storageZoneName: string;
tokenKey?: string;
type: "bunny";
}
| { type: "test" };
};
destPath: string;
sourcePath: string;
},
null
>;
stat: FunctionReference<
"query",
"internal",
{
config: {
blobGracePeriod?: number;
downloadUrlTtl?: number;
storage:
| {
apiKey: string;
cdnHostname: string;
region?: string;
storageZoneName: string;
tokenKey?: string;
type: "bunny";
}
| { type: "test" };
};
path: string;
},
null | {
attributes?: { expiresAt?: number };
blobId: string;
contentType: string;
path: string;
size: number;
}
>;
};
transact: {
commitFiles: FunctionReference<
"mutation",
"internal",
{
config: {
blobGracePeriod?: number;
downloadUrlTtl?: number;
storage:
| {
apiKey: string;
cdnHostname: string;
region?: string;
storageZoneName: string;
tokenKey?: string;
type: "bunny";
}
| { type: "test" };
};
files: Array<{
attributes?: { expiresAt?: number };
basis?: null | string;
blobId: string;
path: string;
}>;
},
null
>;
transact: FunctionReference<
"mutation",
"internal",
{
config: {
blobGracePeriod?: number;
downloadUrlTtl?: number;
storage:
| {
apiKey: string;
cdnHostname: string;
region?: string;
storageZoneName: string;
tokenKey?: string;
type: "bunny";
}
| { type: "test" };
};
ops: Array<
| {
dest: { basis?: null | string; path: string };
op: "move";
source: {
attributes?: { expiresAt?: number };
blobId: string;
contentType: string;
path: string;
size: number;
};
}
| {
dest: { basis?: null | string; path: string };
op: "copy";
source: {
attributes?: { expiresAt?: number };
blobId: string;
contentType: string;
path: string;
size: number;
};
}
| {
op: "delete";
source: {
attributes?: { expiresAt?: number };
blobId: string;
contentType: string;
path: string;
size: number;
};
}
| {
attributes: { expiresAt?: null | number };
op: "setAttributes";
source: {
attributes?: { expiresAt?: number };
blobId: string;
contentType: string;
path: string;
size: number;
};
}
>;
},
null
>;
};
};
transfer: {
getDownloadUrl: FunctionReference<
"action",
"internal",
{
blobId: string;
config: {
blobGracePeriod?: number;
downloadUrlTtl?: number;
storage:
| {
apiKey: string;
cdnHostname: string;
region?: string;
storageZoneName: string;
tokenKey?: string;
type: "bunny";
}
| { type: "test" };
};
extraParams?: Record<string, string>;
},
string
>;
registerPendingUpload: FunctionReference<
"mutation",
"internal",
{
blobId: string;
config: {
blobGracePeriod?: number;
downloadUrlTtl?: number;
storage:
| {
apiKey: string;
cdnHostname: string;
region?: string;
storageZoneName: string;
tokenKey?: string;
type: "bunny";
}
| { type: "test" };
};
contentType: string;
size: number;
},
null
>;
};
};
}; };

View File

@@ -1,6 +1,7 @@
import { defineApp } from "convex/server"; import { defineApp } from "convex/server";
import aggregate from "@convex-dev/aggregate/convex.config.js"; import aggregate from "@convex-dev/aggregate/convex.config.js";
import persistentTextStreaming from "@convex-dev/persistent-text-streaming/convex.config"; import persistentTextStreaming from "@convex-dev/persistent-text-streaming/convex.config";
import fs from "convex-fs/convex.config.js";
const app = defineApp(); const app = defineApp();
@@ -16,5 +17,8 @@ app.use(aggregate, { name: "uniqueVisitors" });
// Persistent text streaming for real-time AI responses in Ask AI feature // Persistent text streaming for real-time AI responses in Ask AI feature
app.use(persistentTextStreaming); app.use(persistentTextStreaming);
// ConvexFS for file storage with Bunny CDN
app.use(fs);
export default app; export default app;

222
convex/files.ts Normal file
View File

@@ -0,0 +1,222 @@
import { mutation, query, action } from "./_generated/server";
import { v } from "convex/values";
import { paginationOptsValidator } from "convex/server";
import { fs, isBunnyConfigured } from "./fs";
// Allowed image MIME types
const ALLOWED_TYPES = [
"image/png",
"image/jpeg",
"image/gif",
"image/webp",
];
// Max file size in bytes (10MB)
const MAX_FILE_SIZE = 10 * 1024 * 1024;
// Check if media uploads are configured
export const isConfigured = query({
args: {},
handler: async () => {
return { configured: isBunnyConfigured };
},
});
// Commit uploaded file to storage path
export const commitFile = action({
args: {
blobId: v.string(),
filename: v.string(),
contentType: v.string(),
size: v.number(),
width: v.optional(v.number()),
height: v.optional(v.number()),
},
handler: async (ctx, args) => {
if (!fs) {
throw new Error(
"Media uploads not configured. Set BUNNY_API_KEY, BUNNY_STORAGE_ZONE, and BUNNY_CDN_HOSTNAME in Convex Dashboard."
);
}
// Validate file type
if (!ALLOWED_TYPES.includes(args.contentType)) {
throw new Error(
`Invalid file type: ${args.contentType}. Allowed: ${ALLOWED_TYPES.join(", ")}`
);
}
// Validate file size
if (args.size > MAX_FILE_SIZE) {
throw new Error(
`File too large: ${(args.size / 1024 / 1024).toFixed(2)}MB. Max: 10MB`
);
}
// Sanitize filename (remove special chars, preserve extension)
const sanitizedName = args.filename
.replace(/[^a-zA-Z0-9.-]/g, "-")
.replace(/-+/g, "-")
.toLowerCase();
// Create unique path with timestamp
const timestamp = Date.now();
const path = `/uploads/${timestamp}-${sanitizedName}`;
// Commit file to ConvexFS
await fs.commitFiles(ctx, [{ path, blobId: args.blobId }]);
return {
path,
filename: sanitizedName,
contentType: args.contentType,
size: args.size,
width: args.width,
height: args.height,
};
},
});
// List files with pagination
export const listFiles = query({
args: {
prefix: v.optional(v.string()),
paginationOpts: paginationOptsValidator,
},
handler: async (ctx, args) => {
if (!fs) {
// Return empty results when not configured
return {
page: [],
isDone: true,
continueCursor: "",
};
}
return await fs.list(ctx, {
prefix: args.prefix ?? "/uploads/",
paginationOpts: args.paginationOpts,
});
},
});
// Get file info by path
export const getFileInfo = query({
args: { path: v.string() },
handler: async (ctx, args) => {
if (!fs) {
return null;
}
const file = await fs.stat(ctx, args.path);
if (!file) {
return null;
}
return {
path: file.path,
blobId: file.blobId,
contentType: file.contentType,
size: file.size,
};
},
});
// Get signed download URL for a file
export const getDownloadUrl = action({
args: { path: v.string() },
handler: async (ctx, args) => {
if (!fs) {
throw new Error("Media uploads not configured");
}
const file = await fs.stat(ctx, args.path);
if (!file) {
throw new Error("File not found");
}
// Generate time-limited signed URL
const url = await fs.getDownloadUrl(ctx, file.blobId);
return { url, expiresIn: 3600 };
},
});
// Delete file by path
export const deleteFile = mutation({
args: { path: v.string() },
handler: async (ctx, args) => {
if (!fs) {
throw new Error("Media uploads not configured");
}
await fs.delete(ctx, args.path);
return { success: true };
},
});
// Delete multiple files at once
export const deleteFiles = mutation({
args: { paths: v.array(v.string()) },
handler: async (ctx, args) => {
if (!fs) {
throw new Error("Media uploads not configured");
}
let deleted = 0;
for (const path of args.paths) {
await fs.delete(ctx, path);
deleted++;
}
return { success: true, deleted };
},
});
// Set file expiration
export const setFileExpiration = action({
args: {
path: v.string(),
expiresInMs: v.optional(v.number()), // null to remove expiration
},
handler: async (ctx, args) => {
if (!fs) {
throw new Error("Media uploads not configured");
}
// Get current file info
const file = await fs.stat(ctx, args.path);
if (!file) {
throw new Error("File not found");
}
const expiresAt = args.expiresInMs ? Date.now() + args.expiresInMs : null;
await fs.transact(ctx, [
{
op: "setAttributes",
source: file,
attributes: { expiresAt },
},
]);
return { success: true, expiresAt };
},
});
// Get total file count
export const getFileCount = query({
args: {},
handler: async (ctx) => {
if (!fs) {
return 0;
}
const result = await fs.list(ctx, {
prefix: "/uploads/",
paginationOpts: { numItems: 1000, cursor: null },
});
return result.page.length;
},
});

34
convex/fs.ts Normal file
View File

@@ -0,0 +1,34 @@
import { ConvexFS } from "convex-fs";
import { components } from "./_generated/api";
// Check if Bunny CDN is configured
// All three required env vars must be set
export const isBunnyConfigured =
!!process.env.BUNNY_API_KEY &&
!!process.env.BUNNY_STORAGE_ZONE &&
!!process.env.BUNNY_CDN_HOSTNAME;
// ConvexFS instance with Bunny.net Edge Storage
// Set these environment variables in Convex Dashboard:
// - BUNNY_API_KEY: Your Bunny.net API key
// - BUNNY_STORAGE_ZONE: Storage zone name (e.g., "my-storage")
// - BUNNY_CDN_HOSTNAME: CDN hostname (e.g., "my-storage.b-cdn.net")
// - BUNNY_TOKEN_KEY: Optional, for signed URLs
// - BUNNY_REGION: Optional, storage region ("ny", "la", "sg", etc.)
// Only create ConvexFS instance if configured
// This prevents validation errors when env vars are not set
export const fs = isBunnyConfigured
? new ConvexFS(components.fs, {
storage: {
type: "bunny",
apiKey: process.env.BUNNY_API_KEY!,
storageZoneName: process.env.BUNNY_STORAGE_ZONE!,
cdnHostname: process.env.BUNNY_CDN_HOSTNAME!,
region: process.env.BUNNY_REGION,
tokenKey: process.env.BUNNY_TOKEN_KEY,
},
downloadUrlTtl: 3600, // URL expiration in seconds (1 hour)
blobGracePeriod: 86400, // Orphaned blobs deleted after 24 hours
})
: null;

View File

@@ -1,8 +1,10 @@
import { httpRouter } from "convex/server"; import { httpRouter } from "convex/server";
import { httpAction } from "./_generated/server"; import { httpAction } from "./_generated/server";
import { api } from "./_generated/api"; import { api, components } from "./_generated/api";
import { rssFeed, rssFullFeed } from "./rss"; import { rssFeed, rssFullFeed } from "./rss";
import { streamResponse, streamResponseOptions } from "./askAI.node"; import { streamResponse, streamResponseOptions } from "./askAI.node";
import { registerRoutes } from "convex-fs";
import { fs } from "./fs";
const http = httpRouter(); const http = httpRouter();
@@ -414,4 +416,24 @@ http.route({
handler: streamResponseOptions, handler: streamResponseOptions,
}); });
// ConvexFS routes for file uploads/downloads
// Only register routes when Bunny CDN is configured
// - POST /fs/upload - Upload files to Bunny.net storage
// - GET /fs/blobs/{blobId} - Returns 302 redirect to signed CDN URL
if (fs) {
registerRoutes(http, components.fs, fs, {
pathPrefix: "/fs",
uploadAuth: async () => {
// TODO: Add authentication check for production
// const identity = await ctx.auth.getUserIdentity();
// return identity !== null;
return true;
},
downloadAuth: async () => {
// Public downloads - images should be accessible to all
return true;
},
});
}
export default http; export default http;

View File

@@ -11,7 +11,7 @@ A brief description of each file in the codebase.
| `vite.config.ts` | Vite bundler configuration | | `vite.config.ts` | Vite bundler configuration |
| `index.html` | Main HTML entry with SEO meta tags, JSON-LD, critical CSS inline, and resource hints | | `index.html` | Main HTML entry with SEO meta tags, JSON-LD, critical CSS inline, and resource hints |
| `netlify.toml` | Netlify deployment and Convex HTTP redirects | | `netlify.toml` | Netlify deployment and Convex HTTP redirects |
| `README.md` | Project documentation | | `README.md` | Project documentation (streamlined with links to docs)|
| `AGENTS.md` | AI coding agent instructions (agents.md spec) | | `AGENTS.md` | AI coding agent instructions (agents.md spec) |
| `CLAUDE.md` | Claude Code instructions for project workflows | | `CLAUDE.md` | Claude Code instructions for project workflows |
| `files.md` | This file - codebase structure | | `files.md` | This file - codebase structure |
@@ -49,7 +49,7 @@ A brief description of each file in the codebase.
| `TagPage.tsx` | Tag archive page displaying posts filtered by a specific tag. Includes view mode toggle (list/cards) with localStorage persistence | | `TagPage.tsx` | Tag archive page displaying posts filtered by a specific tag. Includes view mode toggle (list/cards) with localStorage persistence |
| `AuthorPage.tsx` | Author archive page displaying posts by a specific author. Includes view mode toggle (list/cards) with localStorage persistence. Author name clickable in posts links to this page. | | `AuthorPage.tsx` | Author archive page displaying posts by a specific author. Includes view mode toggle (list/cards) with localStorage persistence. Author name clickable in posts links to this page. |
| `Write.tsx` | Three-column markdown writing page with Cursor docs-style UI, frontmatter reference with copy buttons, theme toggle, font switcher (serif/sans/monospace), localStorage persistence, and optional AI Agent mode (toggleable via siteConfig.aiChat.enabledOnWritePage). When enabled, Agent replaces the textarea with AIChatView component. Includes scroll prevention when switching to Agent mode to prevent page jump. Title changes to "Agent" when in AI chat mode. | | `Write.tsx` | Three-column markdown writing page with Cursor docs-style UI, frontmatter reference with copy buttons, theme toggle, font switcher (serif/sans/monospace), localStorage persistence, and optional AI Agent mode (toggleable via siteConfig.aiChat.enabledOnWritePage). When enabled, Agent replaces the textarea with AIChatView component. Includes scroll prevention when switching to Agent mode to prevent page jump. Title changes to "Agent" when in AI chat mode. |
| `Dashboard.tsx` | Centralized dashboard at `/dashboard` for content management and site configuration. **Cloud CMS Features:** Direct database save ("Save to DB" button), source tracking (Dashboard vs Synced badges), delete confirmation modal with warning, CRUD operations for dashboard-created content. **Content Management:** Posts and Pages list views with filtering, search, pagination, items per page selector, source badges, delete buttons (dashboard content only); Post/Page editor with markdown editor, live preview, "Save Changes" button, draggable/resizable frontmatter sidebar (200px-600px), independent scrolling, download markdown, export to markdown; Write Post/Page sections with three editor modes (Markdown, Rich Text via Quill, Preview), full-screen writing interface. **Rich Text Editor:** Quill-based WYSIWYG editor with toolbar (headers, bold, italic, lists, links, code, blockquote), automatic HTML-to-Markdown conversion on mode switch, theme-aware styling. **AI Agent:** Tab-based UI for Chat and Image Generation, multi-model selector (Claude Sonnet 4, GPT-4o, Gemini 2.0 Flash), image generation with Nano Banana models and aspect ratio selection. **Other Features:** Newsletter management (all Newsletter Admin features integrated); Content import (direct database import via Firecrawl, no file sync needed); Site configuration (Config Generator UI with Version Control toggle); Index HTML editor; Analytics (real-time stats dashboard); Sync commands UI with sync server integration; Header sync buttons; Dashboard search; Toast notifications; Command modal; Version history modal for viewing diffs and restoring previous versions; Mobile responsive design. Uses Convex queries for real-time data, localStorage for preferences, ReactMarkdown for preview. Optional WorkOS authentication via siteConfig.dashboard.requireAuth. | | `Dashboard.tsx` | Centralized dashboard at `/dashboard` for content management and site configuration. **Cloud CMS Features:** Direct database save ("Save to DB" button), source tracking (Dashboard vs Synced badges), delete confirmation modal with warning, CRUD operations for dashboard-created content. **Content Management:** Posts and Pages list views with filtering, search, pagination, items per page selector, source badges, delete buttons (dashboard content only); Post/Page editor with markdown editor, live preview, "Save Changes" button, draggable/resizable frontmatter sidebar (200px-600px), independent scrolling, download markdown, export to markdown; Write Post/Page sections with three editor modes (Markdown, Rich Text via Quill, Preview), full-screen writing interface. **Rich Text Editor:** Quill-based WYSIWYG editor with toolbar (headers, bold, italic, lists, links, code, blockquote), automatic HTML-to-Markdown conversion on mode switch, theme-aware styling. **AI Agent:** Tab-based UI for Chat and Image Generation, multi-model selector (Claude Sonnet 4, GPT-4o, Gemini 2.0 Flash), image generation with Nano Banana models, aspect ratio selection, download button, and MD/HTML copy options with code preview. **Other Features:** Newsletter management (all Newsletter Admin features integrated); Content import (direct database import via Firecrawl, no file sync needed); Site configuration (Config Generator UI with Version Control toggle); Index HTML editor; Analytics (real-time stats dashboard); Sync commands UI with sync server integration; Header sync buttons; Dashboard search; Toast notifications; Command modal; Version history modal for viewing diffs and restoring previous versions; Mobile responsive design. Uses Convex queries for real-time data, localStorage for preferences, ReactMarkdown for preview. Optional WorkOS authentication via siteConfig.dashboard.requireAuth. |
| `Callback.tsx` | OAuth callback handler for WorkOS authentication. Handles redirect from WorkOS after user login, exchanges authorization code for user information, then redirects to dashboard. Only used when WorkOS is configured. | | `Callback.tsx` | OAuth callback handler for WorkOS authentication. Handles redirect from WorkOS after user login, exchanges authorization code for user information, then redirects to dashboard. Only used when WorkOS is configured. |
| `NewsletterAdmin.tsx` | Three-column newsletter admin page for managing subscribers and sending newsletters. Left sidebar with navigation and stats, main area with searchable subscriber list, right sidebar with send newsletter panel and recent sends. Access at /newsletter-admin, configurable via siteConfig.newsletterAdmin. | | `NewsletterAdmin.tsx` | Three-column newsletter admin page for managing subscribers and sending newsletters. Left sidebar with navigation and stats, main area with searchable subscriber list, right sidebar with send newsletter panel and recent sends. Access at /newsletter-admin, configurable via siteConfig.newsletterAdmin. |
@@ -80,6 +80,8 @@ A brief description of each file in the codebase.
| `SocialFooter.tsx` | Social footer component with social icons on left (GitHub, Twitter/X, LinkedIn, Instagram, YouTube, TikTok, Discord, Website) and copyright on right. Configurable via siteConfig.socialFooter. Shows below main footer on homepage, blog posts, and pages. Supports frontmatter override via showSocialFooter: true/false. Auto-updates copyright year. Exports `platformIcons` for reuse in header. | | `SocialFooter.tsx` | Social footer component with social icons on left (GitHub, Twitter/X, LinkedIn, Instagram, YouTube, TikTok, Discord, Website) and copyright on right. Configurable via siteConfig.socialFooter. Shows below main footer on homepage, blog posts, and pages. Supports frontmatter override via showSocialFooter: true/false. Auto-updates copyright year. Exports `platformIcons` for reuse in header. |
| `AskAIModal.tsx` | Ask AI chat modal for RAG-based Q&A about site content. Opens via header button (Cmd+J) when enabled. Uses Convex Persistent Text Streaming for real-time responses. Supports model selection (Claude, GPT-4o). Features streaming messages with markdown rendering, internal link handling via React Router, and source citations. Requires siteConfig.askAI.enabled and siteConfig.semanticSearch.enabled. | | `AskAIModal.tsx` | Ask AI chat modal for RAG-based Q&A about site content. Opens via header button (Cmd+J) when enabled. Uses Convex Persistent Text Streaming for real-time responses. Supports model selection (Claude, GPT-4o). Features streaming messages with markdown rendering, internal link handling via React Router, and source citations. Requires siteConfig.askAI.enabled and siteConfig.semanticSearch.enabled. |
| `VersionHistoryModal.tsx` | Version history modal for viewing and restoring previous content versions. Shows version list with dates and source badges, diff view using DiffCodeBlock component, preview mode, and one-click restore. Used in Dashboard editor when version control is enabled. | | `VersionHistoryModal.tsx` | Version history modal for viewing and restoring previous content versions. Shows version list with dates and source badges, diff view using DiffCodeBlock component, preview mode, and one-click restore. Used in Dashboard editor when version control is enabled. |
| `MediaLibrary.tsx` | Media library component for uploading and managing images. Features drag-and-drop upload, copy as Markdown/HTML/URL, bulk select and delete, file size display, and pagination. Shows configuration warning when Bunny CDN not configured. Uses ConvexFS for storage with Bunny.net Edge Storage and CDN delivery. |
| `ImageUploadModal.tsx` | Image insert modal for Write Post/Page sections. Two tabs: "Upload New" for uploading images and "Media Library" for selecting existing images. Shows image dimensions with aspect ratio, size presets (Original, Large 1200px, Medium 800px, Small 400px, Thumbnail 200px, Custom), alt text field, and calculated dimensions before insert. Uses HTML img tag with explicit width/height for non-original sizes. |
### Context (`src/context/`) ### Context (`src/context/`)
@@ -137,7 +139,9 @@ A brief description of each file in the codebase.
| `versions.ts` | Version control system: isEnabled, setEnabled, createVersion, getVersionHistory, getVersion, restoreVersion, cleanupOldVersions, getStats. Captures content snapshots before updates, provides 3-day history with diff view and restore functionality. | | `versions.ts` | Version control system: isEnabled, setEnabled, createVersion, getVersionHistory, getVersion, restoreVersion, cleanupOldVersions, getStats. Captures content snapshots before updates, provides 3-day history with diff view and restore functionality. |
| `askAI.ts` | Ask AI session management: createSession mutation (creates streaming session with question/model in DB), getStreamBody query (for database fallback), getSessionByStreamId internal query (retrieves question/model for HTTP action). Uses Persistent Text Streaming component. | | `askAI.ts` | Ask AI session management: createSession mutation (creates streaming session with question/model in DB), getStreamBody query (for database fallback), getSessionByStreamId internal query (retrieves question/model for HTTP action). Uses Persistent Text Streaming component. |
| `askAI.node.ts` | Ask AI HTTP action for streaming responses (Node.js runtime). Retrieves question from database, performs vector search using existing semantic search embeddings, generates AI response via Anthropic Claude or OpenAI GPT-4o, streams via appendChunk. Includes CORS headers and source citations. | | `askAI.node.ts` | Ask AI HTTP action for streaming responses (Node.js runtime). Retrieves question from database, performs vector search using existing semantic search embeddings, generates AI response via Anthropic Claude or OpenAI GPT-4o, streams via appendChunk. Includes CORS headers and source citations. |
| `convex.config.ts` | Convex app configuration with aggregate component registrations (pageViewsByPath, totalPageViews, uniqueVisitors) and persistentTextStreaming component | | `fs.ts` | ConvexFS instance configuration with Bunny.net Edge Storage integration. Conditionally creates ConvexFS instance only when BUNNY_API_KEY, BUNNY_STORAGE_ZONE, and BUNNY_CDN_HOSTNAME environment variables are set. Exports `isBunnyConfigured` boolean and `fs` instance (or null if not configured). |
| `files.ts` | File management mutations and queries for media library: commitFile (upload with validation), listFiles (paginated), deleteFile, deleteFiles (bulk), setFileExpiration, getFileInfo, getDownloadUrl, getFileCount, isConfigured. Validates file types (PNG, JPG, GIF, WebP) and size (10MB max). |
| `convex.config.ts` | Convex app configuration with aggregate component registrations (pageViewsByPath, totalPageViews, uniqueVisitors), persistentTextStreaming component, and ConvexFS component for media storage. |
| `tsconfig.json` | Convex TypeScript configuration | | `tsconfig.json` | Convex TypeScript configuration |
### HTTP Endpoints (defined in `http.ts`) ### HTTP Endpoints (defined in `http.ts`)
@@ -360,3 +364,48 @@ Files include a metadata header with type (post/page), date, reading time, and t
| `sec-check.mdc` | Security guidelines and audit checklist | | `sec-check.mdc` | Security guidelines and audit checklist |
| `task.mdc` | Task list management guidelines | | `task.mdc` | Task list management guidelines |
| `write.mdc` | Writing style guide (activate with @write) | | `write.mdc` | Writing style guide (activate with @write) |
## OpenCode Configuration (`.opencode/`)
OpenCode AI-first development tool integration. Works alongside Claude Code and Cursor.
### Root Config
| File | Description |
| ---- | ----------- |
| `opencode.json` | Root OpenCode project configuration |
| `.opencode/config.json` | OpenCode app configuration |
### Agents (`.opencode/agent/`)
| File | Description |
| ---- | ----------- |
| `orchestrator.md` | Main orchestrator agent - routes tasks to specialists |
| `content-writer.md` | Content creation specialist for posts and pages |
| `sync-manager.md` | Sync and deployment specialist |
### Commands (`.opencode/command/`)
| File | Description |
| ---- | ----------- |
| `sync.md` | `/sync` - Sync content to development |
| `sync-prod.md` | `/sync-prod` - Sync content to production |
| `create-post.md` | `/create-post` - Create new blog post |
| `create-page.md` | `/create-page` - Create new page |
| `import.md` | `/import` - Import content from URL |
| `deploy.md` | `/deploy` - Deploy to production |
### Skills (`.opencode/skill/`)
| File | Description |
| ---- | ----------- |
| `frontmatter.md` | Frontmatter syntax for posts and pages |
| `sync.md` | How the sync system works |
| `convex.md` | Convex patterns and conventions |
| `content.md` | Content management guide |
### Plugins (`.opencode/plugin/`)
| File | Description |
| ---- | ----------- |
| `sync-helper.ts` | Logs reminders when content files change |

5
opencode.json Normal file
View File

@@ -0,0 +1,5 @@
{
"name": "markdown-publishing-framework",
"description": "AI-first markdown publishing with Convex real-time sync",
"plugin": []
}

50
package-lock.json generated
View File

@@ -22,6 +22,7 @@
"@workos-inc/widgets": "^1.6.1", "@workos-inc/widgets": "^1.6.1",
"agentmail": "^0.1.15", "agentmail": "^0.1.15",
"convex": "^1.17.4", "convex": "^1.17.4",
"convex-fs": "^0.2.0",
"date-fns": "^3.3.1", "date-fns": "^3.3.1",
"diff": "^8.0.2", "diff": "^8.0.2",
"gray-matter": "^4.0.3", "gray-matter": "^4.0.3",
@@ -4879,6 +4880,54 @@
} }
} }
}, },
"node_modules/convex-fs": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/convex-fs/-/convex-fs-0.2.0.tgz",
"integrity": "sha512-udtiG2Nb5tif0poCa+UHsIlPrL4BCc3cQhrQtKpf5PFYiSMyb6Ay76otYFYLcgfM7AQswMQBoEzmendGYqdP6g==",
"license": "Apache-2.0",
"dependencies": {
"convex-helpers": "^0.1.108",
"fast-json-stable-stringify": "^2.1.0"
},
"peerDependencies": {
"convex": "^1.29.3",
"react": "^18.3.1 || ^19.0.0"
}
},
"node_modules/convex-helpers": {
"version": "0.1.109",
"resolved": "https://registry.npmjs.org/convex-helpers/-/convex-helpers-0.1.109.tgz",
"integrity": "sha512-3Yzi+4M+szpqze2/NjTqBdA6nit1vgtU0M3Ga6lUw2oNjOMrQ6si2Dx2M524GHXJ+5icBNM/6cii4Nrx58zMEw==",
"license": "Apache-2.0",
"bin": {
"convex-helpers": "bin.cjs"
},
"peerDependencies": {
"@standard-schema/spec": "^1.0.0",
"convex": "^1.25.4",
"hono": "^4.0.5",
"react": "^17.0.2 || ^18.0.0 || ^19.0.0",
"typescript": "^5.5",
"zod": "^3.25.0 || ^4.0.0"
},
"peerDependenciesMeta": {
"@standard-schema/spec": {
"optional": true
},
"hono": {
"optional": true
},
"react": {
"optional": true
},
"typescript": {
"optional": true
},
"zod": {
"optional": true
}
}
},
"node_modules/cookie": { "node_modules/cookie": {
"version": "0.7.2", "version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
@@ -5798,7 +5847,6 @@
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
"integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/fast-levenshtein": { "node_modules/fast-levenshtein": {

View File

@@ -41,6 +41,7 @@
"@workos-inc/widgets": "^1.6.1", "@workos-inc/widgets": "^1.6.1",
"agentmail": "^0.1.15", "agentmail": "^0.1.15",
"convex": "^1.17.4", "convex": "^1.17.4",
"convex-fs": "^0.2.0",
"date-fns": "^3.3.1", "date-fns": "^3.3.1",
"diff": "^8.0.2", "diff": "^8.0.2",
"gray-matter": "^4.0.3", "gray-matter": "^4.0.3",

BIN
public/images/opencode.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 423 KiB

View File

@@ -1,6 +1,6 @@
# llms.txt - Information for AI assistants and LLMs # llms.txt - Information for AI assistants and LLMs
# Learn more: https://llmstxt.org/ # Learn more: https://llmstxt.org/
# Last updated: 2026-01-09T07:02:12.474Z # Last updated: 2026-01-10T23:49:21.883Z
> Your content is instantly available to browsers, LLMs, and AI agents. > Your content is instantly available to browsers, LLMs, and AI agents.
@@ -9,8 +9,8 @@
- URL: https://yoursite.example.com - URL: https://yoursite.example.com
- Description: Your content is instantly available to browsers, LLMs, and AI agents. Write markdown, sync from the terminal. Your content is instantly available to browsers, LLMs, and AI agents. Built on Convex and Netlify. - Description: Your content is instantly available to browsers, LLMs, and AI agents. Write markdown, sync from the terminal. Your content is instantly available to browsers, LLMs, and AI agents. Built on Convex and Netlify.
- Topics: Markdown, Convex, React, TypeScript, Netlify, Open Source, AI, LLM, AEO, GEO - Topics: Markdown, Convex, React, TypeScript, Netlify, Open Source, AI, LLM, AEO, GEO
- Total Posts: 18 - Total Posts: 19
- Latest Post: 2026-01-07 - Latest Post: 2026-01-10
- GitHub: https://github.com/waynesutton/markdown-site - GitHub: https://github.com/waynesutton/markdown-site
# API Endpoints # API Endpoints

View File

@@ -7,6 +7,91 @@ Date: 2026-01-10
All notable changes to this project. All notable changes to this project.
## v2.18.0
Released January 10, 2026
**OpenCode AI development tool integration**
Added full OpenCode support to make the framework compatible with the OpenCode AI-first development tool. This works alongside existing Claude Code and Cursor integrations without conflicts.
**Changes:**
- Created `.opencode/` directory structure with config, agents, commands, skills, and plugins
- 3 specialized agents: orchestrator (main router), content-writer, sync-manager
- 6 commands: /sync, /sync-prod, /create-post, /create-page, /import, /deploy
- 4 skills adapted from .claude/skills/: frontmatter, sync, convex, content
- sync-helper plugin logs reminders when content files change
- Documentation page at /docs-opencode
**Files changed:**
- `opencode.json` - Root OpenCode project configuration
- `.opencode/config.json` - OpenCode app configuration
- `.opencode/agent/*.md` - Agent definitions (3 files)
- `.opencode/command/*.md` - Command definitions (6 files)
- `.opencode/skill/*.md` - Skill documentation (4 files)
- `.opencode/plugin/sync-helper.ts` - Reminder plugin
- `content/pages/docs-opencode.md` - Documentation page
- `files.md` - Added OpenCode Configuration section
---
## v2.17.0
Released January 10, 2026
**ConvexFS Media Library with Bunny CDN integration**
Added a full-featured media library for uploading and managing images in the dashboard. Images are stored on Bunny.net Edge Storage and served via their global CDN. The Image Insert modal now supports selecting from existing media and choosing image sizes.
**Changes:**
- Upload images via drag-and-drop or click to upload
- Copy images as Markdown, HTML, or direct URL
- Bulk select and delete multiple images at once
- Media Library tab in Image Insert modal for selecting existing images
- Size presets: Original, Large (1200px), Medium (800px), Small (400px), Thumbnail (200px), Custom
- Image dimensions displayed before insert with aspect ratio preserved
- File expiration support for automatic cleanup
- Configuration warning when Bunny CDN not set up
**Files changed:**
- `convex/convex.config.ts` - Added ConvexFS component
- `convex/fs.ts` - ConvexFS instance with Bunny CDN config
- `convex/files.ts` - File mutations and queries
- `convex/http.ts` - ConvexFS routes for upload/download
- `src/components/MediaLibrary.tsx` - Media library gallery
- `src/components/ImageUploadModal.tsx` - Enhanced modal with library and sizes
- `src/styles/global.css` - Media library and modal styles
- `content/pages/docs-media-setup.md` - Setup documentation
---
## v2.16.4
Released January 10, 2026
**AI image generation download and copy options**
Added download and copy functionality to the Dashboard AI Agent image generation section. After generating an image with Nano Banana, users can now download the image or copy Markdown/HTML code for embedding.
**Changes:**
- Download button saves generated image to computer with filename from prompt
- MD button copies Markdown code (`![prompt](url)`) to clipboard
- HTML button copies HTML code (`<img src="url" alt="prompt" />`) to clipboard
- Code preview section displays both Markdown and HTML snippets
- Visual feedback when code is copied (button changes to "Copied")
**Files changed:**
- `src/pages/Dashboard.tsx` - Added download/copy handlers and updated UI
- `src/styles/global.css` - Added CSS for action buttons and code preview
---
## v2.16.3 ## v2.16.3
Released January 10, 2026 Released January 10, 2026

View File

@@ -0,0 +1,229 @@
# Media Upload Setup
---
Type: page
Date: 2026-01-10
---
Set up image uploads for the dashboard using ConvexFS and Bunny.net CDN.
## ConvexFS Documentation
This media library is powered by [ConvexFS](https://convexfs.dev/), a virtual filesystem for Convex with CDN integration.
**Resources:**
- [ConvexFS Documentation](https://convexfs.dev/) - Complete setup guides, API reference, and examples
- [ConvexFS GitHub](https://github.com/jamwt/convex-fs) - Source code and issues
For detailed setup instructions including app configuration, garbage collection, file expiration, and advanced features, follow the official ConvexFS documentation.
## Overview
The media library allows you to upload images directly from the dashboard and insert them into your content. Images are stored on Bunny.net Edge Storage and served via their global CDN for fast delivery.
## Prerequisites
- A Bunny.net account
- Convex project deployed
- Access to Convex Dashboard environment variables
## Create Bunny.net account
1. Go to [bunny.net](https://bunny.net) and sign up
2. Bunny offers a 14-day free trial with no credit card required
3. After trial, storage costs around $0.01/GB/month
## Create storage zone
1. In the Bunny Dashboard, go to **Storage** in the sidebar
2. Click **Add Storage Zone**
3. Configure your storage zone:
- **Name**: Choose a unique name (e.g., `mysite-media`)
- **Main Storage Region**: Select the region closest to your users
- **Replication Regions**: Optional, select additional regions for redundancy
4. Click **Create Storage Zone**
## Set up Pull Zone (CDN)
1. After creating the storage zone, click **Connected Pull Zone**
2. Create a new pull zone or connect to an existing one
3. Note your **Pull Zone Hostname** (e.g., `mysite-media.b-cdn.net`)
4. Enable **Token Authentication** under Security settings for signed URLs
## Get API credentials
From your Bunny Dashboard, collect these values:
| Credential | Location |
|------------|----------|
| **API Key** | Account > API > API Key (password icon) |
| **Storage Zone Name** | Storage > [Your Zone] > Name |
| **CDN Hostname** | Storage > [Your Zone] > Connected Pull Zone hostname |
| **Token Key** | Pull Zone > Security > Token Authentication > Token Key |
## Add environment variables
### Local development (.env.local)
Create or edit `.env.local` in your project root:
```bash
BUNNY_API_KEY=your-api-key-here
BUNNY_STORAGE_ZONE=your-zone-name
BUNNY_CDN_HOSTNAME=your-zone.b-cdn.net
BUNNY_TOKEN_KEY=your-token-key
```
### Convex Dashboard
1. Go to your project in the [Convex Dashboard](https://dashboard.convex.dev)
2. Navigate to **Settings** > **Environment Variables**
3. Add each of these variables:
- `BUNNY_API_KEY`
- `BUNNY_STORAGE_ZONE`
- `BUNNY_CDN_HOSTNAME`
- `BUNNY_TOKEN_KEY` (optional, for signed URLs)
4. Click **Save**
## Deploy changes
After setting environment variables:
```bash
npx convex deploy
```
This pushes the ConvexFS configuration with your Bunny credentials.
## Test upload workflow
1. Go to Dashboard > Create > Media
2. Click the upload zone or drag an image
3. Verify the image appears in the grid
4. Click **MD** to copy markdown and paste in a post
## Using in content
### Media Library
Access the Media Library from Dashboard sidebar under **Create > Media**. From here you can:
- Upload multiple images via drag-and-drop
- Copy markdown, HTML, or direct URL
- Select multiple images for bulk delete
- View file sizes
### Bulk delete
To delete multiple images at once:
1. Click the **Select** button in the toolbar
2. Click images to select them (or use **Select All**)
3. Click **Delete (N)** to remove selected images
4. Confirm deletion in the dialog
### Insert in editor
When writing a post or page in the dashboard:
1. Click the **Image** button in the toolbar
2. Choose **Upload New** to upload a new image, or **Media Library** to select an existing image
3. After selecting/uploading, you'll see:
- Image preview with original dimensions (e.g., 1920 x 1080px)
- Alt text field for accessibility
- Size presets: Original, Large (1200px), Medium (800px), Small (400px), Thumbnail (200px), or Custom
4. Choose a size - the display shows the calculated dimensions with aspect ratio preserved
5. Click **Insert** to add the image to your content
### Size options
| Preset | Max Width | Use Case |
|--------|-----------|----------|
| Original | Full size | High-resolution displays |
| Large | 1200px | Hero images, full-width content |
| Medium | 800px | Standard content images |
| Small | 400px | Thumbnails, sidebars |
| Thumbnail | 200px | Grids, galleries |
| Custom | Any | Specific dimensions needed |
When using a size other than Original, images are inserted with explicit width/height attributes to prevent layout shift.
### Frontmatter images
For post header images, upload via Media Library then copy the URL:
```yaml
---
title: "My Post"
image: https://your-zone.b-cdn.net/uploads/12345-image.png
---
```
## Configuration options
In `siteConfig.ts`:
```typescript
media: {
enabled: true, // Toggle media features
maxFileSize: 10, // Max file size in MB
allowedTypes: [ // Allowed MIME types
"image/png",
"image/jpeg",
"image/gif",
"image/webp"
],
}
```
## Troubleshooting
### "Upload failed" error
- Verify all four environment variables are set in Convex Dashboard
- Check that the API key has write permissions
- Ensure the storage zone name matches exactly
### Images not loading
- Verify the CDN hostname is correct
- Check the Pull Zone is connected to your Storage Zone
- Try accessing the image URL directly in browser
### 403 Forbidden errors
- Token authentication may be blocking unsigned requests
- Either disable token auth in Pull Zone settings
- Or ensure `BUNNY_TOKEN_KEY` is set correctly
### Files uploading but not visible
- Check browser console for errors
- Verify the `/fs/upload` route is registered in `http.ts`
- Run `npx convex deploy` to sync configuration
## File organization
Uploaded files are stored at `/uploads/{timestamp}-{filename}`:
```
/uploads/
1704067200000-hero-image.png
1704067201000-screenshot.jpg
1704067202000-diagram.webp
```
Timestamps ensure unique filenames and provide chronological ordering.
## Cost estimate
Bunny.net pricing (as of 2024):
| Service | Cost |
|---------|------|
| Storage | $0.01/GB/month |
| Bandwidth (EU/US) | $0.01/GB |
| Bandwidth (APAC) | $0.03/GB |
For a typical blog with 1GB of images and 10GB monthly bandwidth: ~$0.11/month.

180
public/raw/docs-opencode.md Normal file
View File

@@ -0,0 +1,180 @@
# OpenCode Integration
---
Type: post
Date: 2026-01-10
Reading time: 4 min read
Tags: opencode, plugins, terminal
---
## OpenCode Integration
OpenCode is an AI-first development tool that works alongside Claude Code and Cursor. This framework includes full OpenCode support with agents, commands, skills, and plugins.
---
### How OpenCode works
```
+------------------+ +-------------------+ +------------------+
| User request |--->| Orchestrator |--->| Specialist |
| "Create a post" | | Agent routes | | Agent executes |
| | | to specialist | | the task |
+------------------+ +-------------------+ +--------+---------+
|
v
+------------------+ +-------------------+ +------------------+
| Result |<---| Skills provide |<---| Commands wrap |
| returned to | | context and | | npm scripts |
| user | | documentation | | for quick access|
+------------------+ +-------------------+ +------------------+
```
1. User makes a request in OpenCode
2. Orchestrator agent analyzes and routes the task
3. Specialist agent (content-writer or sync-manager) handles it
4. Skills provide documentation context
5. Commands offer quick keyboard shortcuts
6. Plugins automate common workflows
### Directory structure
OpenCode configuration lives in `.opencode/` alongside existing `.claude/` and `.cursor/` directories:
```
.opencode/
├── config.json # OpenCode app configuration
├── agent/
│ ├── orchestrator.md # Main routing agent
│ ├── content-writer.md # Content creation specialist
│ └── sync-manager.md # Sync and deployment specialist
├── command/
│ ├── sync.md # /sync command
│ ├── sync-prod.md # /sync-prod command
│ ├── create-post.md # /create-post command
│ ├── create-page.md # /create-page command
│ ├── import.md # /import command
│ └── deploy.md # /deploy command
├── skill/
│ ├── frontmatter.md # Frontmatter syntax reference
│ ├── sync.md # How sync works
│ ├── convex.md # Convex patterns
│ └── content.md # Content management guide
└── plugin/
└── sync-helper.ts # Reminder plugin for content changes
```
### Available commands
Quick commands accessible via `/` prefix in OpenCode:
| Command | Purpose |
| -------------- | ---------------------------------------------- |
| `/sync` | Sync markdown content to development Convex |
| `/sync-prod` | Sync markdown content to production Convex |
| `/create-post` | Create a new blog post with proper frontmatter |
| `/create-page` | Create a new static page |
| `/import` | Import content from an external URL |
| `/deploy` | Full deployment workflow to production |
### Agents
Three specialized agents handle different types of tasks:
**Orchestrator** (primary agent)
- Routes tasks to appropriate specialists
- Handles general code changes directly
- Coordinates multi-step workflows
**Content Writer** (subagent)
- Creates blog posts and pages
- Validates frontmatter
- Knows content directory structure
- Reminds you to run sync
**Sync Manager** (subagent)
- Executes sync commands
- Handles dev vs prod environments
- Troubleshoots sync issues
- Manages deployments
### Skills
Skills provide documentation context to agents:
| Skill | Purpose |
| ----------- | --------------------------------------------- |
| frontmatter | Complete frontmatter syntax for posts/pages |
| sync | How the sync system works end-to-end |
| convex | Convex patterns (indexes, mutations, queries) |
| content | Content management workflows |
### Plugins
The sync-helper plugin provides automation:
```typescript
// When content files change, log a reminder
"file.edited": async (event) => {
if (event.path.startsWith("content/")) {
await client.app.log("info", "Content changed - run /sync to publish")
}
}
```
Plugins hook into OpenCode events like file edits and session idle states.
### Getting started
1. Install OpenCode CLI (see [opencode.ai](https://opencode.ai))
2. Open your project directory
3. OpenCode automatically recognizes the `.opencode/` configuration
4. Use `/sync` after creating content
### Compatibility
This framework works with multiple AI development tools simultaneously:
| Tool | Configuration Directory |
| ----------- | ----------------------- |
| OpenCode | `.opencode/` |
| Claude Code | `.claude/skills/` |
| Cursor | `.cursor/rules/` |
All tools can be used without conflicts. Skills are duplicated (not shared) to ensure each tool works independently.
### Configuration files
| File | Purpose |
| ----------------------- | -------------------------- |
| `opencode.json` | Root project configuration |
| `.opencode/config.json` | App-level settings |
Example `opencode.json`:
```json
{
"name": "markdown-publishing-framework",
"description": "AI-first markdown publishing with Convex real-time sync",
"plugin": []
}
```
### When to use OpenCode vs other tools
| Task | Recommended Tool |
| ---------------------- | ----------------------------- |
| Quick content creation | OpenCode (`/create-post`) |
| Complex code changes | Claude Code or Cursor |
| Sync workflows | OpenCode (`/sync`, `/deploy`) |
| Debugging | Any tool with your preference |
### Resources
- [OpenCode Documentation](https://opencode.ai/docs/)
- [OpenCode Plugins](https://opencode.ai/docs/plugins/)
- [OpenCode SDK](https://opencode.ai/docs/sdk/)
- [OpenCode Workflow Examples](https://github.com/CloudAI-X/opencode-workflow)

View File

@@ -30,8 +30,10 @@ agents. -->
--- ---
## Blog Posts (19) ## Blog Posts (20)
- **[OpenCode Integration](/raw/docs-opencode.md)**
- Date: 2026-01-10 | Reading time: 4 min read | Tags: opencode, plugins, terminal
- **[How to Use Code Blocks](/raw/how-to-use-code-blocks.md)** - A guide to syntax highlighting, diff rendering, and code formatting in your markdown posts. - **[How to Use Code Blocks](/raw/how-to-use-code-blocks.md)** - A guide to syntax highlighting, diff rendering, and code formatting in your markdown posts.
- Date: 2026-01-07 | Reading time: 4 min read | Tags: tutorial, markdown, code, syntax-highlighting - Date: 2026-01-07 | Reading time: 4 min read | Tags: tutorial, markdown, code, syntax-highlighting
- **[How I added WorkOS to my Convex app with Cursor](/raw/workos-with-convex-cursor.md)** - A timeline of adding WorkOS AuthKit authentication to my markdown blog dashboard using Cursor, prompt engineering, and vibe coding. From PRD import to published feature. - **[How I added WorkOS to my Convex app with Cursor](/raw/workos-with-convex-cursor.md)** - A timeline of adding WorkOS AuthKit authentication to my markdown blog dashboard using Cursor, prompt engineering, and vibe coding. From PRD import to published feature.
@@ -71,7 +73,7 @@ agents. -->
- **[Using Images in Blog Posts](/raw/using-images-in-posts.md)** - Learn how to add header images, inline images, and Open Graph images to your markdown posts. - **[Using Images in Blog Posts](/raw/using-images-in-posts.md)** - Learn how to add header images, inline images, and Open Graph images to your markdown posts.
- Date: 2025-12-14 | Reading time: 4 min read | Tags: images, tutorial, markdown, open-graph - Date: 2025-12-14 | Reading time: 4 min read | Tags: images, tutorial, markdown, open-graph
## Pages (16) ## Pages (17)
- **[Footer](/raw/footer.md)** - **[Footer](/raw/footer.md)**
- **[Home Intro](/raw/home-intro.md)** - **[Home Intro](/raw/home-intro.md)**
@@ -89,10 +91,11 @@ agents. -->
- **[Changelog](/raw/changelog.md)** - **[Changelog](/raw/changelog.md)**
- **[Deployment](/raw/docs-deployment.md)** - **[Deployment](/raw/docs-deployment.md)**
- **[Newsletter](/raw/newsletter.md)** - **[Newsletter](/raw/newsletter.md)**
- **[Media Upload Setup](/raw/docs-media-setup.md)**
--- ---
**Total Content:** 19 posts, 16 pages **Total Content:** 20 posts, 17 pages
All content is available as raw markdown files at `/raw/{slug}.md` All content is available as raw markdown files at `/raw/{slug}.md`

View File

@@ -0,0 +1,553 @@
import { useState, useRef, useCallback, useEffect } from "react";
import { useAction, usePaginatedQuery, useQuery } from "convex/react";
import { api } from "../../convex/_generated/api";
import {
X,
Upload,
CloudArrowUp,
Warning,
Image as ImageIcon,
Images,
ArrowsOut,
Check,
} from "@phosphor-icons/react";
// Derive the .site URL from Convex URL for uploads
const getSiteUrl = () => {
const convexUrl = import.meta.env.VITE_CONVEX_URL ?? "";
return convexUrl.replace(/\.cloud$/, ".site");
};
// Size presets for image insertion
const SIZE_PRESETS = [
{ id: "original", label: "Original", width: null, height: null },
{ id: "large", label: "Large", width: 1200, height: null },
{ id: "medium", label: "Medium", width: 800, height: null },
{ id: "small", label: "Small", width: 400, height: null },
{ id: "thumbnail", label: "Thumbnail", width: 200, height: null },
{ id: "custom", label: "Custom", width: null, height: null },
] as const;
type SizePreset = typeof SIZE_PRESETS[number]["id"];
interface ImageUploadModalProps {
isOpen: boolean;
onClose: () => void;
onInsert: (markdown: string) => void;
}
interface ImageInfo {
url: string;
width: number;
height: number;
filename: string;
}
export function ImageUploadModal({ isOpen, onClose, onInsert }: ImageUploadModalProps) {
const [activeTab, setActiveTab] = useState<"upload" | "library">("upload");
const [uploading, setUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [preview, setPreview] = useState<string | null>(null);
const [altText, setAltText] = useState("");
const [selectedImage, setSelectedImage] = useState<ImageInfo | null>(null);
const [sizePreset, setSizePreset] = useState<SizePreset>("original");
const [customWidth, setCustomWidth] = useState<number | null>(null);
const [customHeight, setCustomHeight] = useState<number | null>(null);
const [dragOver, setDragOver] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const commitFile = useAction(api.files.commitFile);
const configStatus = useQuery(api.files.isConfigured);
const isBunnyConfigured = configStatus?.configured ?? false;
const { results: mediaFiles, status: mediaStatus, loadMore } = usePaginatedQuery(
api.files.listFiles,
{ prefix: "/uploads/" },
{ initialNumItems: 12 }
);
const siteUrl = getSiteUrl();
const cdnHostname = import.meta.env.VITE_BUNNY_CDN_HOSTNAME;
// Reset state when modal closes
const handleClose = () => {
setPreview(null);
setAltText("");
setSelectedImage(null);
setError(null);
setSizePreset("original");
setCustomWidth(null);
setCustomHeight(null);
setActiveTab("upload");
onClose();
};
// Get CDN URL for a file
const getCdnUrl = (path: string, blobId: string) => {
if (cdnHostname) {
return `https://${cdnHostname}${path}`;
}
return `${siteUrl}/fs/blobs/${blobId}`;
};
// Get image dimensions from URL
const getImageDimensionsFromUrl = (url: string): Promise<{ width: number; height: number }> => {
return new Promise((resolve) => {
const img = new window.Image();
img.onload = () => {
resolve({ width: img.naturalWidth, height: img.naturalHeight });
};
img.onerror = () => {
resolve({ width: 0, height: 0 });
};
img.src = url;
});
};
// Get image dimensions from file
const getImageDimensions = (file: File): Promise<{ width: number; height: number }> => {
return new Promise((resolve) => {
const img = new window.Image();
img.onload = () => {
resolve({ width: img.naturalWidth, height: img.naturalHeight });
URL.revokeObjectURL(img.src);
};
img.onerror = () => {
resolve({ width: 0, height: 0 });
URL.revokeObjectURL(img.src);
};
img.src = URL.createObjectURL(file);
});
};
// Calculate display dimensions based on preset
const getDisplayDimensions = () => {
if (!selectedImage) return { width: 0, height: 0 };
const { width: origWidth, height: origHeight } = selectedImage;
const aspectRatio = origWidth / origHeight;
if (sizePreset === "original") {
return { width: origWidth, height: origHeight };
}
if (sizePreset === "custom") {
if (customWidth && customHeight) {
return { width: customWidth, height: customHeight };
}
if (customWidth) {
return { width: customWidth, height: Math.round(customWidth / aspectRatio) };
}
if (customHeight) {
return { width: Math.round(customHeight * aspectRatio), height: customHeight };
}
return { width: origWidth, height: origHeight };
}
const preset = SIZE_PRESETS.find((p) => p.id === sizePreset);
if (preset?.width) {
const newWidth = Math.min(preset.width, origWidth);
return { width: newWidth, height: Math.round(newWidth / aspectRatio) };
}
return { width: origWidth, height: origHeight };
};
// Handle file upload
const handleUpload = useCallback(async (file: File) => {
setError(null);
setUploading(true);
setUploadProgress("Uploading...");
try {
// Validate file type
if (!file.type.startsWith("image/")) {
throw new Error("File must be an image");
}
// Validate file size (10MB max)
if (file.size > 10 * 1024 * 1024) {
throw new Error("File exceeds 10MB limit");
}
// Show preview
const previewUrl = URL.createObjectURL(file);
setPreview(previewUrl);
setAltText(file.name.replace(/\.[^/.]+$/, "").replace(/[-_]/g, " "));
// Get image dimensions
const dimensions = await getImageDimensions(file);
// Upload blob to ConvexFS endpoint
const res = await fetch(`${siteUrl}/fs/upload`, {
method: "POST",
headers: { "Content-Type": file.type },
body: file,
});
if (!res.ok) {
const errorText = await res.text();
throw new Error(errorText || `Upload failed: ${res.status}`);
}
const { blobId } = await res.json();
// Commit file to storage path
const result = await commitFile({
blobId,
filename: file.name,
contentType: file.type,
size: file.size,
width: dimensions.width,
height: dimensions.height,
});
// Get CDN URL
const url = getCdnUrl(result.path, blobId);
setSelectedImage({
url,
width: dimensions.width,
height: dimensions.height,
filename: file.name,
});
setUploadProgress(null);
} catch (err) {
setError((err as Error).message);
setPreview(null);
} finally {
setUploading(false);
setUploadProgress(null);
}
}, [commitFile, siteUrl, cdnHostname]);
// Handle file input change
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
handleUpload(file);
}
};
// Handle drag and drop
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
setDragOver(true);
};
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault();
setDragOver(false);
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
setDragOver(false);
const file = e.dataTransfer.files[0];
if (file) {
handleUpload(file);
}
};
// Handle selecting from media library
const handleSelectFromLibrary = async (file: { path: string; blobId: string; size: number }) => {
const url = getCdnUrl(file.path, file.blobId);
const filename = file.path.split("/").pop() || "image";
// Get dimensions from URL
const dimensions = await getImageDimensionsFromUrl(url);
setSelectedImage({
url,
width: dimensions.width,
height: dimensions.height,
filename,
});
setPreview(url);
setAltText(filename.replace(/\.[^/.]+$/, "").replace(/[-_]/g, " "));
};
// Generate markdown with size
const generateMarkdown = () => {
if (!selectedImage) return "";
const dims = getDisplayDimensions();
const alt = altText || "image";
// For original size, just use standard markdown
if (sizePreset === "original") {
return `![${alt}](${selectedImage.url})`;
}
// For other sizes, use HTML img tag with explicit dimensions
return `<img src="${selectedImage.url}" alt="${alt}" width="${dims.width}" height="${dims.height}" />`;
};
// Insert markdown
const handleInsert = () => {
if (selectedImage) {
const markdown = generateMarkdown();
onInsert(markdown);
handleClose();
}
};
// Update custom dimensions when preset changes
useEffect(() => {
if (sizePreset !== "custom" && selectedImage) {
const dims = getDisplayDimensions();
setCustomWidth(dims.width);
setCustomHeight(dims.height);
}
}, [sizePreset, selectedImage]);
if (!isOpen) return null;
const displayDims = getDisplayDimensions();
return (
<div className="image-upload-modal-backdrop" onClick={handleClose}>
<div
className="image-upload-modal image-upload-modal-large"
onClick={(e) => e.stopPropagation()}
>
<div className="image-upload-modal-header">
<h3>
<ImageIcon size={20} />
Insert Image
</h3>
<button className="image-upload-modal-close" onClick={handleClose}>
<X size={20} />
</button>
</div>
{/* Tabs */}
<div className="image-upload-tabs">
<button
className={`image-upload-tab ${activeTab === "upload" ? "active" : ""}`}
onClick={() => setActiveTab("upload")}
>
<Upload size={16} />
Upload New
</button>
<button
className={`image-upload-tab ${activeTab === "library" ? "active" : ""}`}
onClick={() => setActiveTab("library")}
disabled={!isBunnyConfigured}
>
<Images size={16} />
Media Library
</button>
</div>
<div className="image-upload-modal-content">
{/* Error message */}
{error && (
<div className="image-upload-error">
<Warning size={16} />
<span>{error}</span>
</div>
)}
{activeTab === "upload" && !selectedImage && (
<div
className={`image-upload-dropzone ${dragOver ? "drag-over" : ""}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()}
>
<input
ref={fileInputRef}
type="file"
accept="image/png,image/jpeg,image/gif,image/webp"
onChange={handleFileChange}
style={{ display: "none" }}
/>
{uploading ? (
<>
<CloudArrowUp size={48} className="spinning" />
<p>{uploadProgress}</p>
</>
) : (
<>
<Upload size={48} />
<p>
<strong>Click to upload</strong> or drag and drop
</p>
<span>PNG, JPG, GIF, WebP up to 10MB</span>
</>
)}
</div>
)}
{activeTab === "library" && !selectedImage && (
<div className="image-upload-library">
{!isBunnyConfigured ? (
<div className="image-upload-library-empty">
<Warning size={32} />
<p>Media library not configured</p>
</div>
) : mediaFiles.length === 0 ? (
<div className="image-upload-library-empty">
<Images size={32} />
<p>No images in library</p>
<button onClick={() => setActiveTab("upload")}>Upload an image</button>
</div>
) : (
<>
<div className="image-upload-library-grid">
{mediaFiles.map((file) => (
<div
key={file.path}
className="image-upload-library-item"
onClick={() => handleSelectFromLibrary(file)}
>
<img
src={getCdnUrl(file.path, file.blobId)}
alt={file.path.split("/").pop()}
loading="lazy"
/>
</div>
))}
</div>
{mediaStatus === "CanLoadMore" && (
<button
className="image-upload-library-loadmore"
onClick={() => loadMore(12)}
>
Load more
</button>
)}
</>
)}
</div>
)}
{/* Preview and settings when image is selected */}
{selectedImage && (
<div className="image-upload-selected">
<div className="image-upload-preview-container">
<div className="image-upload-preview">
<img src={preview || selectedImage.url} alt="Preview" />
{uploading && (
<div className="image-upload-preview-loading">
<CloudArrowUp size={32} className="spinning" />
<span>{uploadProgress}</span>
</div>
)}
</div>
<div className="image-upload-dimensions">
<ArrowsOut size={14} />
<span>
{selectedImage.width} x {selectedImage.height}px
{sizePreset !== "original" && (
<> {displayDims.width} x {displayDims.height}px</>
)}
</span>
</div>
</div>
<div className="image-upload-settings">
{/* Alt text input */}
<div className="image-upload-field">
<label htmlFor="alt-text">Alt text</label>
<input
id="alt-text"
type="text"
value={altText}
onChange={(e) => setAltText(e.target.value)}
placeholder="Describe the image..."
/>
</div>
{/* Size presets */}
<div className="image-upload-field">
<label>Size</label>
<div className="image-upload-size-presets">
{SIZE_PRESETS.map((preset) => (
<button
key={preset.id}
className={`image-upload-size-btn ${sizePreset === preset.id ? "active" : ""}`}
onClick={() => setSizePreset(preset.id)}
>
{sizePreset === preset.id && <Check size={12} />}
{preset.label}
{preset.width && <span className="size-hint">{preset.width}px</span>}
</button>
))}
</div>
</div>
{/* Custom dimensions */}
{sizePreset === "custom" && (
<div className="image-upload-custom-size">
<div className="image-upload-field-inline">
<label>Width</label>
<input
type="number"
value={customWidth || ""}
onChange={(e) => {
const val = parseInt(e.target.value) || null;
setCustomWidth(val);
if (val && selectedImage) {
const ratio = selectedImage.width / selectedImage.height;
setCustomHeight(Math.round(val / ratio));
}
}}
placeholder="Auto"
/>
<span>px</span>
</div>
<div className="image-upload-field-inline">
<label>Height</label>
<input
type="number"
value={customHeight || ""}
onChange={(e) => {
const val = parseInt(e.target.value) || null;
setCustomHeight(val);
if (val && selectedImage) {
const ratio = selectedImage.width / selectedImage.height;
setCustomWidth(Math.round(val * ratio));
}
}}
placeholder="Auto"
/>
<span>px</span>
</div>
</div>
)}
{/* Change image button */}
<button
className="image-upload-change"
onClick={() => {
setSelectedImage(null);
setPreview(null);
}}
>
Choose different image
</button>
</div>
</div>
)}
</div>
<div className="image-upload-modal-footer">
<button className="image-upload-cancel" onClick={handleClose}>
Cancel
</button>
<button
className="image-upload-insert"
onClick={handleInsert}
disabled={!selectedImage || uploading}
>
{uploading ? "Uploading..." : "Insert"}
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,509 @@
import { useState, useRef, useCallback } from "react";
import { useAction, usePaginatedQuery, useMutation, useQuery } from "convex/react";
import { api } from "../../convex/_generated/api";
import {
Image as ImageIcon,
Upload,
Trash,
CopySimple,
Check,
Link as LinkIcon,
Code,
X,
Warning,
CloudArrowUp,
CheckSquare,
Square,
SelectionAll,
} from "@phosphor-icons/react";
// Derive the .site URL from Convex URL for uploads
const getSiteUrl = () => {
const convexUrl = import.meta.env.VITE_CONVEX_URL ?? "";
return convexUrl.replace(/\.cloud$/, ".site");
};
// File metadata type from ConvexFS
interface FileInfo {
path: string;
blobId: string;
contentType: string;
size: number;
}
// Copy format options
type CopyFormat = "markdown" | "html" | "url";
export function MediaLibrary() {
const [uploading, setUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [dragOver, setDragOver] = useState(false);
const [copiedPath, setCopiedPath] = useState<string | null>(null);
const [copiedFormat, setCopiedFormat] = useState<CopyFormat | null>(null);
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
const [selectedFiles, setSelectedFiles] = useState<Set<string>>(new Set());
const [selectMode, setSelectMode] = useState(false);
const [bulkDeleteConfirm, setBulkDeleteConfirm] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
// Check if Bunny CDN is configured (server-side check)
const configStatus = useQuery(api.files.isConfigured);
const isBunnyConfigured = configStatus?.configured ?? false;
// Convex hooks
const commitFile = useAction(api.files.commitFile);
const deleteFile = useMutation(api.files.deleteFile);
const deleteFiles = useMutation(api.files.deleteFiles);
const { results, status, loadMore } = usePaginatedQuery(
api.files.listFiles,
{ prefix: "/uploads/" },
{ initialNumItems: 20 }
);
const siteUrl = getSiteUrl();
const cdnHostname = import.meta.env.VITE_BUNNY_CDN_HOSTNAME;
// Get CDN URL for a file
const getCdnUrl = (file: FileInfo) => {
// Use the Bunny CDN hostname if configured
if (cdnHostname) {
return `https://${cdnHostname}${file.path}`;
}
// Fallback to ConvexFS blob URL
return `${siteUrl}/fs/blobs/${file.blobId}`;
};
// Handle file upload
const handleUpload = useCallback(async (files: FileList | null) => {
if (!files || files.length === 0) return;
setError(null);
setUploading(true);
for (let i = 0; i < files.length; i++) {
const file = files[i];
setUploadProgress(`Uploading ${file.name} (${i + 1}/${files.length})...`);
try {
// Validate file type
if (!file.type.startsWith("image/")) {
throw new Error(`${file.name} is not an image`);
}
// Validate file size (10MB max)
if (file.size > 10 * 1024 * 1024) {
throw new Error(`${file.name} exceeds 10MB limit`);
}
// Get image dimensions
const dimensions = await getImageDimensions(file);
// Upload blob to ConvexFS endpoint
const res = await fetch(`${siteUrl}/fs/upload`, {
method: "POST",
headers: { "Content-Type": file.type },
body: file,
});
if (!res.ok) {
const errorText = await res.text();
throw new Error(errorText || `Upload failed: ${res.status}`);
}
const { blobId } = await res.json();
// Commit file to storage path
await commitFile({
blobId,
filename: file.name,
contentType: file.type,
size: file.size,
width: dimensions.width,
height: dimensions.height,
});
} catch (err) {
setError((err as Error).message);
break;
}
}
setUploading(false);
setUploadProgress(null);
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
}, [commitFile, siteUrl]);
// Get image dimensions
const getImageDimensions = (file: File): Promise<{ width: number; height: number }> => {
return new Promise((resolve) => {
const img = new window.Image();
img.onload = () => {
resolve({ width: img.naturalWidth, height: img.naturalHeight });
URL.revokeObjectURL(img.src);
};
img.onerror = () => {
resolve({ width: 0, height: 0 });
URL.revokeObjectURL(img.src);
};
img.src = URL.createObjectURL(file);
});
};
// Handle file input change
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
handleUpload(e.target.files);
};
// Handle drag and drop
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
setDragOver(true);
};
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault();
setDragOver(false);
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
setDragOver(false);
handleUpload(e.dataTransfer.files);
};
// Copy to clipboard
const handleCopy = async (file: FileInfo, format: CopyFormat) => {
const url = getCdnUrl(file);
const filename = file.path.split("/").pop() || "image";
let text = "";
switch (format) {
case "markdown":
text = `![${filename}](${url})`;
break;
case "html":
text = `<img src="${url}" alt="${filename}" />`;
break;
case "url":
text = url;
break;
}
try {
await navigator.clipboard.writeText(text);
setCopiedPath(file.path);
setCopiedFormat(format);
setTimeout(() => {
setCopiedPath(null);
setCopiedFormat(null);
}, 2000);
} catch {
setError("Failed to copy to clipboard");
}
};
// Delete file
const handleDelete = async (path: string) => {
try {
await deleteFile({ path });
setDeleteConfirm(null);
} catch (err) {
setError((err as Error).message);
}
};
// Toggle file selection
const toggleFileSelection = (path: string) => {
setSelectedFiles((prev) => {
const next = new Set(prev);
if (next.has(path)) {
next.delete(path);
} else {
next.add(path);
}
return next;
});
};
// Select all files
const selectAllFiles = () => {
setSelectedFiles(new Set(results.map((f) => f.path)));
};
// Clear selection
const clearSelection = () => {
setSelectedFiles(new Set());
setSelectMode(false);
};
// Bulk delete files
const handleBulkDelete = async () => {
try {
await deleteFiles({ paths: Array.from(selectedFiles) });
setSelectedFiles(new Set());
setSelectMode(false);
setBulkDeleteConfirm(false);
} catch (err) {
setError((err as Error).message);
}
};
// Format file size
const formatSize = (bytes: number) => {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
};
return (
<div className="media-library">
<div className="media-library-header">
<ImageIcon size={32} weight="light" />
<h2>Media Library</h2>
<p>Upload and manage images for your content</p>
</div>
{/* Configuration Status */}
{!isBunnyConfigured && (
<div className="media-config-warning">
<Warning size={20} />
<div>
<strong>Bunny CDN not configured</strong>
<p>
Set BUNNY_API_KEY, BUNNY_STORAGE_ZONE, and BUNNY_CDN_HOSTNAME
environment variables in Convex Dashboard.
See <a href="/docs-media-setup">setup guide</a>.
</p>
</div>
</div>
)}
{/* Selection toolbar */}
{results.length > 0 && (
<div className="media-toolbar">
<button
className={`media-toolbar-btn ${selectMode ? "active" : ""}`}
onClick={() => {
if (selectMode) {
clearSelection();
} else {
setSelectMode(true);
}
}}
>
<SelectionAll size={16} />
<span>{selectMode ? "Cancel" : "Select"}</span>
</button>
{selectMode && (
<>
<button className="media-toolbar-btn" onClick={selectAllFiles}>
<CheckSquare size={16} />
<span>Select All</span>
</button>
{selectedFiles.size > 0 && (
<button
className="media-toolbar-btn danger"
onClick={() => setBulkDeleteConfirm(true)}
>
<Trash size={16} />
<span>Delete ({selectedFiles.size})</span>
</button>
)}
</>
)}
</div>
)}
{/* Bulk delete confirmation */}
{bulkDeleteConfirm && (
<div className="media-bulk-delete-confirm">
<Warning size={20} />
<p>Delete {selectedFiles.size} selected images?</p>
<div className="media-bulk-delete-actions">
<button className="cancel" onClick={() => setBulkDeleteConfirm(false)}>
Cancel
</button>
<button className="confirm" onClick={handleBulkDelete}>
Delete All
</button>
</div>
</div>
)}
{/* Error message */}
{error && (
<div className="media-error">
<Warning size={16} />
<span>{error}</span>
<button onClick={() => setError(null)}>
<X size={14} />
</button>
</div>
)}
{/* Upload zone */}
<div
className={`media-upload-zone ${dragOver ? "drag-over" : ""} ${uploading ? "uploading" : ""}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={() => !uploading && fileInputRef.current?.click()}
>
<input
ref={fileInputRef}
type="file"
accept="image/png,image/jpeg,image/gif,image/webp"
multiple
onChange={handleFileChange}
style={{ display: "none" }}
/>
{uploading ? (
<>
<CloudArrowUp size={48} className="upload-icon spinning" />
<p>{uploadProgress}</p>
</>
) : (
<>
<Upload size={48} className="upload-icon" />
<p>
<strong>Click to upload</strong> or drag and drop
</p>
<span>PNG, JPG, GIF, WebP up to 10MB</span>
</>
)}
</div>
{/* File grid */}
<div className="media-grid">
{results.map((file) => (
<div
key={file.path}
className={`media-item ${selectMode ? "select-mode" : ""} ${selectedFiles.has(file.path) ? "selected" : ""}`}
onClick={selectMode ? () => toggleFileSelection(file.path) : undefined}
>
{selectMode && (
<div className="media-item-checkbox">
{selectedFiles.has(file.path) ? (
<CheckSquare size={20} weight="fill" />
) : (
<Square size={20} />
)}
</div>
)}
<div className="media-item-preview">
<img
src={getCdnUrl(file)}
alt={file.path.split("/").pop()}
loading="lazy"
/>
</div>
<div className="media-item-info">
<span className="media-item-name" title={file.path}>
{file.path.split("/").pop()}
</span>
<span className="media-item-size">{formatSize(file.size)}</span>
</div>
<div className="media-item-actions">
<button
className={`media-copy-btn ${copiedPath === file.path && copiedFormat === "markdown" ? "copied" : ""}`}
onClick={() => handleCopy(file, "markdown")}
title="Copy as Markdown"
>
{copiedPath === file.path && copiedFormat === "markdown" ? (
<Check size={14} />
) : (
<CopySimple size={14} />
)}
<span>MD</span>
</button>
<button
className={`media-copy-btn ${copiedPath === file.path && copiedFormat === "html" ? "copied" : ""}`}
onClick={() => handleCopy(file, "html")}
title="Copy as HTML"
>
{copiedPath === file.path && copiedFormat === "html" ? (
<Check size={14} />
) : (
<Code size={14} />
)}
<span>HTML</span>
</button>
<button
className={`media-copy-btn ${copiedPath === file.path && copiedFormat === "url" ? "copied" : ""}`}
onClick={() => handleCopy(file, "url")}
title="Copy URL"
>
{copiedPath === file.path && copiedFormat === "url" ? (
<Check size={14} />
) : (
<LinkIcon size={14} />
)}
<span>URL</span>
</button>
<button
className="media-delete-btn"
onClick={() => setDeleteConfirm(file.path)}
title="Delete"
>
<Trash size={14} />
</button>
</div>
{/* Delete confirmation */}
{deleteConfirm === file.path && (
<div className="media-delete-confirm">
<p>Delete this image?</p>
<div className="media-delete-confirm-actions">
<button
className="cancel"
onClick={() => setDeleteConfirm(null)}
>
Cancel
</button>
<button
className="confirm"
onClick={() => handleDelete(file.path)}
>
Delete
</button>
</div>
</div>
)}
</div>
))}
</div>
{/* Load more */}
{status === "CanLoadMore" && (
<button className="media-load-more" onClick={() => loadMore(20)}>
Load more
</button>
)}
{status === "LoadingMore" && (
<div className="media-loading">Loading...</div>
)}
{/* Empty state */}
{results.length === 0 && status !== "LoadingFirstPage" && (
<div className="media-empty">
<ImageIcon size={64} weight="light" />
<p>No images uploaded yet</p>
<span>Upload your first image to get started</span>
</div>
)}
{/* Usage info */}
<div className="media-info">
<h3>Usage</h3>
<p>
Click <strong>MD</strong> to copy markdown image syntax,{" "}
<strong>HTML</strong> for img tag, or <strong>URL</strong> for direct link.
Images are served via Bunny CDN for fast global delivery.
</p>
</div>
</div>
);
}

View File

@@ -235,6 +235,14 @@ export interface DashboardConfig {
requireAuth: boolean; // Require WorkOS authentication (only works if WorkOS is configured) requireAuth: boolean; // Require WorkOS authentication (only works if WorkOS is configured)
} }
// Media library configuration
// Controls image upload and CDN storage via ConvexFS and Bunny.net
export interface MediaConfig {
enabled: boolean; // Global toggle for media library feature
maxFileSize: number; // Max file size in MB (default: 10)
allowedTypes: string[]; // Allowed MIME types
}
// Image lightbox configuration // Image lightbox configuration
// Enables click-to-magnify functionality for images in blog posts and pages // Enables click-to-magnify functionality for images in blog posts and pages
export interface ImageLightboxConfig { export interface ImageLightboxConfig {
@@ -388,6 +396,9 @@ export interface SiteConfig {
// Dashboard configuration (optional) // Dashboard configuration (optional)
dashboard?: DashboardConfig; dashboard?: DashboardConfig;
// Media library configuration (optional)
media?: MediaConfig;
// Image lightbox configuration (optional) // Image lightbox configuration (optional)
imageLightbox?: ImageLightboxConfig; imageLightbox?: ImageLightboxConfig;
@@ -735,6 +746,15 @@ export const siteConfig: SiteConfig = {
requireAuth: true, requireAuth: true,
}, },
// Media library configuration
// Upload and manage images via ConvexFS and Bunny.net CDN
// Requires BUNNY_API_KEY, BUNNY_STORAGE_ZONE, BUNNY_CDN_HOSTNAME in Convex dashboard
media: {
enabled: true,
maxFileSize: 10, // Max file size in MB
allowedTypes: ["image/png", "image/jpeg", "image/gif", "image/webp"],
},
// Image lightbox configuration // Image lightbox configuration
// Enables click-to-magnify functionality for images in blog posts and pages // Enables click-to-magnify functionality for images in blog posts and pages
// Images open in a full-screen lightbox overlay when clicked // Images open in a full-screen lightbox overlay when clicked

View File

@@ -66,6 +66,8 @@ import {
import siteConfig from "../config/siteConfig"; import siteConfig from "../config/siteConfig";
import AIChatView from "../components/AIChatView"; import AIChatView from "../components/AIChatView";
import VersionHistoryModal from "../components/VersionHistoryModal"; import VersionHistoryModal from "../components/VersionHistoryModal";
import { MediaLibrary } from "../components/MediaLibrary";
import { ImageUploadModal } from "../components/ImageUploadModal";
import { isWorkOSConfigured } from "../utils/workos"; import { isWorkOSConfigured } from "../utils/workos";
// Always import auth components - they're only used when WorkOS is configured // Always import auth components - they're only used when WorkOS is configured
import { import {
@@ -391,7 +393,8 @@ type DashboardSection =
| "config" | "config"
| "index-html" | "index-html"
| "stats" | "stats"
| "sync"; | "sync"
| "media";
// Post/Page type for editing // Post/Page type for editing
interface ContentItem { interface ContentItem {
@@ -1029,6 +1032,10 @@ function DashboardContent() {
}, [editingItem, editingType, generateMarkdown, addToast]); }, [editingItem, editingType, generateMarkdown, addToast]);
// Navigation items for left sidebar // Navigation items for left sidebar
// Filter items based on feature configuration
const mediaEnabled = siteConfig.media?.enabled ?? false;
const newsletterEnabled = siteConfig.newsletter?.enabled ?? false;
const navSections = [ const navSections = [
{ {
label: "Content", label: "Content",
@@ -1044,34 +1051,47 @@ function DashboardContent() {
{ id: "write-page" as const, label: "Write Page", icon: File }, { id: "write-page" as const, label: "Write Page", icon: File },
{ id: "ai-agent" as const, label: "AI Agent", icon: Robot }, { id: "ai-agent" as const, label: "AI Agent", icon: Robot },
{ id: "import" as const, label: "Import URL", icon: CloudArrowDown }, { id: "import" as const, label: "Import URL", icon: CloudArrowDown },
// Only show Media if media feature is enabled
...(mediaEnabled
? [{ id: "media" as const, label: "Media", icon: Image }]
: []),
], ],
}, },
{ // Only show Newsletter section if newsletter is enabled
label: "Newsletter", ...(newsletterEnabled
items: [ ? [
{ id: "newsletter" as const, label: "Subscribers", icon: Envelope }, {
{ label: "Newsletter",
id: "newsletter-send" as const, items: [
label: "Send Newsletter", {
icon: Envelope, id: "newsletter" as const,
}, label: "Subscribers",
{ icon: Envelope,
id: "newsletter-write-email" as const, },
label: "Write Email", {
icon: PencilSimple, id: "newsletter-send" as const,
}, label: "Send Newsletter",
{ icon: Envelope,
id: "newsletter-recent-sends" as const, },
label: "Recent Sends", {
icon: ClockCounterClockwise, id: "newsletter-write-email" as const,
}, label: "Write Email",
{ icon: PencilSimple,
id: "newsletter-stats" as const, },
label: "Email Stats", {
icon: ChartLine, id: "newsletter-recent-sends" as const,
}, label: "Recent Sends",
], icon: ClockCounterClockwise,
}, },
{
id: "newsletter-stats" as const,
label: "Email Stats",
icon: ChartLine,
},
],
},
]
: []),
{ {
label: "Settings", label: "Settings",
items: [ items: [
@@ -1246,6 +1266,7 @@ function DashboardContent() {
{activeSection === "index-html" && "Index HTML"} {activeSection === "index-html" && "Index HTML"}
{activeSection === "stats" && "Analytics"} {activeSection === "stats" && "Analytics"}
{activeSection === "sync" && "Sync Content"} {activeSection === "sync" && "Sync Content"}
{activeSection === "media" && "Media"}
</h1> </h1>
</div> </div>
@@ -1463,6 +1484,9 @@ function DashboardContent() {
setSyncOutput={setSyncOutput} setSyncOutput={setSyncOutput}
/> />
)} )}
{/* Media */}
{activeSection === "media" && <MediaLibrary />}
</div> </div>
</main> </main>
</div> </div>
@@ -2540,6 +2564,9 @@ function WriteSection({
}); });
// Store previous sidebar state before entering focus mode // Store previous sidebar state before entering focus mode
const [prevSidebarState, setPrevSidebarState] = useState<boolean | null>(null); const [prevSidebarState, setPrevSidebarState] = useState<boolean | null>(null);
// Image upload modal state
const [showImageUpload, setShowImageUpload] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
// Toggle focus mode // Toggle focus mode
const toggleFocusMode = useCallback(() => { const toggleFocusMode = useCallback(() => {
@@ -2693,6 +2720,33 @@ function WriteSection({
} }
}, [content]); }, [content]);
// Insert image markdown at cursor position
const handleInsertImage = useCallback((markdown: string) => {
if (editorMode === "markdown" && textareaRef.current) {
const textarea = textareaRef.current;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const newContent = content.substring(0, start) + markdown + "\n" + content.substring(end);
setContent(newContent);
// Set cursor position after inserted text
setTimeout(() => {
textarea.focus();
textarea.setSelectionRange(start + markdown.length + 1, start + markdown.length + 1);
}, 0);
} else if (editorMode === "richtext") {
// For rich text mode, convert markdown to HTML and append
const imgMatch = markdown.match(/!\[(.*?)\]\((.*?)\)/);
if (imgMatch) {
const alt = imgMatch[1];
const src = imgMatch[2];
setRichTextHtml(prev => prev + `<p><img src="${src}" alt="${alt}" /></p>`);
}
} else {
// Preview mode - just append to content
setContent(prev => prev + "\n" + markdown);
}
}, [content, editorMode]);
// Copy a single frontmatter field // Copy a single frontmatter field
const handleCopyField = useCallback( const handleCopyField = useCallback(
async (fieldName: string, example: string) => { async (fieldName: string, example: string) => {
@@ -2950,6 +3004,16 @@ published: false
)} )}
<span>{copied ? "Copied" : "Copy All"}</span> <span>{copied ? "Copied" : "Copy All"}</span>
</button> </button>
{siteConfig.media?.enabled && (
<button
onClick={() => setShowImageUpload(true)}
className="dashboard-action-btn"
title="Insert Image"
>
<Image size={16} />
<span>Image</span>
</button>
)}
<button <button
onClick={handleDownloadMarkdown} onClick={handleDownloadMarkdown}
className="dashboard-action-btn primary" className="dashboard-action-btn primary"
@@ -2990,6 +3054,7 @@ published: false
<div className="dashboard-write-main"> <div className="dashboard-write-main">
{editorMode === "markdown" && ( {editorMode === "markdown" && (
<textarea <textarea
ref={textareaRef}
value={content} value={content}
onChange={(e) => setContent(e.target.value)} onChange={(e) => setContent(e.target.value)}
className="dashboard-write-textarea" className="dashboard-write-textarea"
@@ -3101,6 +3166,15 @@ published: false
avoid losing work. avoid losing work.
</span> </span>
</div> </div>
{/* Image Upload Modal - only when media is enabled */}
{siteConfig.media?.enabled && (
<ImageUploadModal
isOpen={showImageUpload}
onClose={() => setShowImageUpload(false)}
onInsert={handleInsertImage}
/>
)}
</div> </div>
); );
} }
@@ -3120,6 +3194,7 @@ function AIAgentSection() {
const [imageError, setImageError] = useState<string | null>(null); const [imageError, setImageError] = useState<string | null>(null);
const [showImageModelDropdown, setShowImageModelDropdown] = useState(false); const [showImageModelDropdown, setShowImageModelDropdown] = useState(false);
const [showTextModelDropdown, setShowTextModelDropdown] = useState(false); const [showTextModelDropdown, setShowTextModelDropdown] = useState(false);
const [copiedFormat, setCopiedFormat] = useState<"md" | "html" | null>(null);
const generateImage = useAction(api.aiImageGeneration.generateImage); const generateImage = useAction(api.aiImageGeneration.generateImage);
@@ -3163,6 +3238,48 @@ function AIAgentSection() {
const selectedTextModelName = textModels.find(m => m.id === selectedTextModel)?.name || "Claude Sonnet 4"; const selectedTextModelName = textModels.find(m => m.id === selectedTextModel)?.name || "Claude Sonnet 4";
const selectedImageModelName = imageModels.find(m => m.id === selectedImageModel)?.name || "Nano Banana"; const selectedImageModelName = imageModels.find(m => m.id === selectedImageModel)?.name || "Nano Banana";
// Generate markdown code for the image
const getMarkdownCode = (url: string, prompt: string) => `![${prompt}](${url})`;
// Generate HTML code for the image
const getHtmlCode = (url: string, prompt: string) => `<img src="${url}" alt="${prompt}" />`;
// Copy code to clipboard
const handleCopyCode = async (format: "md" | "html") => {
if (!generatedImage) return;
const code = format === "md"
? getMarkdownCode(generatedImage.url, generatedImage.prompt)
: getHtmlCode(generatedImage.url, generatedImage.prompt);
await navigator.clipboard.writeText(code);
setCopiedFormat(format);
setTimeout(() => setCopiedFormat(null), 2000);
};
// Download image to computer
const handleDownloadImage = async () => {
if (!generatedImage) return;
try {
const response = await fetch(generatedImage.url);
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
// Generate filename from prompt (sanitize and truncate)
const filename = generatedImage.prompt
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.slice(0, 50)
.replace(/-+$/, "");
a.download = `${filename || "generated-image"}.png`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (error) {
console.error("Failed to download image:", error);
}
};
return ( return (
<div className="dashboard-ai-section"> <div className="dashboard-ai-section">
{/* Tabs */} {/* Tabs */}
@@ -3277,6 +3394,46 @@ function AIAgentSection() {
<div className="ai-generated-image"> <div className="ai-generated-image">
<img src={generatedImage.url} alt={generatedImage.prompt} /> <img src={generatedImage.url} alt={generatedImage.prompt} />
<p className="ai-generated-image-prompt">{generatedImage.prompt}</p> <p className="ai-generated-image-prompt">{generatedImage.prompt}</p>
{/* Image Actions */}
<div className="ai-image-actions">
<button
className="ai-image-action-btn download"
onClick={handleDownloadImage}
title="Download image"
>
<Download size={16} />
<span>Download</span>
</button>
<button
className={`ai-image-action-btn ${copiedFormat === "md" ? "copied" : ""}`}
onClick={() => handleCopyCode("md")}
title="Copy as Markdown"
>
{copiedFormat === "md" ? <Check size={16} /> : <CopySimple size={16} />}
<span>{copiedFormat === "md" ? "Copied" : "MD"}</span>
</button>
<button
className={`ai-image-action-btn ${copiedFormat === "html" ? "copied" : ""}`}
onClick={() => handleCopyCode("html")}
title="Copy as HTML"
>
{copiedFormat === "html" ? <Check size={16} /> : <CopySimple size={16} />}
<span>{copiedFormat === "html" ? "Copied" : "HTML"}</span>
</button>
</div>
{/* Code Preview */}
<div className="ai-image-code-preview">
<div className="ai-image-code-block">
<span className="ai-image-code-label">Markdown:</span>
<code>{getMarkdownCode(generatedImage.url, generatedImage.prompt)}</code>
</div>
<div className="ai-image-code-block">
<span className="ai-image-code-label">HTML:</span>
<code>{getHtmlCode(generatedImage.url, generatedImage.prompt)}</code>
</div>
</div>
</div> </div>
)} )}
@@ -4478,11 +4635,6 @@ function IndexHtmlSection({
Path to favicon (e.g., /favicon.svg) Path to favicon (e.g., /favicon.svg)
</span> </span>
</div> </div>
</div>
{/* Theme and Appearance */}
<div className="dashboard-config-card">
<h3>Theme and Appearance</h3>
<div className="config-field"> <div className="config-field">
<label>Theme Color</label> <label>Theme Color</label>
<input <input
@@ -4491,7 +4643,7 @@ function IndexHtmlSection({
onChange={(e) => handleChange("themeColor", e.target.value)} onChange={(e) => handleChange("themeColor", e.target.value)}
/> />
<span className="config-field-note"> <span className="config-field-note">
Used in theme-color meta tag for mobile browsers Mobile browser chrome color (theme-color meta tag)
</span> </span>
</div> </div>
</div> </div>
@@ -4637,6 +4789,9 @@ function ConfigSection({
semanticSearchEnabled: siteConfig.semanticSearch?.enabled || false, semanticSearchEnabled: siteConfig.semanticSearch?.enabled || false,
// Ask AI // Ask AI
askAIEnabled: siteConfig.askAI?.enabled || false, askAIEnabled: siteConfig.askAI?.enabled || false,
// Media library
mediaEnabled: siteConfig.media?.enabled || false,
mediaMaxFileSize: siteConfig.media?.maxFileSize || 10,
}); });
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
@@ -4814,6 +4969,15 @@ export const siteConfig: SiteConfig = {
askAI: { askAI: {
enabled: ${config.askAIEnabled}, enabled: ${config.askAIEnabled},
}, },
// Media library configuration
// Upload and manage images via ConvexFS and Bunny.net CDN
// Requires BUNNY_API_KEY, BUNNY_STORAGE_ZONE, BUNNY_CDN_HOSTNAME in Convex dashboard
media: {
enabled: ${config.mediaEnabled},
maxFileSize: ${config.mediaMaxFileSize},
allowedTypes: ["image/png", "image/jpeg", "image/gif", "image/webp"],
},
}; };
export default siteConfig; export default siteConfig;
@@ -5677,6 +5841,36 @@ export default siteConfig;
</p> </p>
</div> </div>
{/* Media Library */}
<div className="dashboard-config-card">
<h3>Media Library</h3>
<div className="config-field checkbox">
<label>
<input
type="checkbox"
checked={config.mediaEnabled}
onChange={(e) =>
handleChange("mediaEnabled", e.target.checked)
}
/>
<span>Enable media library</span>
</label>
</div>
<div className="config-field">
<label>Max File Size (MB)</label>
<input
type="number"
value={config.mediaMaxFileSize}
onChange={(e) => handleChange("mediaMaxFileSize", parseInt(e.target.value) || 10)}
min={1}
max={50}
/>
</div>
<p className="config-hint">
Upload and manage images via ConvexFS and Bunny.net CDN. Requires BUNNY_API_KEY, BUNNY_STORAGE_ZONE, and BUNNY_CDN_HOSTNAME in Convex dashboard.
</p>
</div>
{/* Version Control */} {/* Version Control */}
<VersionControlCard addToast={addToast} /> <VersionControlCard addToast={addToast} />

File diff suppressed because it is too large Load Diff