mirror of
https://github.com/waynesutton/markdown-site.git
synced 2026-01-11 20:08:57 +00:00
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:
96
.opencode/agent/content-writer.md
Normal file
96
.opencode/agent/content-writer.md
Normal 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
|
||||
72
.opencode/agent/orchestrator.md
Normal file
72
.opencode/agent/orchestrator.md
Normal 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
|
||||
105
.opencode/agent/sync-manager.md
Normal file
105
.opencode/agent/sync-manager.md
Normal 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
|
||||
70
.opencode/command/create-page.md
Normal file
70
.opencode/command/create-page.md
Normal 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
|
||||
```
|
||||
60
.opencode/command/create-post.md
Normal file
60
.opencode/command/create-post.md
Normal 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
|
||||
70
.opencode/command/deploy.md
Normal file
70
.opencode/command/deploy.md
Normal 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
|
||||
62
.opencode/command/import.md
Normal file
62
.opencode/command/import.md
Normal 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.
|
||||
44
.opencode/command/sync-prod.md
Normal file
44
.opencode/command/sync-prod.md
Normal 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
42
.opencode/command/sync.md
Normal 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
6
.opencode/config.json
Normal 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"
|
||||
}
|
||||
47
.opencode/plugin/sync-helper.ts
Normal file
47
.opencode/plugin/sync-helper.ts
Normal 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
187
.opencode/skill/content.md
Normal 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
|
||||

|
||||
```
|
||||
|
||||
### External images
|
||||
|
||||
Use full URLs:
|
||||
|
||||
```markdown
|
||||

|
||||
```
|
||||
|
||||
### 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
192
.opencode/skill/convex.md
Normal 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
|
||||
194
.opencode/skill/frontmatter.md
Normal file
194
.opencode/skill/frontmatter.md
Normal 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
126
.opencode/skill/sync.md
Normal 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
|
||||
@@ -19,10 +19,10 @@ Your content is instantly available to browsers, LLMs, and AI agents.. Write mar
|
||||
- **Site Name**: markdown
|
||||
- **Site Title**: markdown sync framework
|
||||
- **Site URL**: https://yoursite.example.com
|
||||
- **Total Posts**: 18
|
||||
- **Total Posts**: 19
|
||||
- **Total Pages**: 4
|
||||
- **Latest Post**: 2026-01-07
|
||||
- **Last Updated**: 2026-01-09T07:02:12.472Z
|
||||
- **Latest Post**: 2026-01-10
|
||||
- **Last Updated**: 2026-01-10T23:49:21.881Z
|
||||
|
||||
## Tech stack
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ Project instructions for Claude Code.
|
||||
## Project context
|
||||
|
||||
<!-- 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.
|
||||
|
||||
|
||||
500
README.md
500
README.md
@@ -43,13 +43,18 @@ npm run sync # dev
|
||||
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
|
||||
|
||||
- **[Setup Guide](https://www.markdown.fast/setup-guide)** - Complete fork and deployment guide
|
||||
- **[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
|
||||
Full documentation is available at **[markdown.fast/docs](https://www.markdown.fast/docs)**
|
||||
|
||||
### 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
|
||||
|
||||
@@ -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
|
||||
- **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:
|
||||
- `frontmatter.md` - Complete frontmatter syntax and all field options
|
||||
- `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.
|
||||
|
||||
## 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
|
||||
|
||||
After forking this project, you have two options to configure your site:
|
||||
|
||||
### Option 1: Automated (Recommended)
|
||||
|
||||
Run a single command to configure all files automatically:
|
||||
After forking, run the automated configuration:
|
||||
|
||||
```bash
|
||||
# Copy the example config
|
||||
cp fork-config.json.example fork-config.json
|
||||
|
||||
# Edit with your site information
|
||||
# Open fork-config.json and update the values
|
||||
|
||||
# Apply all changes
|
||||
# Edit fork-config.json with your site info
|
||||
npm run configure
|
||||
```
|
||||
|
||||
This updates all 11 configuration files in one step.
|
||||
|
||||
### 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.
|
||||
See the [Fork Configuration Guide](https://www.markdown.fast/fork-configuration-guide) for detailed instructions.
|
||||
|
||||
## Getting Started
|
||||
|
||||
@@ -234,370 +119,31 @@ npm run dev
|
||||
|
||||
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
|
||||
|
||||
### Netlify
|
||||
|
||||
[](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:
|
||||
|
||||
```bash
|
||||
npx convex deploy
|
||||
```
|
||||
|
||||
Note the production URL (e.g., `https://your-deployment.convex.cloud`).
|
||||
|
||||
2. Connect your repository to Netlify
|
||||
3. Configure build settings:
|
||||
- Build command: `npm ci --include=dev && npx convex deploy --cmd 'npm run build'`
|
||||
- Publish directory: `dist`
|
||||
4. Add environment variables in Netlify dashboard:
|
||||
- `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.
|
||||
|
||||
**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 |
|
||||
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.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- React 18
|
||||
- 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)
|
||||
React 18, TypeScript, Vite, Convex, Netlify
|
||||
|
||||
## Source
|
||||
|
||||
|
||||
44
TASK.md
44
TASK.md
@@ -4,10 +4,52 @@
|
||||
|
||||
## 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
|
||||
|
||||
- [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] Added social icons to MobileMenu below navigation links
|
||||
- [x] Removed social icons from mobile header (now only in hamburger menu)
|
||||
|
||||
102
changelog.md
102
changelog.md
@@ -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/).
|
||||
|
||||
## [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 (``) 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
|
||||
|
||||
### Added
|
||||
|
||||
195
content/blog/docs-opencode.md
Normal file
195
content/blog/docs-opencode.md
Normal 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)
|
||||
@@ -11,6 +11,114 @@ docsSectionOrder: 4
|
||||
|
||||
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 (``) 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
|
||||
|
||||
Released January 10, 2026
|
||||
|
||||
234
content/pages/docs-media-setup.md
Normal file
234
content/pages/docs-media-setup.md
Normal 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.
|
||||
568
convex/_generated/api.d.ts
vendored
568
convex/_generated/api.d.ts
vendored
@@ -18,6 +18,8 @@ import type * as contactActions from "../contactActions.js";
|
||||
import type * as crons from "../crons.js";
|
||||
import type * as embeddings from "../embeddings.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 importAction from "../importAction.js";
|
||||
import type * as newsletter from "../newsletter.js";
|
||||
@@ -48,6 +50,8 @@ declare const fullApi: ApiFromModules<{
|
||||
crons: typeof crons;
|
||||
embeddings: typeof embeddings;
|
||||
embeddingsQueries: typeof embeddingsQueries;
|
||||
files: typeof files;
|
||||
fs: typeof fs;
|
||||
http: typeof http;
|
||||
importAction: typeof importAction;
|
||||
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
|
||||
>;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { defineApp } from "convex/server";
|
||||
import aggregate from "@convex-dev/aggregate/convex.config.js";
|
||||
import persistentTextStreaming from "@convex-dev/persistent-text-streaming/convex.config";
|
||||
import fs from "convex-fs/convex.config.js";
|
||||
|
||||
const app = defineApp();
|
||||
|
||||
@@ -16,5 +17,8 @@ app.use(aggregate, { name: "uniqueVisitors" });
|
||||
// Persistent text streaming for real-time AI responses in Ask AI feature
|
||||
app.use(persistentTextStreaming);
|
||||
|
||||
// ConvexFS for file storage with Bunny CDN
|
||||
app.use(fs);
|
||||
|
||||
export default app;
|
||||
|
||||
|
||||
222
convex/files.ts
Normal file
222
convex/files.ts
Normal 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
34
convex/fs.ts
Normal 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;
|
||||
@@ -1,8 +1,10 @@
|
||||
import { httpRouter } from "convex/server";
|
||||
import { httpAction } from "./_generated/server";
|
||||
import { api } from "./_generated/api";
|
||||
import { api, components } from "./_generated/api";
|
||||
import { rssFeed, rssFullFeed } from "./rss";
|
||||
import { streamResponse, streamResponseOptions } from "./askAI.node";
|
||||
import { registerRoutes } from "convex-fs";
|
||||
import { fs } from "./fs";
|
||||
|
||||
const http = httpRouter();
|
||||
|
||||
@@ -414,4 +416,24 @@ http.route({
|
||||
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;
|
||||
|
||||
55
files.md
55
files.md
@@ -11,7 +11,7 @@ A brief description of each file in the codebase.
|
||||
| `vite.config.ts` | Vite bundler configuration |
|
||||
| `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 |
|
||||
| `README.md` | Project documentation |
|
||||
| `README.md` | Project documentation (streamlined with links to docs)|
|
||||
| `AGENTS.md` | AI coding agent instructions (agents.md spec) |
|
||||
| `CLAUDE.md` | Claude Code instructions for project workflows |
|
||||
| `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 |
|
||||
| `AuthorPage.tsx` | Author archive page displaying posts by a specific author. Includes view mode toggle (list/cards) with localStorage persistence. Author name clickable in posts links to this page. |
|
||||
| `Write.tsx` | Three-column markdown writing page with Cursor docs-style UI, frontmatter reference with copy buttons, theme toggle, font switcher (serif/sans/monospace), localStorage persistence, and optional AI Agent mode (toggleable via siteConfig.aiChat.enabledOnWritePage). When enabled, Agent replaces the textarea with AIChatView component. Includes scroll prevention when switching to Agent mode to prevent page jump. Title changes to "Agent" when in AI chat mode. |
|
||||
| `Dashboard.tsx` | Centralized dashboard at `/dashboard` for content management and site configuration. **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. |
|
||||
| `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. |
|
||||
| `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. |
|
||||
| `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/`)
|
||||
|
||||
@@ -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. |
|
||||
| `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. |
|
||||
| `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 |
|
||||
|
||||
### 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 |
|
||||
| `task.mdc` | Task list management guidelines |
|
||||
| `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
5
opencode.json
Normal 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
50
package-lock.json
generated
@@ -22,6 +22,7 @@
|
||||
"@workos-inc/widgets": "^1.6.1",
|
||||
"agentmail": "^0.1.15",
|
||||
"convex": "^1.17.4",
|
||||
"convex-fs": "^0.2.0",
|
||||
"date-fns": "^3.3.1",
|
||||
"diff": "^8.0.2",
|
||||
"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": {
|
||||
"version": "0.7.2",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
|
||||
@@ -5798,7 +5847,6 @@
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
|
||||
"integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-levenshtein": {
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
"@workos-inc/widgets": "^1.6.1",
|
||||
"agentmail": "^0.1.15",
|
||||
"convex": "^1.17.4",
|
||||
"convex-fs": "^0.2.0",
|
||||
"date-fns": "^3.3.1",
|
||||
"diff": "^8.0.2",
|
||||
"gray-matter": "^4.0.3",
|
||||
|
||||
BIN
public/images/opencode.png
Normal file
BIN
public/images/opencode.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 423 KiB |
@@ -1,6 +1,6 @@
|
||||
# llms.txt - Information for AI assistants and LLMs
|
||||
# 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.
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
- 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.
|
||||
- Topics: Markdown, Convex, React, TypeScript, Netlify, Open Source, AI, LLM, AEO, GEO
|
||||
- Total Posts: 18
|
||||
- Latest Post: 2026-01-07
|
||||
- Total Posts: 19
|
||||
- Latest Post: 2026-01-10
|
||||
- GitHub: https://github.com/waynesutton/markdown-site
|
||||
|
||||
# API Endpoints
|
||||
|
||||
@@ -7,6 +7,91 @@ Date: 2026-01-10
|
||||
|
||||
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 (``) 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
|
||||
|
||||
Released January 10, 2026
|
||||
|
||||
229
public/raw/docs-media-setup.md
Normal file
229
public/raw/docs-media-setup.md
Normal 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
180
public/raw/docs-opencode.md
Normal 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)
|
||||
@@ -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.
|
||||
- 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.
|
||||
@@ -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.
|
||||
- Date: 2025-12-14 | Reading time: 4 min read | Tags: images, tutorial, markdown, open-graph
|
||||
|
||||
## Pages (16)
|
||||
## Pages (17)
|
||||
|
||||
- **[Footer](/raw/footer.md)**
|
||||
- **[Home Intro](/raw/home-intro.md)**
|
||||
@@ -89,10 +91,11 @@ agents. -->
|
||||
- **[Changelog](/raw/changelog.md)**
|
||||
- **[Deployment](/raw/docs-deployment.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`
|
||||
|
||||
|
||||
553
src/components/ImageUploadModal.tsx
Normal file
553
src/components/ImageUploadModal.tsx
Normal 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 ``;
|
||||
}
|
||||
|
||||
// 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>
|
||||
);
|
||||
}
|
||||
509
src/components/MediaLibrary.tsx
Normal file
509
src/components/MediaLibrary.tsx
Normal 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 = ``;
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -235,6 +235,14 @@ export interface DashboardConfig {
|
||||
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
|
||||
// Enables click-to-magnify functionality for images in blog posts and pages
|
||||
export interface ImageLightboxConfig {
|
||||
@@ -388,6 +396,9 @@ export interface SiteConfig {
|
||||
// Dashboard configuration (optional)
|
||||
dashboard?: DashboardConfig;
|
||||
|
||||
// Media library configuration (optional)
|
||||
media?: MediaConfig;
|
||||
|
||||
// Image lightbox configuration (optional)
|
||||
imageLightbox?: ImageLightboxConfig;
|
||||
|
||||
@@ -735,6 +746,15 @@ export const siteConfig: SiteConfig = {
|
||||
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
|
||||
// Enables click-to-magnify functionality for images in blog posts and pages
|
||||
// Images open in a full-screen lightbox overlay when clicked
|
||||
|
||||
@@ -66,6 +66,8 @@ import {
|
||||
import siteConfig from "../config/siteConfig";
|
||||
import AIChatView from "../components/AIChatView";
|
||||
import VersionHistoryModal from "../components/VersionHistoryModal";
|
||||
import { MediaLibrary } from "../components/MediaLibrary";
|
||||
import { ImageUploadModal } from "../components/ImageUploadModal";
|
||||
import { isWorkOSConfigured } from "../utils/workos";
|
||||
// Always import auth components - they're only used when WorkOS is configured
|
||||
import {
|
||||
@@ -391,7 +393,8 @@ type DashboardSection =
|
||||
| "config"
|
||||
| "index-html"
|
||||
| "stats"
|
||||
| "sync";
|
||||
| "sync"
|
||||
| "media";
|
||||
|
||||
// Post/Page type for editing
|
||||
interface ContentItem {
|
||||
@@ -1029,6 +1032,10 @@ function DashboardContent() {
|
||||
}, [editingItem, editingType, generateMarkdown, addToast]);
|
||||
|
||||
// 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 = [
|
||||
{
|
||||
label: "Content",
|
||||
@@ -1044,34 +1051,47 @@ function DashboardContent() {
|
||||
{ id: "write-page" as const, label: "Write Page", icon: File },
|
||||
{ id: "ai-agent" as const, label: "AI Agent", icon: Robot },
|
||||
{ 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 }]
|
||||
: []),
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Newsletter",
|
||||
items: [
|
||||
{ id: "newsletter" as const, label: "Subscribers", icon: Envelope },
|
||||
{
|
||||
id: "newsletter-send" as const,
|
||||
label: "Send Newsletter",
|
||||
icon: Envelope,
|
||||
},
|
||||
{
|
||||
id: "newsletter-write-email" as const,
|
||||
label: "Write Email",
|
||||
icon: PencilSimple,
|
||||
},
|
||||
{
|
||||
id: "newsletter-recent-sends" as const,
|
||||
label: "Recent Sends",
|
||||
icon: ClockCounterClockwise,
|
||||
},
|
||||
{
|
||||
id: "newsletter-stats" as const,
|
||||
label: "Email Stats",
|
||||
icon: ChartLine,
|
||||
},
|
||||
],
|
||||
},
|
||||
// Only show Newsletter section if newsletter is enabled
|
||||
...(newsletterEnabled
|
||||
? [
|
||||
{
|
||||
label: "Newsletter",
|
||||
items: [
|
||||
{
|
||||
id: "newsletter" as const,
|
||||
label: "Subscribers",
|
||||
icon: Envelope,
|
||||
},
|
||||
{
|
||||
id: "newsletter-send" as const,
|
||||
label: "Send Newsletter",
|
||||
icon: Envelope,
|
||||
},
|
||||
{
|
||||
id: "newsletter-write-email" as const,
|
||||
label: "Write Email",
|
||||
icon: PencilSimple,
|
||||
},
|
||||
{
|
||||
id: "newsletter-recent-sends" as const,
|
||||
label: "Recent Sends",
|
||||
icon: ClockCounterClockwise,
|
||||
},
|
||||
{
|
||||
id: "newsletter-stats" as const,
|
||||
label: "Email Stats",
|
||||
icon: ChartLine,
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
label: "Settings",
|
||||
items: [
|
||||
@@ -1246,6 +1266,7 @@ function DashboardContent() {
|
||||
{activeSection === "index-html" && "Index HTML"}
|
||||
{activeSection === "stats" && "Analytics"}
|
||||
{activeSection === "sync" && "Sync Content"}
|
||||
{activeSection === "media" && "Media"}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
@@ -1463,6 +1484,9 @@ function DashboardContent() {
|
||||
setSyncOutput={setSyncOutput}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Media */}
|
||||
{activeSection === "media" && <MediaLibrary />}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
@@ -2540,6 +2564,9 @@ function WriteSection({
|
||||
});
|
||||
// Store previous sidebar state before entering focus mode
|
||||
const [prevSidebarState, setPrevSidebarState] = useState<boolean | null>(null);
|
||||
// Image upload modal state
|
||||
const [showImageUpload, setShowImageUpload] = useState(false);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
// Toggle focus mode
|
||||
const toggleFocusMode = useCallback(() => {
|
||||
@@ -2693,6 +2720,33 @@ function WriteSection({
|
||||
}
|
||||
}, [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
|
||||
const handleCopyField = useCallback(
|
||||
async (fieldName: string, example: string) => {
|
||||
@@ -2950,6 +3004,16 @@ published: false
|
||||
)}
|
||||
<span>{copied ? "Copied" : "Copy All"}</span>
|
||||
</button>
|
||||
{siteConfig.media?.enabled && (
|
||||
<button
|
||||
onClick={() => setShowImageUpload(true)}
|
||||
className="dashboard-action-btn"
|
||||
title="Insert Image"
|
||||
>
|
||||
<Image size={16} />
|
||||
<span>Image</span>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={handleDownloadMarkdown}
|
||||
className="dashboard-action-btn primary"
|
||||
@@ -2990,6 +3054,7 @@ published: false
|
||||
<div className="dashboard-write-main">
|
||||
{editorMode === "markdown" && (
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
className="dashboard-write-textarea"
|
||||
@@ -3101,6 +3166,15 @@ published: false
|
||||
avoid losing work.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Image Upload Modal - only when media is enabled */}
|
||||
{siteConfig.media?.enabled && (
|
||||
<ImageUploadModal
|
||||
isOpen={showImageUpload}
|
||||
onClose={() => setShowImageUpload(false)}
|
||||
onInsert={handleInsertImage}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3120,6 +3194,7 @@ function AIAgentSection() {
|
||||
const [imageError, setImageError] = useState<string | null>(null);
|
||||
const [showImageModelDropdown, setShowImageModelDropdown] = useState(false);
|
||||
const [showTextModelDropdown, setShowTextModelDropdown] = useState(false);
|
||||
const [copiedFormat, setCopiedFormat] = useState<"md" | "html" | null>(null);
|
||||
|
||||
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 selectedImageModelName = imageModels.find(m => m.id === selectedImageModel)?.name || "Nano Banana";
|
||||
|
||||
// Generate markdown code for the image
|
||||
const getMarkdownCode = (url: string, prompt: string) => ``;
|
||||
|
||||
// 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 (
|
||||
<div className="dashboard-ai-section">
|
||||
{/* Tabs */}
|
||||
@@ -3277,6 +3394,46 @@ function AIAgentSection() {
|
||||
<div className="ai-generated-image">
|
||||
<img src={generatedImage.url} alt={generatedImage.prompt} />
|
||||
<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>
|
||||
)}
|
||||
|
||||
@@ -4478,11 +4635,6 @@ function IndexHtmlSection({
|
||||
Path to favicon (e.g., /favicon.svg)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Theme and Appearance */}
|
||||
<div className="dashboard-config-card">
|
||||
<h3>Theme and Appearance</h3>
|
||||
<div className="config-field">
|
||||
<label>Theme Color</label>
|
||||
<input
|
||||
@@ -4491,7 +4643,7 @@ function IndexHtmlSection({
|
||||
onChange={(e) => handleChange("themeColor", e.target.value)}
|
||||
/>
|
||||
<span className="config-field-note">
|
||||
Used in theme-color meta tag for mobile browsers
|
||||
Mobile browser chrome color (theme-color meta tag)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -4637,6 +4789,9 @@ function ConfigSection({
|
||||
semanticSearchEnabled: siteConfig.semanticSearch?.enabled || false,
|
||||
// Ask AI
|
||||
askAIEnabled: siteConfig.askAI?.enabled || false,
|
||||
// Media library
|
||||
mediaEnabled: siteConfig.media?.enabled || false,
|
||||
mediaMaxFileSize: siteConfig.media?.maxFileSize || 10,
|
||||
});
|
||||
|
||||
const [copied, setCopied] = useState(false);
|
||||
@@ -4814,6 +4969,15 @@ export const siteConfig: SiteConfig = {
|
||||
askAI: {
|
||||
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;
|
||||
@@ -5677,6 +5841,36 @@ export default siteConfig;
|
||||
</p>
|
||||
</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 */}
|
||||
<VersionControlCard addToast={addToast} />
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user