From 4822125787bdf5103a6741e9d54af07459108fb3 Mon Sep 17 00:00:00 2001 From: Stan Runge Date: Mon, 31 Aug 2026 16:39:56 +0000 Subject: [PATCH] add blog editor --- src/lib/server/auth.ts | 7 +- src/lib/server/db/schema.ts | 23 ++-- src/lib/server/slugify.ts | 29 +++++ src/lib/validation/blog.ts | 10 ++ src/routes/admin/login/+server.ts | 25 ++++ src/routes/blog/+page.server.ts | 51 +++++--- src/routes/blog/+page.svelte | 122 ++++++++++++++++++-- src/routes/blog/[slug]/+page.server.ts | 35 ++++++ src/routes/blog/[slug]/+page.svelte | 21 ++++ src/routes/blog/[slug]/edit/+page.server.ts | 90 +++++++++++++++ src/routes/blog/[slug]/edit/+page.svelte | 73 ++++++++++++ src/routes/blog/{slug}/+page.server.ts | 20 ---- src/routes/blog/{slug}/+page.svelte | 1 - 13 files changed, 447 insertions(+), 60 deletions(-) create mode 100644 src/lib/server/slugify.ts create mode 100644 src/lib/validation/blog.ts create mode 100644 src/routes/admin/login/+server.ts create mode 100644 src/routes/blog/[slug]/+page.server.ts create mode 100644 src/routes/blog/[slug]/+page.svelte create mode 100644 src/routes/blog/[slug]/edit/+page.server.ts create mode 100644 src/routes/blog/[slug]/edit/+page.svelte delete mode 100644 src/routes/blog/{slug}/+page.server.ts delete mode 100644 src/routes/blog/{slug}/+page.svelte diff --git a/src/lib/server/auth.ts b/src/lib/server/auth.ts index 978cc1b..bb3fec3 100644 --- a/src/lib/server/auth.ts +++ b/src/lib/server/auth.ts @@ -5,7 +5,7 @@ import { sveltekitCookies } from 'better-auth/svelte-kit'; import { getRequestEvent } from '$app/server'; import { db } from '$lib/server/db'; import * as schema from '$lib/server/db/schema'; -import type { RequestEvent } from '@sveltejs/kit'; +import { error, type RequestEvent } from '@sveltejs/kit'; export const auth = betterAuth({ baseURL: env.ORIGIN ?? 'http://localhost:3000', @@ -30,9 +30,6 @@ export function isAdmin(event: RequestEvent): boolean { export function requireAdmin(event: RequestEvent): void { if (!isAdmin(event)) { - throw new Response(JSON.stringify({ error: 'Unauthorized' }), { - status: 401, - headers: { 'Content-Type': 'application/json' } - }); + error(401, { message: 'Unauthorized' }); } } diff --git a/src/lib/server/db/schema.ts b/src/lib/server/db/schema.ts index 108bafd..cfb548f 100755 --- a/src/lib/server/db/schema.ts +++ b/src/lib/server/db/schema.ts @@ -69,14 +69,21 @@ export const publications = pgTable('publications', { // Legacy ^ // New -export const blogArticles = pgTable('blog_articles', { - id: serial('id').primaryKey(), - title: varchar('title', { length: 256 }), - slug: varchar('slug', { length: 256 }), - content: varchar('content', { length: 8192 }), - createdAt: timestamp('created_at').defaultNow(), - updatedAt: timestamp('updated_at').defaultNow() -}); +export const blogArticleStatus = pgEnum('blog_article_status', ['draft', 'published']); + +export const blogArticles = pgTable( + 'blog_articles', + { + id: serial('id').primaryKey(), + title: varchar('title', { length: 256 }).notNull(), + slug: varchar('slug', { length: 256 }).notNull(), + content: text('content').default(''), + status: blogArticleStatus('status').notNull().default('draft'), + createdAt: timestamp('created_at').defaultNow(), + updatedAt: timestamp('updated_at').defaultNow() + }, + (table) => [unique().on(table.slug)] +); // CatchEmAll diff --git a/src/lib/server/slugify.ts b/src/lib/server/slugify.ts new file mode 100644 index 0000000..237be2f --- /dev/null +++ b/src/lib/server/slugify.ts @@ -0,0 +1,29 @@ +import { db } from '$lib/server/db'; +import { blogArticles } from '$lib/server/db/schema'; +import { eq } from 'drizzle-orm'; + +export function slugify(title: string): string { + return ( + title + .toLowerCase() + .trim() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') || 'article' + ); +} + +export async function uniqueSlug(title: string): Promise { + const base = slugify(title); + let slug = base; + let suffix = 2; + + while ( + (await db.select({ id: blogArticles.id }).from(blogArticles).where(eq(blogArticles.slug, slug))) + .length > 0 + ) { + slug = `${base}-${suffix}`; + suffix += 1; + } + + return slug; +} diff --git a/src/lib/validation/blog.ts b/src/lib/validation/blog.ts new file mode 100644 index 0000000..694e9e8 --- /dev/null +++ b/src/lib/validation/blog.ts @@ -0,0 +1,10 @@ +import { z } from 'zod'; + +export const createArticleSchema = z.object({ + title: z.string().min(1).max(256) +}); + +export const updateArticleSchema = z.object({ + title: z.string().min(1).max(256), + content: z.string().max(50000) +}); diff --git a/src/routes/admin/login/+server.ts b/src/routes/admin/login/+server.ts new file mode 100644 index 0000000..e2cd02c --- /dev/null +++ b/src/routes/admin/login/+server.ts @@ -0,0 +1,25 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { env } from '$env/dynamic/private'; +import { z } from 'zod'; + +const loginSchema = z.object({ password: z.string().min(1) }); + +export const POST: RequestHandler = async ({ request, cookies }) => { + const body = await request.json().catch(() => null); + const parsed = loginSchema.safeParse(body); + + if (!parsed.success || parsed.data.password !== env.ADMIN_SECRET) { + throw error(401, { message: 'Invalid password' }); + } + + cookies.set('admin_token', env.ADMIN_SECRET, { + path: '/', + httpOnly: true, + sameSite: 'strict', + secure: process.env.NODE_ENV === 'production', + maxAge: 60 * 60 * 24 * 7 + }); + + return json({ ok: true }); +}; diff --git a/src/routes/blog/+page.server.ts b/src/routes/blog/+page.server.ts index bb617f5..52ba94b 100644 --- a/src/routes/blog/+page.server.ts +++ b/src/routes/blog/+page.server.ts @@ -1,29 +1,50 @@ import { db } from '$lib/server/db'; import { blogArticles } from '$lib/server/db/schema'; -import { redirect } from '@sveltejs/kit'; +import { isAdmin, requireAdmin } from '$lib/server/auth'; +import { uniqueSlug } from '$lib/server/slugify'; +import { createArticleSchema } from '$lib/validation/blog'; +import { desc, eq } from 'drizzle-orm'; +import { fail, redirect } from '@sveltejs/kit'; import type { PageServerLoad } from './$types.js'; -export const load: PageServerLoad = async () => { - const articles = await db.select().from(blogArticles); +export const load: PageServerLoad = async (event) => { + const admin = isAdmin(event); + + const articles = admin + ? await db.select().from(blogArticles).orderBy(desc(blogArticles.createdAt)) + : await db + .select() + .from(blogArticles) + .where(eq(blogArticles.status, 'published')) + .orderBy(desc(blogArticles.createdAt)); return { - blogArticles: articles + blogArticles: articles, + isAdmin: admin }; }; export const actions = { - default: async ({ request }): Promise => { - const formData = await request.formData(); + default: async (event) => { + requireAdmin(event); - const blogArticle = await db - .insert(blogArticles) - .values({ - title: formData.get('title')?.toString(), - slug: formData.get('slug')?.toString(), - content: formData.get('content')?.toString() - }) - .returning(); + const formData = await event.request.formData(); + const parsed = createArticleSchema.safeParse({ + title: formData.get('title')?.toString() + }); - return redirect(303, `/blog/${blogArticle[0].slug}`); + if (!parsed.success) { + return fail(400, { message: 'Title is required' }); + } + + const slug = await uniqueSlug(parsed.data.title); + + await db.insert(blogArticles).values({ + title: parsed.data.title, + slug, + status: 'draft' + }); + + return redirect(303, `/blog/${slug}/edit`); } }; diff --git a/src/routes/blog/+page.svelte b/src/routes/blog/+page.svelte index 8f2f95a..bcfe8e0 100644 --- a/src/routes/blog/+page.svelte +++ b/src/routes/blog/+page.svelte @@ -1,31 +1,131 @@

Blog (Stan's yapping corner)

- + {#if data.isAdmin} + + {:else} + + {/if}
{#each data.blogArticles as article (article.slug)} -
- {article.title} +
+ {article.title} + {#if data.isAdmin && article.status === 'draft'} + draft + edit + {/if}

{article.createdAt?.toDateString()}

{/each} {#if createNewArticlePopup} -
-
-

Create New Article

-
-
+
+
{ + return async ({ result }) => { + if (result.type !== 'redirect') { + createNewArticlePopup = false; + } + }; + }} + class="w-80 rounded bg-white p-6 shadow-xl" + > +

Create New Article

+ +
+ + +
+
+
+{/if} + +{#if loginPopup} +
+
+

Admin login

+ e.key === 'Enter' && login()} + class="mb-3 w-full rounded border border-gray-300 px-3 py-2 text-sm outline-none" + /> + {#if loginError} +

{loginError}

+ {/if} +
+ + +
+
+
{/if} diff --git a/src/routes/blog/[slug]/+page.server.ts b/src/routes/blog/[slug]/+page.server.ts new file mode 100644 index 0000000..c9293ee --- /dev/null +++ b/src/routes/blog/[slug]/+page.server.ts @@ -0,0 +1,35 @@ +import { db } from '$lib/server/db'; +import { blogArticles } from '$lib/server/db/schema'; +import { isAdmin } from '$lib/server/auth'; +import { eq } from 'drizzle-orm'; + +import type { PageServerLoad } from './$types.js'; +import { error, redirect } from '@sveltejs/kit'; + +export const load: PageServerLoad = async (event) => { + const [article] = await db + .select() + .from(blogArticles) + .where(eq(blogArticles.slug, event.params.slug)); + + if (!article) { + return error(404, { message: 'Article not found' }); + } + + const admin = isAdmin(event); + + if (article.status !== 'published') { + if (!admin) { + return error(404, { message: 'Article not found' }); + } + // Drafts are only ever viewed/edited through the editor. + return redirect(303, `/blog/${article.slug}/edit`); + } + + return { + blogArticle: article, + isAdmin: admin + }; +}; + +export const actions = {}; diff --git a/src/routes/blog/[slug]/+page.svelte b/src/routes/blog/[slug]/+page.svelte new file mode 100644 index 0000000..5ef4d8a --- /dev/null +++ b/src/routes/blog/[slug]/+page.svelte @@ -0,0 +1,21 @@ + + +{data.blogArticle.title} - Blog + +
+
+

{data.blogArticle.title}

+ {#if data.isAdmin} + Edit + {/if} +
+

{data.blogArticle.createdAt?.toDateString()}

+
{data.blogArticle.content}
+
diff --git a/src/routes/blog/[slug]/edit/+page.server.ts b/src/routes/blog/[slug]/edit/+page.server.ts new file mode 100644 index 0000000..dd67d63 --- /dev/null +++ b/src/routes/blog/[slug]/edit/+page.server.ts @@ -0,0 +1,90 @@ +import { db } from '$lib/server/db'; +import { blogArticles } from '$lib/server/db/schema'; +import { isAdmin, requireAdmin } from '$lib/server/auth'; +import { updateArticleSchema } from '$lib/validation/blog'; +import { eq } from 'drizzle-orm'; +import { error, fail, redirect } from '@sveltejs/kit'; + +import type { PageServerLoad } from './$types.js'; + +export const load: PageServerLoad = async (event) => { + if (!isAdmin(event)) { + return redirect(303, `/blog/${event.params.slug}`); + } + + const [article] = await db + .select() + .from(blogArticles) + .where(eq(blogArticles.slug, event.params.slug)); + + if (!article) { + return error(404, { message: 'Article not found' }); + } + + return { + blogArticle: article + }; +}; + +export const actions = { + save: async (event) => { + requireAdmin(event); + + const formData = await event.request.formData(); + const parsed = updateArticleSchema.safeParse({ + title: formData.get('title')?.toString(), + content: formData.get('content')?.toString() + }); + + if (!parsed.success) { + return fail(400, { message: 'Title and content are required' }); + } + + const [updated] = await db + .update(blogArticles) + .set({ + title: parsed.data.title, + content: parsed.data.content, + updatedAt: new Date() + }) + .where(eq(blogArticles.slug, event.params.slug)) + .returning(); + + if (!updated) { + return error(404, { message: 'Article not found' }); + } + + return { saved: true }; + }, + + publish: async (event) => { + requireAdmin(event); + + const formData = await event.request.formData(); + const parsed = updateArticleSchema.safeParse({ + title: formData.get('title')?.toString(), + content: formData.get('content')?.toString() + }); + + if (!parsed.success) { + return fail(400, { message: 'Title and content are required' }); + } + + const [updated] = await db + .update(blogArticles) + .set({ + title: parsed.data.title, + content: parsed.data.content, + status: 'published', + updatedAt: new Date() + }) + .where(eq(blogArticles.slug, event.params.slug)) + .returning(); + + if (!updated) { + return error(404, { message: 'Article not found' }); + } + + return redirect(303, `/blog/${updated.slug}`); + } +}; diff --git a/src/routes/blog/[slug]/edit/+page.svelte b/src/routes/blog/[slug]/edit/+page.svelte new file mode 100644 index 0000000..6c68e26 --- /dev/null +++ b/src/routes/blog/[slug]/edit/+page.svelte @@ -0,0 +1,73 @@ + + +Editing {data.blogArticle.title} + +
+
+

+ Editing article + {data.blogArticle.status} +

+ View +
+ + {#if form?.message} +

{form.message}

+ {/if} + +
{ + saving = true; + return async ({ update }) => { + await update(); + saving = false; + }; + }} + class="flex flex-col gap-3" + > + + + +
+ + +
+
+
diff --git a/src/routes/blog/{slug}/+page.server.ts b/src/routes/blog/{slug}/+page.server.ts deleted file mode 100644 index 7e62106..0000000 --- a/src/routes/blog/{slug}/+page.server.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { db } from '$lib/server/db'; -import { blogArticles } from '$lib/server/db/schema'; -import { eq } from 'drizzle-orm'; - -import type { PageServerLoad } from './$types.js'; -import { fail } from '@sveltejs/kit'; - -export const load: PageServerLoad = async ({ params }) => { - const article = await db.select().from(blogArticles).where(eq(blogArticles.slug, params.slug)); - - if (article.length === 0) { - return fail(404, { message: 'Article not found' }); - } - - return { - blogArticle: article[0] - }; -}; - -export const actions = {}; diff --git a/src/routes/blog/{slug}/+page.svelte b/src/routes/blog/{slug}/+page.svelte deleted file mode 100644 index 7e6edb8..0000000 --- a/src/routes/blog/{slug}/+page.svelte +++ /dev/null @@ -1 +0,0 @@ -bu -- 2.49.1