Files
wiki/convex/cms.ts

435 lines
15 KiB
TypeScript

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({
slug: v.string(),
title: v.string(),
description: v.string(),
content: v.string(),
date: v.string(),
published: v.boolean(),
tags: v.array(v.string()),
readTime: v.optional(v.string()),
image: v.optional(v.string()),
showImageAtTop: v.optional(v.boolean()),
excerpt: v.optional(v.string()),
featured: v.optional(v.boolean()),
featuredOrder: v.optional(v.number()),
authorName: v.optional(v.string()),
authorImage: v.optional(v.string()),
layout: v.optional(v.string()),
rightSidebar: v.optional(v.boolean()),
showFooter: v.optional(v.boolean()),
footer: v.optional(v.string()),
showSocialFooter: v.optional(v.boolean()),
aiChat: v.optional(v.boolean()),
blogFeatured: v.optional(v.boolean()),
newsletter: v.optional(v.boolean()),
contactForm: v.optional(v.boolean()),
unlisted: v.optional(v.boolean()),
docsSection: v.optional(v.boolean()),
docsSectionGroup: v.optional(v.string()),
docsSectionOrder: v.optional(v.number()),
docsSectionGroupOrder: v.optional(v.number()),
docsSectionGroupIcon: v.optional(v.string()),
docsLanding: v.optional(v.boolean()),
});
// Shared validator for page data
const pageDataValidator = v.object({
slug: v.string(),
title: v.string(),
content: v.string(),
published: v.boolean(),
order: v.optional(v.number()),
showInNav: v.optional(v.boolean()),
excerpt: v.optional(v.string()),
image: v.optional(v.string()),
showImageAtTop: v.optional(v.boolean()),
featured: v.optional(v.boolean()),
featuredOrder: v.optional(v.number()),
authorName: v.optional(v.string()),
authorImage: v.optional(v.string()),
layout: v.optional(v.string()),
rightSidebar: v.optional(v.boolean()),
showFooter: v.optional(v.boolean()),
footer: v.optional(v.string()),
showSocialFooter: v.optional(v.boolean()),
aiChat: v.optional(v.boolean()),
contactForm: v.optional(v.boolean()),
newsletter: v.optional(v.boolean()),
textAlign: v.optional(v.string()),
docsSection: v.optional(v.boolean()),
docsSectionGroup: v.optional(v.string()),
docsSectionOrder: v.optional(v.number()),
docsSectionGroupOrder: v.optional(v.number()),
docsSectionGroupIcon: v.optional(v.string()),
docsLanding: v.optional(v.boolean()),
});
// Create a new post via dashboard
export const createPost = mutation({
args: { post: postDataValidator },
returns: v.id("posts"),
handler: async (ctx, args) => {
// Check if slug already exists
const existing = await ctx.db
.query("posts")
.withIndex("by_slug", (q) => q.eq("slug", args.post.slug))
.first();
if (existing) {
throw new Error(`Post with slug "${args.post.slug}" already exists`);
}
const postId = await ctx.db.insert("posts", {
...args.post,
source: "dashboard",
lastSyncedAt: Date.now(),
});
return postId;
},
});
// Update any post (dashboard or synced)
export const updatePost = mutation({
args: {
id: v.id("posts"),
post: v.object({
slug: v.optional(v.string()),
title: v.optional(v.string()),
description: v.optional(v.string()),
content: v.optional(v.string()),
date: v.optional(v.string()),
published: v.optional(v.boolean()),
tags: v.optional(v.array(v.string())),
readTime: v.optional(v.string()),
image: v.optional(v.string()),
showImageAtTop: v.optional(v.boolean()),
excerpt: v.optional(v.string()),
featured: v.optional(v.boolean()),
featuredOrder: v.optional(v.number()),
authorName: v.optional(v.string()),
authorImage: v.optional(v.string()),
layout: v.optional(v.string()),
rightSidebar: v.optional(v.boolean()),
showFooter: v.optional(v.boolean()),
footer: v.optional(v.string()),
showSocialFooter: v.optional(v.boolean()),
aiChat: v.optional(v.boolean()),
blogFeatured: v.optional(v.boolean()),
newsletter: v.optional(v.boolean()),
contactForm: v.optional(v.boolean()),
unlisted: v.optional(v.boolean()),
docsSection: v.optional(v.boolean()),
docsSectionGroup: v.optional(v.string()),
docsSectionOrder: v.optional(v.number()),
docsSectionGroupOrder: v.optional(v.number()),
docsSectionGroupIcon: v.optional(v.string()),
docsLanding: v.optional(v.boolean()),
}),
},
returns: v.null(),
handler: async (ctx, args) => {
const existing = await ctx.db.get(args.id);
if (!existing) {
throw new Error("Post not found");
}
// If slug is being changed, check for conflicts
const newSlug = args.post.slug;
if (newSlug && newSlug !== existing.slug) {
const slugConflict = await ctx.db
.query("posts")
.withIndex("by_slug", (q) => q.eq("slug", newSlug))
.first();
if (slugConflict) {
throw new Error(`Post with slug "${newSlug}" already exists`);
}
}
// 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(),
});
return null;
},
});
// Delete a post
export const deletePost = mutation({
args: { id: v.id("posts") },
returns: v.null(),
handler: async (ctx, args) => {
const existing = await ctx.db.get(args.id);
if (!existing) {
throw new Error("Post not found");
}
await ctx.db.delete(args.id);
return null;
},
});
// Create a new page via dashboard
export const createPage = mutation({
args: { page: pageDataValidator },
returns: v.id("pages"),
handler: async (ctx, args) => {
// Check if slug already exists
const existing = await ctx.db
.query("pages")
.withIndex("by_slug", (q) => q.eq("slug", args.page.slug))
.first();
if (existing) {
throw new Error(`Page with slug "${args.page.slug}" already exists`);
}
const pageId = await ctx.db.insert("pages", {
...args.page,
source: "dashboard",
lastSyncedAt: Date.now(),
});
return pageId;
},
});
// Update any page (dashboard or synced)
export const updatePage = mutation({
args: {
id: v.id("pages"),
page: v.object({
slug: v.optional(v.string()),
title: v.optional(v.string()),
content: v.optional(v.string()),
published: v.optional(v.boolean()),
order: v.optional(v.number()),
showInNav: v.optional(v.boolean()),
excerpt: v.optional(v.string()),
image: v.optional(v.string()),
showImageAtTop: v.optional(v.boolean()),
featured: v.optional(v.boolean()),
featuredOrder: v.optional(v.number()),
authorName: v.optional(v.string()),
authorImage: v.optional(v.string()),
layout: v.optional(v.string()),
rightSidebar: v.optional(v.boolean()),
showFooter: v.optional(v.boolean()),
footer: v.optional(v.string()),
showSocialFooter: v.optional(v.boolean()),
aiChat: v.optional(v.boolean()),
contactForm: v.optional(v.boolean()),
newsletter: v.optional(v.boolean()),
textAlign: v.optional(v.string()),
docsSection: v.optional(v.boolean()),
docsSectionGroup: v.optional(v.string()),
docsSectionOrder: v.optional(v.number()),
docsSectionGroupOrder: v.optional(v.number()),
docsSectionGroupIcon: v.optional(v.string()),
docsLanding: v.optional(v.boolean()),
}),
},
returns: v.null(),
handler: async (ctx, args) => {
const existing = await ctx.db.get(args.id);
if (!existing) {
throw new Error("Page not found");
}
// If slug is being changed, check for conflicts
const newSlug = args.page.slug;
if (newSlug && newSlug !== existing.slug) {
const slugConflict = await ctx.db
.query("pages")
.withIndex("by_slug", (q) => q.eq("slug", newSlug))
.first();
if (slugConflict) {
throw new Error(`Page with slug "${newSlug}" already exists`);
}
}
// 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(),
});
return null;
},
});
// Delete a page
export const deletePage = mutation({
args: { id: v.id("pages") },
returns: v.null(),
handler: async (ctx, args) => {
const existing = await ctx.db.get(args.id);
if (!existing) {
throw new Error("Page not found");
}
await ctx.db.delete(args.id);
return null;
},
});
// Export post as markdown with frontmatter
export const exportPostAsMarkdown = query({
args: { id: v.id("posts") },
returns: v.string(),
handler: async (ctx, args) => {
const post = await ctx.db.get(args.id);
if (!post) {
throw new Error("Post not found");
}
// Build frontmatter
const frontmatter: string[] = ["---"];
frontmatter.push(`title: "${post.title.replace(/"/g, '\\"')}"`);
frontmatter.push(`description: "${post.description.replace(/"/g, '\\"')}"`);
frontmatter.push(`date: "${post.date}"`);
frontmatter.push(`slug: "${post.slug}"`);
frontmatter.push(`published: ${post.published}`);
frontmatter.push(`tags: [${post.tags.map((t) => `"${t}"`).join(", ")}]`);
// Add optional fields
if (post.readTime) frontmatter.push(`readTime: "${post.readTime}"`);
if (post.image) frontmatter.push(`image: "${post.image}"`);
if (post.showImageAtTop !== undefined)
frontmatter.push(`showImageAtTop: ${post.showImageAtTop}`);
if (post.excerpt)
frontmatter.push(`excerpt: "${post.excerpt.replace(/"/g, '\\"')}"`);
if (post.featured !== undefined)
frontmatter.push(`featured: ${post.featured}`);
if (post.featuredOrder !== undefined)
frontmatter.push(`featuredOrder: ${post.featuredOrder}`);
if (post.authorName) frontmatter.push(`authorName: "${post.authorName}"`);
if (post.authorImage) frontmatter.push(`authorImage: "${post.authorImage}"`);
if (post.layout) frontmatter.push(`layout: "${post.layout}"`);
if (post.rightSidebar !== undefined)
frontmatter.push(`rightSidebar: ${post.rightSidebar}`);
if (post.showFooter !== undefined)
frontmatter.push(`showFooter: ${post.showFooter}`);
if (post.footer)
frontmatter.push(`footer: "${post.footer.replace(/"/g, '\\"')}"`);
if (post.showSocialFooter !== undefined)
frontmatter.push(`showSocialFooter: ${post.showSocialFooter}`);
if (post.aiChat !== undefined) frontmatter.push(`aiChat: ${post.aiChat}`);
if (post.blogFeatured !== undefined)
frontmatter.push(`blogFeatured: ${post.blogFeatured}`);
if (post.newsletter !== undefined)
frontmatter.push(`newsletter: ${post.newsletter}`);
if (post.contactForm !== undefined)
frontmatter.push(`contactForm: ${post.contactForm}`);
if (post.unlisted !== undefined)
frontmatter.push(`unlisted: ${post.unlisted}`);
if (post.docsSection !== undefined)
frontmatter.push(`docsSection: ${post.docsSection}`);
if (post.docsSectionGroup)
frontmatter.push(`docsSectionGroup: "${post.docsSectionGroup}"`);
if (post.docsSectionOrder !== undefined)
frontmatter.push(`docsSectionOrder: ${post.docsSectionOrder}`);
if (post.docsSectionGroupOrder !== undefined)
frontmatter.push(`docsSectionGroupOrder: ${post.docsSectionGroupOrder}`);
if (post.docsSectionGroupIcon)
frontmatter.push(`docsSectionGroupIcon: "${post.docsSectionGroupIcon}"`);
if (post.docsLanding !== undefined)
frontmatter.push(`docsLanding: ${post.docsLanding}`);
frontmatter.push("---");
return `${frontmatter.join("\n")}\n\n${post.content}`;
},
});
// Export page as markdown with frontmatter
export const exportPageAsMarkdown = query({
args: { id: v.id("pages") },
returns: v.string(),
handler: async (ctx, args) => {
const page = await ctx.db.get(args.id);
if (!page) {
throw new Error("Page not found");
}
// Build frontmatter
const frontmatter: string[] = ["---"];
frontmatter.push(`title: "${page.title.replace(/"/g, '\\"')}"`);
frontmatter.push(`slug: "${page.slug}"`);
frontmatter.push(`published: ${page.published}`);
// Add optional fields
if (page.order !== undefined) frontmatter.push(`order: ${page.order}`);
if (page.showInNav !== undefined)
frontmatter.push(`showInNav: ${page.showInNav}`);
if (page.excerpt)
frontmatter.push(`excerpt: "${page.excerpt.replace(/"/g, '\\"')}"`);
if (page.image) frontmatter.push(`image: "${page.image}"`);
if (page.showImageAtTop !== undefined)
frontmatter.push(`showImageAtTop: ${page.showImageAtTop}`);
if (page.featured !== undefined)
frontmatter.push(`featured: ${page.featured}`);
if (page.featuredOrder !== undefined)
frontmatter.push(`featuredOrder: ${page.featuredOrder}`);
if (page.authorName) frontmatter.push(`authorName: "${page.authorName}"`);
if (page.authorImage) frontmatter.push(`authorImage: "${page.authorImage}"`);
if (page.layout) frontmatter.push(`layout: "${page.layout}"`);
if (page.rightSidebar !== undefined)
frontmatter.push(`rightSidebar: ${page.rightSidebar}`);
if (page.showFooter !== undefined)
frontmatter.push(`showFooter: ${page.showFooter}`);
if (page.footer)
frontmatter.push(`footer: "${page.footer.replace(/"/g, '\\"')}"`);
if (page.showSocialFooter !== undefined)
frontmatter.push(`showSocialFooter: ${page.showSocialFooter}`);
if (page.aiChat !== undefined) frontmatter.push(`aiChat: ${page.aiChat}`);
if (page.contactForm !== undefined)
frontmatter.push(`contactForm: ${page.contactForm}`);
if (page.newsletter !== undefined)
frontmatter.push(`newsletter: ${page.newsletter}`);
if (page.textAlign) frontmatter.push(`textAlign: "${page.textAlign}"`);
if (page.docsSection !== undefined)
frontmatter.push(`docsSection: ${page.docsSection}`);
if (page.docsSectionGroup)
frontmatter.push(`docsSectionGroup: "${page.docsSectionGroup}"`);
if (page.docsSectionOrder !== undefined)
frontmatter.push(`docsSectionOrder: ${page.docsSectionOrder}`);
if (page.docsSectionGroupOrder !== undefined)
frontmatter.push(`docsSectionGroupOrder: ${page.docsSectionGroupOrder}`);
if (page.docsSectionGroupIcon)
frontmatter.push(`docsSectionGroupIcon: "${page.docsSectionGroupIcon}"`);
if (page.docsLanding !== undefined)
frontmatter.push(`docsLanding: ${page.docsLanding}`);
frontmatter.push("---");
return `${frontmatter.join("\n")}\n\n${page.content}`;
},
});