feat: Added a Sync version control system for tracking changes to posts, pages, home content, and footer.

This commit is contained in:
Wayne Sutton
2026-01-09 23:02:28 -08:00
parent 1323928341
commit 03bf3e49e5
38 changed files with 1530 additions and 87 deletions

View File

@@ -83,7 +83,7 @@ Edit each file individually following the guide below.
### Files to Update
| File | What to Update |
| ----------------------------------- | ------------------------------------------------------------ |
| ----------------------------------- | --------------------------------------------------------------------------- |
| `src/config/siteConfig.ts` | Site name, bio, GitHub username, gitHubRepo config, default theme, features |
| `src/pages/Home.tsx` | Intro paragraph, footer links |
| `src/pages/Post.tsx` | `SITE_URL`, `SITE_NAME` constants |
@@ -750,6 +750,7 @@ The dashboard includes a sync server feature that allows executing sync commands
**Setup:**
1. Start the sync server locally:
```bash
npm run sync-server
```
@@ -1141,13 +1142,29 @@ Configure the AI writing assistant. The Dashboard AI Agent supports multiple pro
"enableImageGeneration": true,
"defaultTextModel": "claude-sonnet-4-20250514",
"textModels": [
{ "id": "claude-sonnet-4-20250514", "name": "Claude Sonnet 4", "provider": "anthropic" },
{
"id": "claude-sonnet-4-20250514",
"name": "Claude Sonnet 4",
"provider": "anthropic"
},
{ "id": "gpt-4o", "name": "GPT-4o", "provider": "openai" },
{ "id": "gemini-2.0-flash", "name": "Gemini 2.0 Flash", "provider": "google" }
{
"id": "gemini-2.0-flash",
"name": "Gemini 2.0 Flash",
"provider": "google"
}
],
"imageModels": [
{ "id": "gemini-2.0-flash-exp-image-generation", "name": "Nano Banana", "provider": "google" },
{ "id": "imagen-3.0-generate-002", "name": "Nano Banana Pro", "provider": "google" }
{
"id": "gemini-2.0-flash-exp-image-generation",
"name": "Nano Banana",
"provider": "google"
},
{
"id": "imagen-3.0-generate-002",
"name": "Nano Banana Pro",
"provider": "google"
}
]
}
}
@@ -1180,7 +1197,7 @@ aiDashboard: {
**Environment Variables (Convex):**
| Variable | Provider | Features |
| --- | --- | --- |
| ------------------- | --------- | ---------------------------------------- |
| `ANTHROPIC_API_KEY` | Anthropic | Claude Sonnet 4 chat |
| `OPENAI_API_KEY` | OpenAI | GPT-4o chat |
| `GOOGLE_AI_API_KEY` | Google | Gemini 2.0 Flash chat + image generation |
@@ -1348,7 +1365,7 @@ The script reads from `siteConfig.ts` and queries Convex for live content statis
Replace example content in:
| File | Purpose |
| ------------------------------ | -------------------------- |
| ------------------------------ | -------------------------------------------------------------------------------------------- |
| `content/blog/*.md` | Blog posts |
| `content/pages/*.md` | Static pages (About, etc.) |
| `content/pages/home.md` | Homepage intro content (slug: `home-intro`, uses blog heading styles) |
@@ -1368,7 +1385,7 @@ The site serves pre-rendered HTML with correct canonical URLs and meta tags to s
The edge function detects different types of bots and serves appropriate responses:
| Bot Type | Response | Examples |
| ------------------- | ------------------------------------- | ------------------------------------ |
| ------------------- | ---------------------------------------- | ------------------------------------ |
| Social preview bots | Pre-rendered HTML with OG tags | Twitter, Facebook, LinkedIn, Discord |
| Search engine bots | Pre-rendered HTML with correct canonical | Google, Bing, DuckDuckGo |
| AI crawlers | Normal SPA (can render JavaScript) | GPTBot, ClaudeBot, PerplexityBot |
@@ -1417,3 +1434,54 @@ curl https://yoursite.com/your-post | grep canonical
### Why This Matters
Single-page apps (SPAs) update meta tags via JavaScript after the page loads. Search engines that check raw HTML before rendering may see incorrect canonical URLs. By serving pre-rendered HTML to search engine bots, we ensure they see the correct canonical URL for each page.
---
## Version Control Configuration
The dashboard includes a built-in Sync version control system. Unlike most features, version control is configured via the Dashboard UI, not `siteConfig.ts` or `fork-config.json`.
### How to enable
1. Navigate to `/dashboard`
2. Go to the **Config** section
3. Find the **Version Control** card
4. Toggle **Enable version control** on
### Features
- **3-day version history** for all posts, pages, home content, and footer
- **Diff visualization** using unified diff format
- **One-click restore** with automatic backup of current content
- **Automatic cleanup** of versions older than 3 days (runs daily at 3 AM UTC)
### When versions are captured
| Source | When created |
| --------- | --------------------------------------------- |
| sync | Before markdown sync updates (`npm run sync`) |
| dashboard | Before saving edits in Dashboard |
| restore | Before restoring a previous version |
### Viewing version history
1. Open any post or page in the Dashboard editor
2. Click the **History** button (clock icon) in the editor toolbar
3. Select a version from the list
4. View diff or preview
5. Click **Restore This Version** to revert
### Technical details
- Versions stored in `contentVersions` table in Convex
- Settings stored in `versionControlSettings` table
- Cleanup via cron job in `convex/crons.ts`
- Version capture is async (non-blocking via `ctx.scheduler.runAfter`)
### Why database-based?
Version control settings are stored in the Convex database rather than config files because:
1. **Toggle requires real-time state** - UI needs to reflect current setting immediately
2. **Shared across environments** - Same setting for all users of the dashboard
3. **No redeploy needed** - Toggle works instantly without rebuilding

15
TASK.md
View File

@@ -4,10 +4,23 @@
## Current Status
v2.15.3 ready. Fixed footer not displaying on docs landing page.
v2.16.0 ready. Added version control system for posts and pages.
## Completed
- [x] Version control system (v2.16.0)
- [x] Added contentVersions and versionControlSettings tables to schema
- [x] Created convex/versions.ts with 7 functions (isEnabled, setEnabled, createVersion, getVersionHistory, getVersion, restoreVersion, cleanupOldVersions, getStats)
- [x] Modified cms.ts to capture versions before dashboard edits
- [x] Modified posts.ts to capture versions before sync updates
- [x] Modified pages.ts to capture versions before sync updates
- [x] Added cleanup cron job (daily at 3 AM UTC) for 3-day retention
- [x] Created VersionHistoryModal component with diff view and restore functionality
- [x] Added Version Control card in Dashboard Config section with toggle and stats
- [x] Added History button in Dashboard editor for viewing version history
- [x] Added ~370 lines of CSS for version modal UI
- [x] Updated documentation: docs-dashboard.md, FORK_CONFIG.md, files.md, changelog.md, task.md, changelog-page.md
- [x] Footer not displaying on /docs landing page fix (v2.15.3)
- [x] DocsPage.tsx was missing Footer component entirely
- [x] Added Footer import and footerPage query to DocsPage.tsx

View File

@@ -4,6 +4,41 @@ 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.16.0] - 2026-01-09
### Added
- Sync version control system
- 3-day version history for posts, pages, home content, and footer
- Dashboard toggle to enable/disable version control
- Version history modal with unified diff visualization using DiffCodeBlock component
- Preview mode to view previous version content
- One-click restore with automatic backup of current state
- Automatic cleanup of versions older than 3 days (daily cron at 3 AM UTC)
- Version stats display in Config section (total, posts, pages)
### Technical
- New `convex/versions.ts` with 7 functions:
- `isEnabled` / `setEnabled` - Toggle version control
- `createVersion` - Capture content snapshot (internal mutation)
- `getVersionHistory` / `getVersion` - Query version data
- `restoreVersion` - Restore with backup creation
- `cleanupOldVersions` - Batch delete old versions
- `getStats` - Version count statistics
- New `contentVersions` table in schema with indexes:
- `by_content` - Query by content type and ID
- `by_slug` - Query by content type and slug
- `by_createdAt` - For cleanup queries
- `by_content_createdAt` - Compound index for history
- New `versionControlSettings` table for toggle state
- New `src/components/VersionHistoryModal.tsx` component
- Updated `convex/cms.ts` to capture versions before dashboard edits
- Updated `convex/posts.ts` to capture versions before sync updates
- Updated `convex/pages.ts` to capture versions before sync updates
- Updated `convex/crons.ts` with daily cleanup job
- Added ~370 lines of CSS for version modal UI
## [2.15.3] - 2026-01-09
### Fixed
@@ -133,7 +168,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Unified and split (side-by-side) view modes with toggle button
- Theme-aware colors (dark/light/tan/cloud support)
- Copy button for diff content
- Automatic routing: ```diff and ```patch blocks use enhanced renderer
- Automatic routing: `diff and `patch blocks use enhanced renderer
- New blog post: "How to Use Code Blocks" with syntax highlighting and diff examples
- DiffCodeBlock component (`src/components/DiffCodeBlock.tsx`)
@@ -624,7 +659,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
### Technical
- Updated: `scripts/configure-fork.ts` - Added ES module compatible __dirname using fileURLToPath
- Updated: `scripts/configure-fork.ts` - Added ES module compatible \_\_dirname using fileURLToPath
## [2.2.0] - 2025-12-30

View File

@@ -7,7 +7,7 @@ published: true
featured: true
featuredOrder: 5
layout: "sidebar"
blogFeatured: true
blogFeatured: false
image: /images/agentmail-blog.png
tags: ["agentmail", "newsletter", "email", "setup"]
docsSection: true

View File

@@ -5,7 +5,7 @@ date: "2025-12-28"
slug: "how-to-use-mcp-server"
image: /images/mcp-blog.png
published: true
blogFeatured: true
blogFeatured: false
layout: "sidebar"
tags: ["mcp", "cursor", "ai", "tutorial", "netlify"]
docsSection: true

View File

@@ -9,6 +9,7 @@ readTime: "8 min read"
featured: true
layout: "sidebar"
featuredOrder: 2
blogFeatured: true
image: /images/dashboard.png
excerpt: "A complete guide to using the dashboard for managing your markdown blog without leaving your browser."
docsSection: true

View File

@@ -11,6 +11,56 @@ docsSectionOrder: 4
All notable changes to this project.
## v2.16.0
Released January 9, 2026
**Version control system**
Added a Sync version control system for tracking changes to posts, pages, home content, and footer.
**Features:**
- 3-day version history for all content
- Dashboard toggle to enable/disable version control
- Version history modal with unified diff visualization
- Preview mode to view previous version content
- One-click restore with automatic backup of current state
- Automatic cleanup of versions older than 3 days (daily cron at 3 AM UTC)
- Version stats display in Config section
**How to use:**
1. Navigate to Dashboard > Config
2. Find the "Version Control" card
3. Toggle "Enable version control" on
4. Edit posts/pages or run sync commands to capture versions
5. Click the History button in the editor to view version history
6. Select a version to view diff or preview, then click "Restore This Version"
**Technical:**
- New `convex/versions.ts` with 7 functions (isEnabled, setEnabled, createVersion, getVersionHistory, getVersion, restoreVersion, cleanupOldVersions, getStats)
- New `contentVersions` table with indexes for efficient queries
- New `versionControlSettings` table for toggle state
- New `VersionHistoryModal.tsx` component using existing DiffCodeBlock
- Version capture integrated into cms.ts, posts.ts, and pages.ts
- Cleanup cron job in crons.ts
**Files changed:**
- `convex/schema.ts` - Added contentVersions and versionControlSettings tables
- `convex/versions.ts` - New file with all version control logic
- `convex/cms.ts` - Added version capture before dashboard edits
- `convex/posts.ts` - Added version capture before sync updates
- `convex/pages.ts` - Added version capture before sync updates
- `convex/crons.ts` - Added daily cleanup job
- `src/components/VersionHistoryModal.tsx` - New version history modal
- `src/pages/Dashboard.tsx` - Added Version Control config card and History button
- `src/styles/global.css` - Added ~370 lines of version modal CSS
---
## v2.15.3
Released January 9, 2026
@@ -208,7 +258,7 @@ Diff and patch code blocks now render with enhanced visualization powered by @pi
- View toggle button to switch between modes
- Theme-aware colors matching dark/light/tan/cloud themes
- Copy button for copying raw diff content
- Automatic routing: Use ```diff or ```patch in markdown
- Automatic routing: Use `diff or `patch in markdown
**New documentation:**
@@ -351,6 +401,7 @@ semanticSearch: {
```
When disabled:
- Search modal shows only keyword search (no mode toggle)
- Embedding generation skipped during sync (saves API costs)
- Existing embeddings preserved in database (no data loss)
@@ -380,7 +431,7 @@ Search now supports two modes accessible via Cmd+K:
**When to use each mode:**
| Use Case | Mode |
|----------|------|
| ----------------------------------------- | -------- |
| Specific code, commands, exact phrases | Keyword |
| Conceptual questions ("how do I deploy?") | Semantic |
| Need to highlight matches on page | Keyword |

View File

@@ -2,7 +2,7 @@
title: "Dashboard"
slug: "docs-dashboard"
published: true
order: 5
order: 1
showInNav: false
layout: "sidebar"
rightSidebar: true
@@ -375,6 +375,63 @@ This creates a file in `content/blog/` that requires syncing.
**Note:** The dashboard provides a UI for these commands. When the sync server is running (`npm run sync-server`), you can execute commands directly from the dashboard with real-time output. Otherwise, the dashboard shows commands in a modal for copying to your terminal.
### Version control
The dashboard includes a Sync version control system that tracks changes to posts, pages, home content, and footer content.
**Features:**
- 3-day version history for all content
- Toggle to enable/disable version control
- View version history with unified diff visualization
- Preview previous versions
- One-click restore with automatic backup
- Automatic cleanup of versions older than 3 days
**Enabling version control:**
1. Navigate to Dashboard > Config
2. Find the "Version Control" card
3. Toggle "Enable version control" on
When enabled, versions are captured:
- Before sync updates (from markdown files)
- Before dashboard edits (Save Changes button)
- Before restoring a previous version
**Viewing version history:**
1. Open any post or page in the Dashboard editor
2. Click the clock (History) button in the editor toolbar
3. Select a version from the list to view details
4. Toggle between "Diff" and "Preview" modes
5. Click "Restore This Version" to revert
**How it works:**
- Versions are stored in the `contentVersions` table
- Settings stored in `versionControlSettings` table (database, not config file)
- Cleanup runs daily at 3:00 AM UTC via cron job
- Restore creates a backup of current content before reverting
- Uses existing DiffCodeBlock component for diff visualization
**Version sources:**
| Source | When created |
| --------- | ---------------------------- |
| sync | Before markdown sync updates |
| dashboard | Before dashboard edits |
| restore | Before restoring a version |
**Stats display:**
The Version Control card in Config shows:
- Total version count
- Post versions count
- Page versions count
### Environment variables
**Convex Environment Variables:**
@@ -395,6 +452,6 @@ npx convex env set VARIABLE_NAME value
**Local Environment Variables (.env.local):**
| Variable | Description |
| ------------------- | ------------------------------------------ |
| ------------------- | ----------------------------------------- |
| `VITE_CONVEX_URL` | Your Convex deployment URL (auto-created) |
| `FIRECRAWL_API_KEY` | For CLI import command only |

View File

@@ -29,6 +29,7 @@ import type * as search from "../search.js";
import type * as semanticSearch from "../semanticSearch.js";
import type * as semanticSearchQueries from "../semanticSearchQueries.js";
import type * as stats from "../stats.js";
import type * as versions from "../versions.js";
import type {
ApiFromModules,
@@ -58,6 +59,7 @@ declare const fullApi: ApiFromModules<{
semanticSearch: typeof semanticSearch;
semanticSearchQueries: typeof semanticSearchQueries;
stats: typeof stats;
versions: typeof versions;
}>;
/**

View File

@@ -1,5 +1,6 @@
import { mutation, query } from "./_generated/server";
import { v } from "convex/values";
import { internal } from "./_generated/api";
// Shared validator for post data
const postDataValidator = v.object({
@@ -150,6 +151,17 @@ export const updatePost = mutation({
}
}
// Capture version before update (async, non-blocking)
await ctx.scheduler.runAfter(0, internal.versions.createVersion, {
contentType: "post",
contentId: args.id,
slug: existing.slug,
title: existing.title,
content: existing.content,
description: existing.description,
source: "dashboard",
});
await ctx.db.patch(args.id, {
...args.post,
lastSyncedAt: Date.now(),
@@ -253,6 +265,16 @@ export const updatePage = mutation({
}
}
// Capture version before update (async, non-blocking)
await ctx.scheduler.runAfter(0, internal.versions.createVersion, {
contentType: "page",
contentId: args.id,
slug: existing.slug,
title: existing.title,
content: existing.content,
source: "dashboard",
});
await ctx.db.patch(args.id, {
...args.page,
lastSyncedAt: Date.now(),

View File

@@ -35,5 +35,14 @@ crons.cron(
}
);
// Clean up old content versions daily at 3:00 AM UTC
// Deletes versions older than 3 days to maintain storage efficiency
crons.cron(
"cleanup old content versions",
"0 3 * * *", // 3:00 AM UTC daily
internal.versions.cleanupOldVersions,
{}
);
export default crons;

View File

@@ -1,5 +1,6 @@
import { query, mutation } from "./_generated/server";
import { v } from "convex/values";
import { internal } from "./_generated/api";
// Get all pages (published and unpublished) for dashboard admin view
export const listAll = query({
@@ -392,6 +393,15 @@ export const syncPagesPublic = mutation({
skipped++;
continue;
}
// Capture version before update (async, non-blocking)
await ctx.scheduler.runAfter(0, internal.versions.createVersion, {
contentType: "page",
contentId: existing._id,
slug: existing.slug,
title: existing.title,
content: existing.content,
source: "sync",
});
// Update existing sync page
await ctx.db.patch(existing._id, {
title: page.title,

View File

@@ -1,5 +1,6 @@
import { query, mutation, internalMutation, internalQuery } from "./_generated/server";
import { v } from "convex/values";
import { internal } from "./_generated/api";
// Get all posts (published and unpublished) for dashboard admin view
export const listAll = query({
@@ -545,6 +546,16 @@ export const syncPostsPublic = mutation({
skipped++;
continue;
}
// Capture version before update (async, non-blocking)
await ctx.scheduler.runAfter(0, internal.versions.createVersion, {
contentType: "post",
contentId: existing._id,
slug: existing.slug,
title: existing.title,
content: existing.content,
description: existing.description,
source: "sync",
});
// Update existing sync post
await ctx.db.patch(existing._id, {
title: post.title,

View File

@@ -244,4 +244,32 @@ export default defineSchema({
)
), // Optional sources cited in the response
}).index("by_stream", ["streamId"]),
// Content version history for posts and pages
// Stores snapshots before each update for 3-day retention
contentVersions: defineTable({
contentType: v.union(v.literal("post"), v.literal("page")), // Type of content
contentId: v.string(), // ID of the post or page (stored as string for flexibility)
slug: v.string(), // Slug for display and querying
title: v.string(), // Title at time of snapshot
content: v.string(), // Full markdown content at time of snapshot
description: v.optional(v.string()), // Description (posts only)
createdAt: v.number(), // Timestamp when version was created
source: v.union(
v.literal("sync"),
v.literal("dashboard"),
v.literal("restore")
), // What triggered the version capture
})
.index("by_content", ["contentType", "contentId"])
.index("by_slug", ["contentType", "slug"])
.index("by_createdAt", ["createdAt"])
.index("by_content_createdAt", ["contentType", "contentId", "createdAt"]),
// Version control settings
// Stores toggle state for version control feature
versionControlSettings: defineTable({
key: v.string(), // Setting key: "enabled"
value: v.boolean(), // Setting value
}).index("by_key", ["key"]),
});

276
convex/versions.ts Normal file
View File

@@ -0,0 +1,276 @@
import { query, mutation, internalMutation } from "./_generated/server";
import { v } from "convex/values";
import { Id } from "./_generated/dataModel";
// Retention period: 3 days in milliseconds
const RETENTION_MS = 3 * 24 * 60 * 60 * 1000;
// Check if version control is enabled
export const isEnabled = query({
args: {},
returns: v.boolean(),
handler: async (ctx) => {
const setting = await ctx.db
.query("versionControlSettings")
.withIndex("by_key", (q) => q.eq("key", "enabled"))
.first();
return setting?.value === true;
},
});
// Toggle version control on/off
export const setEnabled = mutation({
args: { enabled: v.boolean() },
returns: v.null(),
handler: async (ctx, args) => {
const existing = await ctx.db
.query("versionControlSettings")
.withIndex("by_key", (q) => q.eq("key", "enabled"))
.first();
if (existing) {
await ctx.db.patch(existing._id, { value: args.enabled });
} else {
await ctx.db.insert("versionControlSettings", {
key: "enabled",
value: args.enabled,
});
}
return null;
},
});
// Create a version snapshot (called before updates)
// This is an internal mutation to be called from other mutations
export const createVersion = internalMutation({
args: {
contentType: v.union(v.literal("post"), v.literal("page")),
contentId: v.string(),
slug: v.string(),
title: v.string(),
content: v.string(),
description: v.optional(v.string()),
source: v.union(
v.literal("sync"),
v.literal("dashboard"),
v.literal("restore")
),
},
returns: v.union(v.id("contentVersions"), v.null()),
handler: async (ctx, args) => {
// Check if version control is enabled
const setting = await ctx.db
.query("versionControlSettings")
.withIndex("by_key", (q) => q.eq("key", "enabled"))
.first();
if (setting?.value !== true) {
return null;
}
// Create version snapshot
const versionId = await ctx.db.insert("contentVersions", {
contentType: args.contentType,
contentId: args.contentId,
slug: args.slug,
title: args.title,
content: args.content,
description: args.description,
createdAt: Date.now(),
source: args.source,
});
return versionId;
},
});
// Get version history for a piece of content
export const getVersionHistory = query({
args: {
contentType: v.union(v.literal("post"), v.literal("page")),
contentId: v.string(),
},
returns: v.array(
v.object({
_id: v.id("contentVersions"),
title: v.string(),
createdAt: v.number(),
source: v.union(
v.literal("sync"),
v.literal("dashboard"),
v.literal("restore")
),
contentPreview: v.string(),
})
),
handler: async (ctx, args) => {
const versions = await ctx.db
.query("contentVersions")
.withIndex("by_content", (q) =>
q.eq("contentType", args.contentType).eq("contentId", args.contentId)
)
.order("desc")
.collect();
return versions.map((v) => ({
_id: v._id,
title: v.title,
createdAt: v.createdAt,
source: v.source,
contentPreview:
v.content.slice(0, 150) + (v.content.length > 150 ? "..." : ""),
}));
},
});
// Get a specific version's full content
export const getVersion = query({
args: { versionId: v.id("contentVersions") },
returns: v.union(
v.object({
_id: v.id("contentVersions"),
contentType: v.union(v.literal("post"), v.literal("page")),
contentId: v.string(),
slug: v.string(),
title: v.string(),
content: v.string(),
description: v.optional(v.string()),
createdAt: v.number(),
source: v.union(
v.literal("sync"),
v.literal("dashboard"),
v.literal("restore")
),
}),
v.null()
),
handler: async (ctx, args) => {
const version = await ctx.db.get(args.versionId);
if (!version) return null;
return {
_id: version._id,
contentType: version.contentType,
contentId: version.contentId,
slug: version.slug,
title: version.title,
content: version.content,
description: version.description,
createdAt: version.createdAt,
source: version.source,
};
},
});
// Restore a previous version
export const restoreVersion = mutation({
args: { versionId: v.id("contentVersions") },
returns: v.object({
success: v.boolean(),
message: v.string(),
}),
handler: async (ctx, args) => {
const version = await ctx.db.get(args.versionId);
if (!version) {
return { success: false, message: "Version not found" };
}
// Get current content to create a backup before restoring
let currentContent;
if (version.contentType === "post") {
currentContent = await ctx.db.get(
version.contentId as Id<"posts">
);
} else {
currentContent = await ctx.db.get(
version.contentId as Id<"pages">
);
}
if (!currentContent) {
return { success: false, message: "Original content not found" };
}
// Create backup version of current state before restoring
await ctx.db.insert("contentVersions", {
contentType: version.contentType,
contentId: version.contentId,
slug: version.slug,
title: currentContent.title,
content: currentContent.content,
description:
"description" in currentContent ? currentContent.description : undefined,
createdAt: Date.now(),
source: "restore",
});
// Restore the content
if (version.contentType === "post") {
await ctx.db.patch(version.contentId as Id<"posts">, {
title: version.title,
content: version.content,
description: version.description || "",
lastSyncedAt: Date.now(),
});
} else {
await ctx.db.patch(version.contentId as Id<"pages">, {
title: version.title,
content: version.content,
lastSyncedAt: Date.now(),
});
}
return { success: true, message: "Version restored successfully" };
},
});
// Clean up versions older than 3 days
// Called by cron job
export const cleanupOldVersions = internalMutation({
args: {},
returns: v.number(),
handler: async (ctx) => {
const cutoff = Date.now() - RETENTION_MS;
// Get old versions using the createdAt index
const oldVersions = await ctx.db
.query("contentVersions")
.withIndex("by_createdAt", (q) => q.lt("createdAt", cutoff))
.take(1000);
// Delete the batch
await Promise.all(oldVersions.map((version) => ctx.db.delete(version._id)));
return oldVersions.length;
},
});
// Get version control stats (for dashboard display)
export const getStats = query({
args: {},
returns: v.object({
enabled: v.boolean(),
totalVersions: v.number(),
oldestVersion: v.union(v.number(), v.null()),
newestVersion: v.union(v.number(), v.null()),
}),
handler: async (ctx) => {
const setting = await ctx.db
.query("versionControlSettings")
.withIndex("by_key", (q) => q.eq("key", "enabled"))
.first();
const versions = await ctx.db.query("contentVersions").collect();
const timestamps = versions.map((v) => v.createdAt);
const oldest = timestamps.length > 0 ? Math.min(...timestamps) : null;
const newest = timestamps.length > 0 ? Math.max(...timestamps) : null;
return {
enabled: setting?.value === true,
totalVersions: versions.length,
oldestVersion: oldest,
newestVersion: newest,
};
},
});

View File

@@ -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); Index HTML editor; Analytics (real-time stats dashboard); Sync commands UI with sync server integration; Header sync buttons; Dashboard search; Toast notifications; Command modal; Mobile responsive design. Uses Convex queries for real-time data, localStorage for preferences, ReactMarkdown for preview. Optional WorkOS authentication via siteConfig.dashboard.requireAuth. |
| `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. |
| `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. |
@@ -79,6 +79,7 @@ A brief description of each file in the codebase.
| `ContactForm.tsx` | Contact form component with name, email, and message fields. Displays when contactForm: true in frontmatter. Submits to Convex which sends email via AgentMail to configured recipient. Requires AGENTMAIL_API_KEY and AGENTMAIL_INBOX environment variables. Includes honeypot field for bot protection. |
| `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. |
### Context (`src/context/`)
@@ -112,8 +113,8 @@ A brief description of each file in the codebase.
| File | Description |
| ------------------ | ------------------------------------------------------------------------------------------------------------------ |
| `schema.ts` | Database schema (posts, pages, viewCounts, pageViews, activeSessions, aiChats, aiGeneratedImages, newsletterSubscribers, newsletterSentPosts, contactMessages, askAISessions) with indexes for tag queries (by_tags), AI queries, blog featured posts (by_blogFeatured), source tracking (by_source), and vector search (by_embedding). Posts and pages include showSocialFooter, showImageAtTop, blogFeatured, contactForm, source, and embedding fields for frontmatter control, cloud CMS tracking, and semantic search. askAISessions stores question, streamId, model, and sources for Ask AI RAG feature. |
| `cms.ts` | CRUD mutations for dashboard cloud CMS: createPost, updatePost, deletePost, createPage, updatePage, deletePage, exportPostAsMarkdown, exportPageAsMarkdown. Posts/pages created via dashboard have `source: "dashboard"` (protected from sync overwrites). |
| `schema.ts` | Database schema (posts, pages, viewCounts, pageViews, activeSessions, aiChats, aiGeneratedImages, newsletterSubscribers, newsletterSentPosts, contactMessages, askAISessions, contentVersions, versionControlSettings) with indexes for tag queries (by_tags), AI queries, blog featured posts (by_blogFeatured), source tracking (by_source), vector search (by_embedding), and version history (by_content, by_createdAt). Posts and pages include showSocialFooter, showImageAtTop, blogFeatured, contactForm, source, and embedding fields for frontmatter control, cloud CMS tracking, and semantic search. contentVersions stores snapshots before content updates. versionControlSettings stores the enable/disable toggle. |
| `cms.ts` | CRUD mutations for dashboard cloud CMS: createPost, updatePost, deletePost, createPage, updatePage, deletePage, exportPostAsMarkdown, exportPageAsMarkdown. Posts/pages created via dashboard have `source: "dashboard"` (protected from sync overwrites). Captures versions before updates when version control is enabled. |
| `importAction.ts` | Server-side Convex action for direct URL import via Firecrawl API. Scrapes URL, converts to markdown, saves directly to database with `source: "dashboard"`. Requires FIRECRAWL_API_KEY environment variable. |
| `posts.ts` | Queries and mutations for blog posts, view counts, getAllTags, getPostsByTag, getRelatedPosts, and getBlogFeaturedPosts. Includes tag-based queries for tag pages and related posts functionality. |
| `pages.ts` | Queries and mutations for static pages |
@@ -123,7 +124,7 @@ A brief description of each file in the codebase.
| `embeddings.ts` | Embedding generation actions using OpenAI text-embedding-ada-002 |
| `embeddingsQueries.ts` | Internal queries and mutations for embedding storage and retrieval |
| `stats.ts` | Real-time stats with aggregate component for O(log n) counts, page view recording, session heartbeat |
| `crons.ts` | Cron jobs for stale session cleanup (every 5 minutes), weekly newsletter digest (Sundays 9am UTC), and weekly stats summary (Mondays 9am UTC). Uses environment variables SITE_URL and SITE_NAME for email content. |
| `crons.ts` | Cron jobs for stale session cleanup (every 5 minutes), weekly newsletter digest (Sundays 9am UTC), weekly stats summary (Mondays 9am UTC), and version cleanup (daily 3am UTC). Uses environment variables SITE_URL and SITE_NAME for email content. |
| `http.ts` | HTTP endpoints: sitemap (includes tag pages), API (update SITE_URL/SITE_NAME when forking, uses www.markdown.fast), Open Graph HTML generation for social crawlers with hreflang and twitter:site meta tags |
| `rss.ts` | RSS feed generation (update SITE_URL/SITE_TITLE when forking, uses www.markdown.fast) |
| `auth.config.ts` | Convex authentication configuration for WorkOS. Defines JWT providers for WorkOS API and user management. Requires WORKOS_CLIENT_ID environment variable in Convex. Optional - only needed if using WorkOS authentication for dashboard. |
@@ -133,6 +134,7 @@ A brief description of each file in the codebase.
| `newsletter.ts` | Newsletter mutations and queries: subscribe, unsubscribe, getSubscriberCount, getActiveSubscribers, getAllSubscribers (admin), deleteSubscriber (admin), getNewsletterStats, getPostsForNewsletter, wasPostSent, recordPostSent, scheduleSendPostNewsletter, scheduleSendCustomNewsletter, scheduleSendStatsSummary, getStatsForSummary. |
| `newsletterActions.ts` | Newsletter actions (Node.js runtime): sendPostNewsletter, sendCustomNewsletter, sendWeeklyDigest, notifyNewSubscriber, sendWeeklyStatsSummary. Uses AgentMail SDK for email delivery. Includes markdown-to-HTML conversion for custom emails. |
| `contact.ts` | Contact form mutations and actions: submitContact, sendContactEmail (AgentMail API), markEmailSent. |
| `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 |

1
package-lock.json generated
View File

@@ -23,6 +23,7 @@
"agentmail": "^0.1.15",
"convex": "^1.17.4",
"date-fns": "^3.3.1",
"diff": "^8.0.2",
"gray-matter": "^4.0.3",
"lucide-react": "^0.344.0",
"openai": "^4.79.0",

View File

@@ -42,6 +42,7 @@
"agentmail": "^0.1.15",
"convex": "^1.17.4",
"date-fns": "^3.3.1",
"diff": "^8.0.2",
"gray-matter": "^4.0.3",
"lucide-react": "^0.344.0",
"openai": "^4.79.0",

View File

@@ -2,7 +2,7 @@
---
Type: page
Date: 2026-01-09
Date: 2026-01-10
---
An open-source publishing framework built for AI agents and developers to ship websites, docs, or blogs. Write markdown, sync from the terminal. Your content is instantly available to browsers, LLMs, and AI agents. Built on Convex and Netlify.

View File

@@ -2,11 +2,90 @@
---
Type: page
Date: 2026-01-09
Date: 2026-01-10
---
All notable changes to this project.
## v2.16.0
Released January 9, 2026
**Version control system**
Added a Sync version control system for tracking changes to posts, pages, home content, and footer.
**Features:**
- 3-day version history for all content
- Dashboard toggle to enable/disable version control
- Version history modal with unified diff visualization
- Preview mode to view previous version content
- One-click restore with automatic backup of current state
- Automatic cleanup of versions older than 3 days (daily cron at 3 AM UTC)
- Version stats display in Config section
**How to use:**
1. Navigate to Dashboard > Config
2. Find the "Version Control" card
3. Toggle "Enable version control" on
4. Edit posts/pages or run sync commands to capture versions
5. Click the History button in the editor to view version history
6. Select a version to view diff or preview, then click "Restore This Version"
**Technical:**
- New `convex/versions.ts` with 7 functions (isEnabled, setEnabled, createVersion, getVersionHistory, getVersion, restoreVersion, cleanupOldVersions, getStats)
- New `contentVersions` table with indexes for efficient queries
- New `versionControlSettings` table for toggle state
- New `VersionHistoryModal.tsx` component using existing DiffCodeBlock
- Version capture integrated into cms.ts, posts.ts, and pages.ts
- Cleanup cron job in crons.ts
**Files changed:**
- `convex/schema.ts` - Added contentVersions and versionControlSettings tables
- `convex/versions.ts` - New file with all version control logic
- `convex/cms.ts` - Added version capture before dashboard edits
- `convex/posts.ts` - Added version capture before sync updates
- `convex/pages.ts` - Added version capture before sync updates
- `convex/crons.ts` - Added daily cleanup job
- `src/components/VersionHistoryModal.tsx` - New version history modal
- `src/pages/Dashboard.tsx` - Added Version Control config card and History button
- `src/styles/global.css` - Added ~370 lines of version modal CSS
---
## v2.15.3
Released January 9, 2026
**Footer not displaying on /docs landing page fix**
Fixed an issue where the footer was not displaying on the `/docs` landing page even when `showFooter: true` was set in the frontmatter. The `DocsPage.tsx` component (which handles the `/docs` route with `docsLanding: true`) was missing the Footer component entirely.
**Fixes:**
- Added Footer component to DocsPage.tsx
- Footer now respects `showFooter` frontmatter field on docs landing pages
- Added AI chat support to docs landing page via `aiChatEnabled` and `pageContent` props
**Technical:**
- Added `Footer` import and `footerPage` query to fetch footer content
- Added footer rendering logic after BlogPost component (same pattern as Post.tsx)
- Updated `getDocsLandingPage` query in `convex/pages.ts` to return `showFooter`, `footer`, `excerpt`, and `aiChat` fields
- Updated `getDocsLandingPost` query in `convex/posts.ts` to return `showFooter`, `footer`, and `aiChat` fields
**Files changed:**
- `src/pages/DocsPage.tsx` - Added Footer component and rendering logic
- `convex/pages.ts` - Updated getDocsLandingPage query return fields
- `convex/posts.ts` - Updated getDocsLandingPost query return fields
---
## v2.15.2
Released January 8, 2026
@@ -175,7 +254,7 @@ Diff and patch code blocks now render with enhanced visualization powered by @pi
- View toggle button to switch between modes
- Theme-aware colors matching dark/light/tan/cloud themes
- Copy button for copying raw diff content
- Automatic routing: Use ```diff or ```patch in markdown
- Automatic routing: Use `diff or `patch in markdown
**New documentation:**
@@ -318,6 +397,7 @@ semanticSearch: {
```
When disabled:
- Search modal shows only keyword search (no mode toggle)
- Embedding generation skipped during sync (saves API costs)
- Existing embeddings preserved in database (no data loss)
@@ -347,7 +427,7 @@ Search now supports two modes accessible via Cmd+K:
**When to use each mode:**
| Use Case | Mode |
|----------|------|
| ----------------------------------------- | -------- |
| Specific code, commands, exact phrases | Keyword |
| Conceptual questions ("how do I deploy?") | Semantic |
| Need to highlight matches on page | Keyword |

View File

@@ -2,7 +2,7 @@
---
Type: page
Date: 2026-01-09
Date: 2026-01-10
---
You found the contact page. Nice

View File

@@ -2,7 +2,7 @@
---
Type: page
Date: 2026-01-09
Date: 2026-01-10
---
## Ask AI

View File

@@ -2,7 +2,7 @@
---
Type: page
Date: 2026-01-09
Date: 2026-01-10
---
## Configuration

View File

@@ -2,7 +2,7 @@
---
Type: page
Date: 2026-01-09
Date: 2026-01-10
---
## Content

View File

@@ -2,7 +2,7 @@
---
Type: page
Date: 2026-01-09
Date: 2026-01-10
---
## Dashboard
@@ -367,6 +367,63 @@ This creates a file in `content/blog/` that requires syncing.
**Note:** The dashboard provides a UI for these commands. When the sync server is running (`npm run sync-server`), you can execute commands directly from the dashboard with real-time output. Otherwise, the dashboard shows commands in a modal for copying to your terminal.
### Version control
The dashboard includes a Sync version control system that tracks changes to posts, pages, home content, and footer content.
**Features:**
- 3-day version history for all content
- Toggle to enable/disable version control
- View version history with unified diff visualization
- Preview previous versions
- One-click restore with automatic backup
- Automatic cleanup of versions older than 3 days
**Enabling version control:**
1. Navigate to Dashboard > Config
2. Find the "Version Control" card
3. Toggle "Enable version control" on
When enabled, versions are captured:
- Before sync updates (from markdown files)
- Before dashboard edits (Save Changes button)
- Before restoring a previous version
**Viewing version history:**
1. Open any post or page in the Dashboard editor
2. Click the clock (History) button in the editor toolbar
3. Select a version from the list to view details
4. Toggle between "Diff" and "Preview" modes
5. Click "Restore This Version" to revert
**How it works:**
- Versions are stored in the `contentVersions` table
- Settings stored in `versionControlSettings` table (database, not config file)
- Cleanup runs daily at 3:00 AM UTC via cron job
- Restore creates a backup of current content before reverting
- Uses existing DiffCodeBlock component for diff visualization
**Version sources:**
| Source | When created |
| --------- | ---------------------------- |
| sync | Before markdown sync updates |
| dashboard | Before dashboard edits |
| restore | Before restoring a version |
**Stats display:**
The Version Control card in Config shows:
- Total version count
- Post versions count
- Page versions count
### Environment variables
**Convex Environment Variables:**
@@ -387,6 +444,6 @@ npx convex env set VARIABLE_NAME value
**Local Environment Variables (.env.local):**
| Variable | Description |
| ------------------- | ------------------------------------------ |
| ------------------- | ----------------------------------------- |
| `VITE_CONVEX_URL` | Your Convex deployment URL (auto-created) |
| `FIRECRAWL_API_KEY` | For CLI import command only |

View File

@@ -2,7 +2,7 @@
---
Type: page
Date: 2026-01-09
Date: 2026-01-10
---
## Deployment

View File

@@ -2,7 +2,7 @@
---
Type: page
Date: 2026-01-09
Date: 2026-01-10
---
## Frontmatter

View File

@@ -2,7 +2,7 @@
---
Type: page
Date: 2026-01-09
Date: 2026-01-10
---
## Keyword Search

View File

@@ -2,7 +2,7 @@
---
Type: page
Date: 2026-01-09
Date: 2026-01-10
---
## Semantic Search

View File

@@ -2,7 +2,7 @@
---
Type: page
Date: 2026-01-09
Date: 2026-01-10
---
## Getting started

View File

@@ -2,7 +2,7 @@
---
Type: page
Date: 2026-01-09
Date: 2026-01-10
---
Built with [Convex](https://convex.dev) for real-time sync and deployed on [Netlify](https://netlify.com). Read the [project on GitHub](https://github.com/waynesutton/markdown-site) to fork and deploy your own. View [real-time site stats](/stats).

View File

@@ -2,7 +2,7 @@
---
Type: page
Date: 2026-01-09
Date: 2026-01-10
---
An open-source publishing framework built for AI agents and developers to ship **[docs](/docs)**, or **[blogs](/blog)** or **[websites](/)**.

View File

@@ -76,6 +76,7 @@ agents. -->
- **[Footer](/raw/footer.md)**
- **[Home Intro](/raw/home-intro.md)**
- **[Documentation](/raw/documentation.md)**
- **[Dashboard](/raw/docs-dashboard.md)**
- **[About](/raw/about.md)** - An open-source publishing framework built for AI agents and developers to ship websites, docs, or blogs.
- **[Ask AI](/raw/docs-ask-ai.md)**
- **[Content](/raw/docs-content.md)**
@@ -86,7 +87,6 @@ agents. -->
- **[Contact](/raw/contact.md)**
- **[Configuration](/raw/docs-configuration.md)**
- **[Changelog](/raw/changelog.md)**
- **[Dashboard](/raw/docs-dashboard.md)**
- **[Deployment](/raw/docs-deployment.md)**
- **[Newsletter](/raw/newsletter.md)**

View File

@@ -2,7 +2,7 @@
---
Type: page
Date: 2026-01-09
Date: 2026-01-10
---
# Newsletter Demo Page

View File

@@ -2,7 +2,7 @@
---
Type: page
Date: 2026-01-09
Date: 2026-01-10
---
This markdown framework is open source and built to be extended. Here is what ships out of the box.

View File

@@ -0,0 +1,238 @@
import { useState, useMemo } from "react";
import { useQuery, useMutation } from "convex/react";
import { api } from "../../convex/_generated/api";
import { Id } from "../../convex/_generated/dataModel";
import { X, History, RotateCcw, Eye, FileCode } from "lucide-react";
import { createPatch } from "diff";
import DiffCodeBlock from "./DiffCodeBlock";
interface VersionHistoryModalProps {
isOpen: boolean;
onClose: () => void;
contentType: "post" | "page";
contentId: string;
currentContent: string;
currentTitle: string;
}
export default function VersionHistoryModal({
isOpen,
onClose,
contentType,
contentId,
currentContent,
currentTitle,
}: VersionHistoryModalProps) {
const [selectedVersionId, setSelectedVersionId] = useState<string | null>(
null
);
const [viewMode, setViewMode] = useState<"preview" | "diff">("diff");
const [isRestoring, setIsRestoring] = useState(false);
const versions = useQuery(api.versions.getVersionHistory, {
contentType,
contentId,
});
const selectedVersion = useQuery(
api.versions.getVersion,
selectedVersionId
? { versionId: selectedVersionId as Id<"contentVersions"> }
: "skip"
);
const restoreVersionMutation = useMutation(api.versions.restoreVersion);
// Generate diff patch when a version is selected
const diffPatch = useMemo(() => {
if (!selectedVersion) return "";
return createPatch(
"content.md",
selectedVersion.content,
currentContent,
`Version from ${new Date(selectedVersion.createdAt).toLocaleString()}`,
"Current Version"
);
}, [selectedVersion, currentContent]);
const handleRestore = async () => {
if (!selectedVersionId) return;
setIsRestoring(true);
try {
const result = await restoreVersionMutation({
versionId: selectedVersionId as Id<"contentVersions">,
});
if (result.success) {
onClose();
// The page will automatically update via Convex real-time subscription
}
} finally {
setIsRestoring(false);
}
};
const formatDate = (timestamp: number) => {
const date = new Date(timestamp);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
const diffDays = Math.floor(diffHours / 24);
if (diffHours < 1) {
const diffMins = Math.floor(diffMs / (1000 * 60));
return `${diffMins} minute${diffMins !== 1 ? "s" : ""} ago`;
} else if (diffHours < 24) {
return `${diffHours} hour${diffHours !== 1 ? "s" : ""} ago`;
} else if (diffDays < 3) {
return `${diffDays} day${diffDays !== 1 ? "s" : ""} ago`;
}
return date.toLocaleString();
};
const getSourceLabel = (source: string) => {
switch (source) {
case "sync":
return "Sync";
case "dashboard":
return "Dashboard";
case "restore":
return "Restore";
default:
return source;
}
};
if (!isOpen) return null;
return (
<div className="version-modal-overlay" onClick={onClose}>
<div className="version-modal" onClick={(e) => e.stopPropagation()}>
<div className="version-modal-header">
<div className="version-modal-title">
<History size={18} />
<span>Version History</span>
<span className="version-modal-subtitle">
{currentTitle} ({contentType})
</span>
</div>
<button
className="version-modal-close"
onClick={onClose}
aria-label="Close"
>
<X size={18} />
</button>
</div>
<div className="version-modal-content">
<div className="version-list">
<div className="version-list-header">
<span>Previous Versions</span>
<span className="version-count">
{versions?.length || 0} version
{versions?.length !== 1 ? "s" : ""}
</span>
</div>
{versions?.length === 0 && (
<div className="version-empty">
<p>No previous versions available.</p>
<p className="version-empty-hint">
Versions are created when content is edited.
</p>
</div>
)}
{versions?.map((version) => (
<div
key={version._id}
className={`version-item ${selectedVersionId === version._id ? "selected" : ""}`}
onClick={() => setSelectedVersionId(version._id)}
>
<div className="version-item-header">
<span className="version-date">
{formatDate(version.createdAt)}
</span>
<span className={`version-source source-${version.source}`}>
{getSourceLabel(version.source)}
</span>
</div>
<div className="version-title">{version.title}</div>
<div className="version-preview">{version.contentPreview}</div>
</div>
))}
</div>
<div className="version-detail">
{!selectedVersion ? (
<div className="version-detail-empty">
<p>Select a version to view details</p>
</div>
) : (
<>
<div className="version-detail-header">
<div className="version-detail-tabs">
<button
className={`version-tab ${viewMode === "diff" ? "active" : ""}`}
onClick={() => setViewMode("diff")}
>
<FileCode size={14} />
Diff
</button>
<button
className={`version-tab ${viewMode === "preview" ? "active" : ""}`}
onClick={() => setViewMode("preview")}
>
<Eye size={14} />
Preview
</button>
</div>
<div className="version-detail-info">
<span>
{new Date(selectedVersion.createdAt).toLocaleString()}
</span>
</div>
</div>
<div className="version-detail-content">
{viewMode === "diff" ? (
<div className="version-diff-container">
<DiffCodeBlock code={diffPatch} language="diff" />
</div>
) : (
<div className="version-preview-content">
<h3>{selectedVersion.title}</h3>
{selectedVersion.description && (
<p className="version-preview-description">
{selectedVersion.description}
</p>
)}
<pre className="version-preview-code">
{selectedVersion.content}
</pre>
</div>
)}
</div>
</>
)}
</div>
</div>
<div className="version-modal-footer">
<button className="version-btn-secondary" onClick={onClose}>
Cancel
</button>
<button
className="version-btn-primary"
onClick={handleRestore}
disabled={!selectedVersionId || isRestoring}
>
<RotateCcw size={14} />
{isRestoring ? "Restoring..." : "Restore This Version"}
</button>
</div>
</div>
</div>
);
}

View File

@@ -65,6 +65,7 @@ import {
} from "@phosphor-icons/react";
import siteConfig from "../config/siteConfig";
import AIChatView from "../components/AIChatView";
import VersionHistoryModal from "../components/VersionHistoryModal";
import { isWorkOSConfigured } from "../utils/workos";
// Always import auth components - they're only used when WorkOS is configured
import {
@@ -1880,6 +1881,8 @@ function EditorView({
}) {
const [copied, setCopied] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [showVersionHistory, setShowVersionHistory] = useState(false);
const versionControlEnabled = useQuery(api.versions.isEnabled);
const [sidebarWidth, setSidebarWidth] = useState(() => {
const saved = localStorage.getItem("dashboard-sidebar-width");
return saved ? Number(saved) : 280;
@@ -1976,6 +1979,16 @@ function EditorView({
{copied ? <Check size={16} /> : <Copy size={16} />}
<span>{copied ? "Copied" : "Copy"}</span>
</button>
{versionControlEnabled && (
<button
className="dashboard-action-btn"
onClick={() => setShowVersionHistory(true)}
title="View Version History"
>
<ClockCounterClockwise size={16} />
<span>History</span>
</button>
)}
<button
className="dashboard-action-btn primary"
onClick={onDownload}
@@ -2040,6 +2053,17 @@ function EditorView({
<FrontmatterSidebar item={item} type={type} setItem={setItem} />
</div>
</div>
{showVersionHistory && (
<VersionHistoryModal
isOpen={showVersionHistory}
onClose={() => setShowVersionHistory(false)}
contentType={type}
contentId={item._id}
currentContent={item.content}
currentTitle={item.title}
/>
)}
</div>
);
}
@@ -5601,6 +5625,9 @@ export default siteConfig;
</p>
</div>
{/* Version Control */}
<VersionControlCard addToast={addToast} />
{/* Links */}
<div className="dashboard-config-card">
<h3>External Links</h3>
@@ -5645,6 +5672,81 @@ export default siteConfig;
);
}
// Version Control Card Component
function VersionControlCard({
addToast,
}: {
addToast: (message: string, type: ToastType) => void;
}) {
const versionControlEnabled = useQuery(api.versions.isEnabled);
const versionStats = useQuery(api.versions.getStats);
const setVersionControlEnabled = useMutation(api.versions.setEnabled);
const [isToggling, setIsToggling] = useState(false);
const handleToggle = async () => {
setIsToggling(true);
try {
await setVersionControlEnabled({ enabled: !versionControlEnabled });
addToast(
`Version control ${!versionControlEnabled ? "enabled" : "disabled"}`,
"success"
);
} catch {
addToast("Failed to update version control setting", "error");
} finally {
setIsToggling(false);
}
};
const formatDate = (timestamp: number | null) => {
if (!timestamp) return "N/A";
return new Date(timestamp).toLocaleString();
};
return (
<div className="dashboard-config-card">
<h3>Version Control</h3>
<div className="config-field checkbox">
<label>
<input
type="checkbox"
checked={versionControlEnabled ?? false}
onChange={handleToggle}
disabled={isToggling}
/>
<span>
{isToggling ? "Updating..." : "Enable version history (3-day retention)"}
</span>
</label>
</div>
<p className="config-hint">
When enabled, saves a snapshot before each edit. View and restore previous versions
from the editor toolbar.
</p>
{versionStats && versionStats.totalVersions > 0 && (
<div className="version-stats">
<div className="version-stat">
<span className="version-stat-label">Total versions:</span>
<span className="version-stat-value">{versionStats.totalVersions}</span>
</div>
<div className="version-stat">
<span className="version-stat-label">Oldest:</span>
<span className="version-stat-value">
{formatDate(versionStats.oldestVersion)}
</span>
</div>
<div className="version-stat">
<span className="version-stat-label">Newest:</span>
<span className="version-stat-value">
{formatDate(versionStats.newestVersion)}
</span>
</div>
</div>
)}
</div>
);
}
function StatsSection() {
const stats = useQuery(api.stats.getStats);

View File

@@ -363,7 +363,7 @@ body {
/* Themed background to prevent content overlap on scroll */
background-color: var(--bg-primary);
padding: 8px 12px;
border-radius: 8px;
border-radius: 6px;
/* nav bar border to match sidebar if sidebar is enabled
border-bottom: 1px solid var(--border-sidebar); */
}
@@ -12960,7 +12960,9 @@ body {
overflow-y: auto;
background-color: var(--bg-sidebar);
padding: 24px;
padding-top: 32px;
padding-top: 20px;
border-radius: 6px;
border-top: 1px solid var(--border-sidebar);
border-right: 1px solid var(--border-sidebar);
z-index: 10;
}
@@ -12993,7 +12995,9 @@ body {
overflow-y: auto;
background-color: var(--bg-sidebar);
padding: 24px;
padding-top: 32px;
padding-top: 20px;
border-radius: 6px;
border-top: 1px solid var(--border-sidebar);
border-left: 1px solid var(--border-sidebar);
z-index: 10;
}
@@ -14036,3 +14040,378 @@ body {
opacity: 1;
}
}
/* ============================================
Version History Modal
============================================ */
.version-modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
backdrop-filter: blur(2px);
}
.version-modal {
background: var(--bg-secondary);
border-radius: 12px;
width: 90vw;
max-width: 1200px;
height: 80vh;
max-height: 800px;
display: flex;
flex-direction: column;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
border: 1px solid var(--border-color);
}
.version-modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid var(--border-color);
}
.version-modal-title {
display: flex;
align-items: center;
gap: 10px;
font-weight: 600;
font-size: 16px;
}
.version-modal-subtitle {
color: var(--text-secondary);
font-weight: 400;
font-size: 14px;
}
.version-modal-close {
background: transparent;
border: none;
color: var(--text-secondary);
cursor: pointer;
padding: 8px;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
}
.version-modal-close:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.version-modal-content {
flex: 1;
display: grid;
grid-template-columns: 320px 1fr;
overflow: hidden;
}
.version-list {
border-right: 1px solid var(--border-color);
overflow-y: auto;
background: var(--bg-primary);
}
.version-list-header {
padding: 12px 16px;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid var(--border-color);
font-size: 13px;
font-weight: 500;
position: sticky;
top: 0;
background: var(--bg-primary);
z-index: 1;
}
.version-count {
color: var(--text-secondary);
font-weight: 400;
}
.version-empty {
padding: 40px 20px;
text-align: center;
color: var(--text-secondary);
}
.version-empty-hint {
font-size: 13px;
margin-top: 8px;
opacity: 0.7;
}
.version-item {
padding: 14px 16px;
border-bottom: 1px solid var(--border-color);
cursor: pointer;
transition: background 0.15s ease;
}
.version-item:hover {
background: var(--bg-hover);
}
.version-item.selected {
background: var(--bg-tertiary);
border-left: 3px solid var(--accent-color);
padding-left: 13px;
}
.version-item-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 6px;
}
.version-date {
font-size: 12px;
color: var(--text-secondary);
}
.version-source {
font-size: 10px;
padding: 2px 6px;
border-radius: 4px;
background: var(--bg-hover);
color: var(--text-secondary);
text-transform: uppercase;
font-weight: 500;
}
.version-source.source-sync {
background: rgba(59, 130, 246, 0.15);
color: rgb(59, 130, 246);
}
.version-source.source-dashboard {
background: rgba(34, 197, 94, 0.15);
color: rgb(34, 197, 94);
}
.version-source.source-restore {
background: rgba(168, 85, 247, 0.15);
color: rgb(168, 85, 247);
}
.version-title {
font-weight: 500;
font-size: 14px;
margin-bottom: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.version-preview {
font-size: 12px;
color: var(--text-secondary);
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.version-detail {
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--bg-secondary);
}
.version-detail-empty {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-secondary);
}
.version-detail-header {
padding: 12px 16px;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid var(--border-color);
}
.version-detail-tabs {
display: flex;
gap: 4px;
}
.version-tab {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border: none;
background: transparent;
color: var(--text-secondary);
border-radius: 6px;
cursor: pointer;
font-size: 13px;
transition: all 0.15s ease;
}
.version-tab:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.version-tab.active {
background: var(--bg-tertiary);
color: var(--text-primary);
}
.version-detail-info {
font-size: 12px;
color: var(--text-secondary);
}
.version-detail-content {
flex: 1;
overflow-y: auto;
padding: 16px;
}
.version-diff-container {
height: 100%;
}
.version-diff-container .diff-block-wrapper {
height: 100%;
display: flex;
flex-direction: column;
}
.version-preview-content {
padding: 0;
}
.version-preview-content h3 {
font-size: 18px;
margin-bottom: 12px;
}
.version-preview-description {
color: var(--text-secondary);
margin-bottom: 16px;
}
.version-preview-code {
background: var(--bg-primary);
padding: 16px;
border-radius: 8px;
font-size: 13px;
line-height: 1.6;
white-space: pre-wrap;
word-wrap: break-word;
font-family: var(--font-mono);
max-height: 100%;
overflow-y: auto;
}
.version-modal-footer {
padding: 16px 20px;
border-top: 1px solid var(--border-color);
display: flex;
justify-content: flex-end;
gap: 10px;
}
.version-btn-secondary {
padding: 8px 16px;
border: 1px solid var(--border-color);
background: transparent;
color: var(--text-primary);
border-radius: 6px;
cursor: pointer;
font-size: 14px;
transition: all 0.15s ease;
}
.version-btn-secondary:hover {
background: var(--bg-hover);
}
.version-btn-primary {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
border: none;
background: var(--accent);
color: var(--bg-primary);
border-radius: 6px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
transition: all 0.15s ease;
}
.version-btn-primary:hover:not(:disabled) {
background: var(--accent-hover);
}
.version-btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Version Control Stats in Config */
.version-stats {
margin-top: 12px;
padding: 12px;
background: var(--bg-primary);
border-radius: 8px;
border: 1px solid var(--border-color);
}
.version-stat {
display: flex;
justify-content: space-between;
align-items: center;
padding: 4px 0;
font-size: 13px;
}
.version-stat-label {
color: var(--text-secondary);
}
.version-stat-value {
font-weight: 500;
}
/* Mobile Responsive */
@media (max-width: 768px) {
.version-modal {
width: 95vw;
height: 90vh;
max-height: none;
}
.version-modal-content {
grid-template-columns: 1fr;
grid-template-rows: 200px 1fr;
}
.version-list {
border-right: none;
border-bottom: 1px solid var(--border-color);
}
}