blog editor
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
// https://orm.drizzle.team/docs/sql-schema-declaration
|
||||
|
||||
import { relations, sql } from "drizzle-orm";
|
||||
import { index, pgEnum, pgSchema, pgTableCreator } from "drizzle-orm/pg-core";
|
||||
import { index, pgEnum, pgSchema, pgTableCreator, uniqueIndex } from "drizzle-orm/pg-core";
|
||||
|
||||
/**
|
||||
* This is an example of how to use the multi-project schema feature of Drizzle ORM. Use the same
|
||||
@@ -104,6 +104,33 @@ export const music = createTable(
|
||||
})
|
||||
)
|
||||
|
||||
export const blogPost = createTable(
|
||||
"blog_post",
|
||||
(d) => ({
|
||||
id: d.uuid().primaryKey().defaultRandom(),
|
||||
slug: d.varchar({ length: 200 }).notNull(),
|
||||
title: d.varchar({ length: 200 }).notNull(),
|
||||
date: d.varchar({ length: 20 }),
|
||||
description: d.text(),
|
||||
tags: d.text().array(),
|
||||
fileKey: d.varchar("file_key", { length: 200 }).notNull(),
|
||||
fileUrl: d.varchar("file_url", { length: 500 }).notNull(),
|
||||
fileName: d.varchar("file_name", { length: 255 }).notNull(),
|
||||
customId: d.varchar("custom_id", { length: 255 }).notNull(),
|
||||
createdAt: d
|
||||
.timestamp({ withTimezone: true })
|
||||
.default(sql`CURRENT_TIMESTAMP`)
|
||||
.notNull()
|
||||
.$type<Date>(),
|
||||
updatedAt: d.timestamp({ withTimezone: true }).$onUpdate(() => new Date()).$type<Date>(),
|
||||
}),
|
||||
(t) => [
|
||||
uniqueIndex("blog_post_slug_idx").on(t.slug),
|
||||
uniqueIndex("blog_post_file_key_idx").on(t.fileKey),
|
||||
uniqueIndex("blog_post_custom_id_idx").on(t.customId),
|
||||
],
|
||||
)
|
||||
|
||||
export const messageRoleEnum = pgEnum('message_role', ['user', 'assistant'])
|
||||
|
||||
export const chatSession = createTable(
|
||||
|
||||
@@ -1,142 +1,360 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { desc, eq, or } from "drizzle-orm";
|
||||
import matter from "gray-matter";
|
||||
import { UTApi } from "uploadthing/server";
|
||||
import { UTApi, UTFile } from "uploadthing/server";
|
||||
import z from "zod";
|
||||
import { isAdmin } from "~/app/actions";
|
||||
import { env } from "~/env.js";
|
||||
import { db } from "~/server/db";
|
||||
import { blogPost } from "~/server/dbschema/schema";
|
||||
import { publicProcedure, router } from "~/server/trpc";
|
||||
|
||||
const utapi = new UTApi({ token: env.UPLOADTHING_TOKEN });
|
||||
|
||||
function fileToSlug(name: string, folder: string): string {
|
||||
const prefix = folder.endsWith("/") ? folder : `${folder}/`;
|
||||
const withoutFolder = name.startsWith(prefix) ? name.slice(prefix.length) : name;
|
||||
return withoutFolder.replace(/\.mdx?$/, "");
|
||||
const blogPostInput = z.object({
|
||||
slug: z.string().min(1),
|
||||
title: z.string().min(1),
|
||||
date: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
content: z.string(),
|
||||
});
|
||||
|
||||
type BlogPostInput = z.infer<typeof blogPostInput>;
|
||||
|
||||
type UploadThingFile = Awaited<ReturnType<typeof utapi.listFiles>>["files"][number];
|
||||
|
||||
function cleanPrefix(): string {
|
||||
return env.BLOG_MDX_PREFIX.trim().replace(/^\/+|\/+$/g, "");
|
||||
}
|
||||
|
||||
function fileUrl(key: string): string {
|
||||
return `https://utfs.io/f/${key}`;
|
||||
function blogFileName(slug: string): string {
|
||||
const prefix = cleanPrefix();
|
||||
return prefix ? `${prefix}-${slug}.mdx` : `${slug}.mdx`;
|
||||
}
|
||||
|
||||
async function fetchMdx(key: string): Promise<string> {
|
||||
const res = await fetch(fileUrl(key), { next: { revalidate: 3600 } });
|
||||
function blogCustomId(slug: string): string {
|
||||
const prefix = cleanPrefix();
|
||||
const id = crypto.randomUUID();
|
||||
return prefix ? `${prefix}:${slug}:${id}` : `${slug}:${id}`;
|
||||
}
|
||||
|
||||
function optionalText(value: string | undefined): string | null {
|
||||
const trimmed = value?.trim();
|
||||
return trimmed ? trimmed : null;
|
||||
}
|
||||
|
||||
function frontmatterText(value: unknown): string | null {
|
||||
if (value instanceof Date) return value.toISOString().slice(0, 10);
|
||||
return optionalText(typeof value === "string" ? value : undefined);
|
||||
}
|
||||
|
||||
function normalizeTags(tags: unknown): string[] {
|
||||
const values = Array.isArray(tags)
|
||||
? tags
|
||||
: typeof tags === "string"
|
||||
? tags.split(",")
|
||||
: [];
|
||||
|
||||
return Array.from(
|
||||
new Set(
|
||||
values
|
||||
.map((tag) => String(tag).trim())
|
||||
.filter(Boolean),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function createMdxContent(input: BlogPostInput): string {
|
||||
const date = optionalText(input.date);
|
||||
const description = optionalText(input.description);
|
||||
const tags = normalizeTags(input.tags);
|
||||
const frontmatter: Record<string, unknown> = { slug: input.slug, title: input.title };
|
||||
if (date) frontmatter.date = date;
|
||||
if (description) frontmatter.description = description;
|
||||
if (tags.length > 0) frontmatter.tags = tags;
|
||||
return matter.stringify(input.content, frontmatter);
|
||||
}
|
||||
|
||||
function summaryFromInput(input: BlogPostInput) {
|
||||
return {
|
||||
slug: input.slug,
|
||||
title: input.title,
|
||||
date: optionalText(input.date),
|
||||
description: optionalText(input.description),
|
||||
tags: normalizeTags(input.tags),
|
||||
};
|
||||
}
|
||||
|
||||
async function assertAdmin() {
|
||||
const admin = await isAdmin();
|
||||
if (!admin) throw new TRPCError({ code: "FORBIDDEN", message: "Access denied" });
|
||||
}
|
||||
|
||||
async function uploadMdx(input: BlogPostInput) {
|
||||
const mdxContent = createMdxContent(input);
|
||||
const customId = blogCustomId(input.slug);
|
||||
const file = new UTFile([mdxContent], blogFileName(input.slug), {
|
||||
customId,
|
||||
type: "text/plain",
|
||||
});
|
||||
const result = await utapi.uploadFiles(file);
|
||||
|
||||
if (result.error) {
|
||||
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: result.error.message });
|
||||
}
|
||||
|
||||
if (!result.data.ufsUrl) {
|
||||
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "UploadThing did not return a file URL" });
|
||||
}
|
||||
|
||||
return {
|
||||
fileKey: result.data.key,
|
||||
fileUrl: result.data.ufsUrl,
|
||||
fileName: result.data.name,
|
||||
customId: result.data.customId ?? customId,
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchMdx(fileUrl: string): Promise<string> {
|
||||
const res = await fetch(fileUrl, { next: { revalidate: 3600 } });
|
||||
if (!res.ok) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Failed to fetch MDX file" });
|
||||
return res.text();
|
||||
}
|
||||
|
||||
async function getBlogFiles() {
|
||||
const folder = env.BLOG_MDX_FOLDER;
|
||||
const { files } = await utapi.listFiles();
|
||||
return files.filter(
|
||||
(f) => f.name.startsWith(`${folder}/`) && /\.mdx?$/.test(f.name),
|
||||
);
|
||||
function fileUrl(file: Pick<UploadThingFile, "key">): string {
|
||||
return `https://utfs.io/f/${file.key}`;
|
||||
}
|
||||
|
||||
function slugFromFileName(name: string): string {
|
||||
const prefix = cleanPrefix();
|
||||
const withoutExtension = name.replace(/\.mdx?$/, "");
|
||||
if (prefix && withoutExtension.startsWith(`${prefix}-`)) return withoutExtension.slice(prefix.length + 1);
|
||||
if (prefix && withoutExtension.startsWith(`${prefix}/`)) return withoutExtension.slice(prefix.length + 1);
|
||||
return withoutExtension;
|
||||
}
|
||||
|
||||
function slugFromCustomId(customId: string): string {
|
||||
const prefix = cleanPrefix();
|
||||
const value = prefix && customId.startsWith(`${prefix}:`)
|
||||
? customId.slice(prefix.length + 1)
|
||||
: customId;
|
||||
return value.split(":")[0] ?? value;
|
||||
}
|
||||
|
||||
function fileMatchesPrefix(file: Pick<UploadThingFile, "name">): boolean {
|
||||
if (!/\.mdx?$/.test(file.name)) return false;
|
||||
|
||||
const prefix = cleanPrefix();
|
||||
if (!prefix) return true;
|
||||
|
||||
return file.name.startsWith(`${prefix}-`) || file.name.startsWith(`${prefix}/`);
|
||||
}
|
||||
|
||||
function metadataFromFile(file: UploadThingFile, raw: string) {
|
||||
const parsed = matter(raw);
|
||||
const fallbackSlug = file.customId ? slugFromCustomId(file.customId) : slugFromFileName(file.name);
|
||||
|
||||
return {
|
||||
slug: String(parsed.data.slug ?? fallbackSlug),
|
||||
title: String(parsed.data.title ?? fallbackSlug),
|
||||
date: frontmatterText(parsed.data.date),
|
||||
description: frontmatterText(parsed.data.description),
|
||||
tags: normalizeTags(parsed.data.tags),
|
||||
fileKey: file.key,
|
||||
fileUrl: fileUrl(file),
|
||||
fileName: file.name,
|
||||
customId: file.customId ?? blogCustomId(fallbackSlug),
|
||||
};
|
||||
}
|
||||
|
||||
async function listAllFiles(): Promise<readonly UploadThingFile[]> {
|
||||
const files: UploadThingFile[] = [];
|
||||
let offset = 0;
|
||||
const limit = 500;
|
||||
let hasMore = true;
|
||||
|
||||
while (hasMore) {
|
||||
const page = await utapi.listFiles({ limit, offset });
|
||||
files.push(...page.files);
|
||||
hasMore = page.hasMore;
|
||||
offset += limit;
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
export const blogRouter = router({
|
||||
insert: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
slug: z.string().min(1),
|
||||
title: z.string().min(1),
|
||||
date: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
content: z.string(),
|
||||
}),
|
||||
)
|
||||
.input(blogPostInput)
|
||||
.mutation(async ({ input }) => {
|
||||
const folder = env.BLOG_MDX_FOLDER;
|
||||
const frontmatter: Record<string, unknown> = { title: input.title };
|
||||
if (input.date) frontmatter.date = input.date;
|
||||
if (input.description) frontmatter.description = input.description;
|
||||
const mdxContent = matter.stringify(input.content, frontmatter);
|
||||
const file = new File([mdxContent], `${folder}/${input.slug}.mdx`, { type: "text/plain" });
|
||||
const result = await utapi.uploadFiles(file);
|
||||
if (result.error) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: result.error.message });
|
||||
return [{ slug: input.slug, title: input.title, date: input.date, description: input.description }];
|
||||
await assertAdmin();
|
||||
|
||||
const existing = await db.query.blogPost.findFirst({
|
||||
where(fields, operators) {
|
||||
return operators.eq(fields.slug, input.slug);
|
||||
},
|
||||
});
|
||||
if (existing) throw new TRPCError({ code: "CONFLICT", message: `Post "${input.slug}" already exists` });
|
||||
|
||||
const uploaded = await uploadMdx(input);
|
||||
|
||||
try {
|
||||
await db.insert(blogPost).values({
|
||||
slug: input.slug,
|
||||
title: input.title,
|
||||
date: optionalText(input.date),
|
||||
description: optionalText(input.description),
|
||||
tags: normalizeTags(input.tags),
|
||||
...uploaded,
|
||||
});
|
||||
return [summaryFromInput(input)];
|
||||
} catch (error) {
|
||||
await utapi.deleteFiles(uploaded.fileKey);
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
|
||||
update: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
slug: z.string().min(1),
|
||||
originalSlug: z.string().min(1),
|
||||
title: z.string().min(1),
|
||||
date: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
content: z.string(),
|
||||
}),
|
||||
)
|
||||
.input(blogPostInput.extend({ originalSlug: z.string().min(1) }))
|
||||
.mutation(async ({ input }) => {
|
||||
const folder = env.BLOG_MDX_FOLDER;
|
||||
const files = await getBlogFiles();
|
||||
const old = files.find(
|
||||
(f) => f.name === `${folder}/${input.originalSlug}.mdx` || f.name === `${folder}/${input.originalSlug}.md`,
|
||||
);
|
||||
if (old) await utapi.deleteFiles(old.key);
|
||||
const frontmatter: Record<string, unknown> = { title: input.title };
|
||||
if (input.date) frontmatter.date = input.date;
|
||||
if (input.description) frontmatter.description = input.description;
|
||||
const mdxContent = matter.stringify(input.content, frontmatter);
|
||||
const file = new File([mdxContent], `${folder}/${input.slug}.mdx`, { type: "text/plain" });
|
||||
const result = await utapi.uploadFiles(file);
|
||||
if (result.error) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: result.error.message });
|
||||
return [{ slug: input.slug, title: input.title, date: input.date, description: input.description, content: input.content }];
|
||||
await assertAdmin();
|
||||
|
||||
const existing = await db.query.blogPost.findFirst({
|
||||
where(fields, operators) {
|
||||
return operators.eq(fields.slug, input.originalSlug);
|
||||
},
|
||||
});
|
||||
if (!existing) throw new TRPCError({ code: "NOT_FOUND", message: `Post "${input.originalSlug}" not found` });
|
||||
|
||||
if (input.slug !== input.originalSlug) {
|
||||
const slugConflict = await db.query.blogPost.findFirst({
|
||||
where(fields, operators) {
|
||||
return operators.eq(fields.slug, input.slug);
|
||||
},
|
||||
});
|
||||
if (slugConflict) throw new TRPCError({ code: "CONFLICT", message: `Post "${input.slug}" already exists` });
|
||||
}
|
||||
|
||||
const uploaded = await uploadMdx(input);
|
||||
|
||||
try {
|
||||
await db.update(blogPost).set({
|
||||
slug: input.slug,
|
||||
title: input.title,
|
||||
date: optionalText(input.date),
|
||||
description: optionalText(input.description),
|
||||
tags: normalizeTags(input.tags),
|
||||
...uploaded,
|
||||
}).where(eq(blogPost.id, existing.id));
|
||||
|
||||
await utapi.deleteFiles(existing.fileKey);
|
||||
return [summaryFromInput(input)];
|
||||
} catch (error) {
|
||||
await utapi.deleteFiles(uploaded.fileKey);
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
|
||||
delete: publicProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(async ({ input }) => {
|
||||
const folder = env.BLOG_MDX_FOLDER;
|
||||
const files = await getBlogFiles();
|
||||
const file = files.find(
|
||||
(f) => f.name === `${folder}/${input.id}.mdx` || f.name === `${folder}/${input.id}.md`,
|
||||
);
|
||||
if (!file) throw new TRPCError({ code: "NOT_FOUND", message: `Post "${input.id}" not found` });
|
||||
await utapi.deleteFiles(file.key);
|
||||
await assertAdmin();
|
||||
|
||||
const post = await db.query.blogPost.findFirst({
|
||||
where(fields, operators) {
|
||||
return operators.eq(fields.slug, input.id);
|
||||
},
|
||||
});
|
||||
if (!post) throw new TRPCError({ code: "NOT_FOUND", message: `Post "${input.id}" not found` });
|
||||
|
||||
await db.delete(blogPost).where(eq(blogPost.id, post.id));
|
||||
await utapi.deleteFiles(post.fileKey);
|
||||
return [];
|
||||
}),
|
||||
|
||||
list: publicProcedure.query(async () => {
|
||||
const folder = env.BLOG_MDX_FOLDER;
|
||||
const files = await getBlogFiles();
|
||||
|
||||
const posts = await Promise.all(
|
||||
files.map(async (file) => {
|
||||
const raw = await fetchMdx(file.key);
|
||||
const { data } = matter(raw);
|
||||
return {
|
||||
slug: fileToSlug(file.name, folder),
|
||||
title: (data.title as string | undefined) ?? fileToSlug(file.name, folder),
|
||||
date: data.date as string | undefined,
|
||||
description: data.description as string | undefined,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
return posts.sort((a, b) => {
|
||||
if (!a.date || !b.date) return 0;
|
||||
return new Date(b.date).getTime() - new Date(a.date).getTime();
|
||||
});
|
||||
return db.select({
|
||||
slug: blogPost.slug,
|
||||
title: blogPost.title,
|
||||
date: blogPost.date,
|
||||
description: blogPost.description,
|
||||
tags: blogPost.tags,
|
||||
}).from(blogPost).orderBy(desc(blogPost.date), desc(blogPost.createdAt));
|
||||
}),
|
||||
|
||||
bySlug: publicProcedure.input(z.string()).query(async ({ input: slug }) => {
|
||||
const folder = env.BLOG_MDX_FOLDER;
|
||||
const files = await getBlogFiles();
|
||||
const post = await db.query.blogPost.findFirst({
|
||||
where(fields, operators) {
|
||||
return operators.eq(fields.slug, slug);
|
||||
},
|
||||
});
|
||||
|
||||
const file = files.find(
|
||||
(f) => f.name === `${folder}/${slug}.mdx` || f.name === `${folder}/${slug}.md`,
|
||||
);
|
||||
if (!post) throw new TRPCError({ code: "NOT_FOUND", message: `Post "${slug}" not found` });
|
||||
|
||||
if (!file) throw new TRPCError({ code: "NOT_FOUND", message: `Post "${slug}" not found` });
|
||||
|
||||
const raw = await fetchMdx(file.key);
|
||||
const raw = await fetchMdx(post.fileUrl);
|
||||
const { content, data } = matter(raw);
|
||||
|
||||
return {
|
||||
slug,
|
||||
slug: post.slug,
|
||||
content,
|
||||
title: (data.title as string | undefined) ?? slug,
|
||||
date: data.date as string | undefined,
|
||||
description: data.description as string | undefined,
|
||||
title: (data.title as string | undefined) ?? post.title,
|
||||
date: frontmatterText(data.date) ?? post.date,
|
||||
description: frontmatterText(data.description) ?? post.description,
|
||||
tags: normalizeTags(data.tags).length > 0 ? normalizeTags(data.tags) : (post.tags ?? []),
|
||||
};
|
||||
}),
|
||||
|
||||
syncFromUploadThing: publicProcedure.mutation(async () => {
|
||||
await assertAdmin();
|
||||
|
||||
const files = (await listAllFiles()).filter(fileMatchesPrefix);
|
||||
const seenFileKeys = new Set<string>();
|
||||
const seenSlugs = new Set<string>();
|
||||
let created = 0;
|
||||
let updated = 0;
|
||||
let skipped = 0;
|
||||
let deleted = 0;
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
const raw = await fetchMdx(fileUrl(file));
|
||||
const metadata = metadataFromFile(file, raw);
|
||||
seenFileKeys.add(file.key);
|
||||
seenSlugs.add(metadata.slug);
|
||||
const existing = await db.query.blogPost.findFirst({
|
||||
where(fields, operators) {
|
||||
return or(operators.eq(fields.fileKey, file.key), operators.eq(fields.slug, metadata.slug));
|
||||
},
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
await db.update(blogPost).set(metadata).where(eq(blogPost.id, existing.id));
|
||||
updated += 1;
|
||||
} else {
|
||||
await db.insert(blogPost).values(metadata);
|
||||
created += 1;
|
||||
}
|
||||
} catch {
|
||||
skipped += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const posts = await db.select({
|
||||
id: blogPost.id,
|
||||
fileKey: blogPost.fileKey,
|
||||
slug: blogPost.slug,
|
||||
}).from(blogPost);
|
||||
|
||||
const stalePostIds = posts
|
||||
.filter((post) => !seenFileKeys.has(post.fileKey) && !seenSlugs.has(post.slug))
|
||||
.map((post) => post.id);
|
||||
|
||||
for (const id of stalePostIds) {
|
||||
await db.delete(blogPost).where(eq(blogPost.id, id));
|
||||
deleted += 1;
|
||||
}
|
||||
|
||||
return { created, updated, skipped, deleted };
|
||||
}),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user