Compare commits
2 Commits
62ae672630
...
4328d81408
| Author | SHA1 | Date | |
|---|---|---|---|
| 4328d81408 | |||
| 4822125787 |
@@ -5,7 +5,7 @@ import { sveltekitCookies } from 'better-auth/svelte-kit';
|
|||||||
import { getRequestEvent } from '$app/server';
|
import { getRequestEvent } from '$app/server';
|
||||||
import { db } from '$lib/server/db';
|
import { db } from '$lib/server/db';
|
||||||
import * as schema from '$lib/server/db/schema';
|
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({
|
export const auth = betterAuth({
|
||||||
baseURL: env.ORIGIN ?? 'http://localhost:3000',
|
baseURL: env.ORIGIN ?? 'http://localhost:3000',
|
||||||
@@ -30,9 +30,6 @@ export function isAdmin(event: RequestEvent): boolean {
|
|||||||
|
|
||||||
export function requireAdmin(event: RequestEvent): void {
|
export function requireAdmin(event: RequestEvent): void {
|
||||||
if (!isAdmin(event)) {
|
if (!isAdmin(event)) {
|
||||||
throw new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
error(401, { message: 'Unauthorized' });
|
||||||
status: 401,
|
|
||||||
headers: { 'Content-Type': 'application/json' }
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,14 +69,21 @@ export const publications = pgTable('publications', {
|
|||||||
// Legacy ^
|
// Legacy ^
|
||||||
// New
|
// New
|
||||||
|
|
||||||
export const blogArticles = pgTable('blog_articles', {
|
export const blogArticleStatus = pgEnum('blog_article_status', ['draft', 'published']);
|
||||||
|
|
||||||
|
export const blogArticles = pgTable(
|
||||||
|
'blog_articles',
|
||||||
|
{
|
||||||
id: serial('id').primaryKey(),
|
id: serial('id').primaryKey(),
|
||||||
title: varchar('title', { length: 256 }),
|
title: varchar('title', { length: 256 }).notNull(),
|
||||||
slug: varchar('slug', { length: 256 }),
|
slug: varchar('slug', { length: 256 }).notNull(),
|
||||||
content: varchar('content', { length: 8192 }),
|
content: text('content').default(''),
|
||||||
|
status: blogArticleStatus('status').notNull().default('draft'),
|
||||||
createdAt: timestamp('created_at').defaultNow(),
|
createdAt: timestamp('created_at').defaultNow(),
|
||||||
updatedAt: timestamp('updated_at').defaultNow()
|
updatedAt: timestamp('updated_at').defaultNow()
|
||||||
});
|
},
|
||||||
|
(table) => [unique().on(table.slug)]
|
||||||
|
);
|
||||||
|
|
||||||
// CatchEmAll
|
// CatchEmAll
|
||||||
|
|
||||||
|
|||||||
29
src/lib/server/slugify.ts
Normal file
29
src/lib/server/slugify.ts
Normal file
@@ -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<string> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
10
src/lib/validation/blog.ts
Normal file
10
src/lib/validation/blog.ts
Normal file
@@ -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)
|
||||||
|
});
|
||||||
25
src/routes/admin/login/+server.ts
Normal file
25
src/routes/admin/login/+server.ts
Normal file
@@ -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 });
|
||||||
|
};
|
||||||
@@ -1,29 +1,50 @@
|
|||||||
import { db } from '$lib/server/db';
|
import { db } from '$lib/server/db';
|
||||||
import { blogArticles } from '$lib/server/db/schema';
|
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';
|
import type { PageServerLoad } from './$types.js';
|
||||||
|
|
||||||
export const load: PageServerLoad = async () => {
|
export const load: PageServerLoad = async (event) => {
|
||||||
const articles = await db.select().from(blogArticles);
|
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 {
|
return {
|
||||||
blogArticles: articles
|
blogArticles: articles,
|
||||||
|
isAdmin: admin
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const actions = {
|
export const actions = {
|
||||||
default: async ({ request }): Promise<Response> => {
|
default: async (event) => {
|
||||||
const formData = await request.formData();
|
requireAdmin(event);
|
||||||
|
|
||||||
const blogArticle = await db
|
const formData = await event.request.formData();
|
||||||
.insert(blogArticles)
|
const parsed = createArticleSchema.safeParse({
|
||||||
.values({
|
title: formData.get('title')?.toString()
|
||||||
title: formData.get('title')?.toString(),
|
});
|
||||||
slug: formData.get('slug')?.toString(),
|
|
||||||
content: formData.get('content')?.toString()
|
|
||||||
})
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
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`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,31 +1,131 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { enhance } from '$app/forms';
|
import { enhance } from '$app/forms';
|
||||||
|
import { invalidateAll } from '$app/navigation';
|
||||||
import { resolve } from '$app/paths';
|
import { resolve } from '$app/paths';
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
|
|
||||||
let createNewArticlePopup = $state(false);
|
let createNewArticlePopup = $state(false);
|
||||||
|
let loginPopup = $state(false);
|
||||||
|
let password = $state('');
|
||||||
|
let loginError = $state('');
|
||||||
|
let loginLoading = $state(false);
|
||||||
|
|
||||||
|
async function login() {
|
||||||
|
loginLoading = true;
|
||||||
|
loginError = '';
|
||||||
|
const res = await fetch('/admin/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ password })
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
loginPopup = false;
|
||||||
|
password = '';
|
||||||
|
await invalidateAll();
|
||||||
|
} else {
|
||||||
|
loginError = 'Wrong password';
|
||||||
|
}
|
||||||
|
loginLoading = false;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="text-center py-8 gap-2 flex align-center justify-center">
|
<div class="text-center py-8 gap-2 flex align-center justify-center">
|
||||||
<h1 class="text-2xl">Blog (Stan's yapping corner)</h1>
|
<h1 class="text-2xl">Blog (Stan's yapping corner)</h1>
|
||||||
|
{#if data.isAdmin}
|
||||||
<button
|
<button
|
||||||
onclick={() => (createNewArticlePopup = true)}
|
onclick={() => (createNewArticlePopup = true)}
|
||||||
class="bg-gray-800 rounded px-4 py-2 text-white font-bold">Write new article</button
|
class="bg-gray-800 rounded px-4 py-2 text-white font-bold cursor-pointer"
|
||||||
|
>Write new article</button
|
||||||
>
|
>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
onclick={() => (loginPopup = true)}
|
||||||
|
class="text-xs text-gray-400 underline cursor-pointer">Admin login</button
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#each data.blogArticles as article (article.slug)}
|
{#each data.blogArticles as article (article.slug)}
|
||||||
<div class="flex">
|
<div class="flex gap-2 px-4">
|
||||||
<a href={resolve('/blog/{slug}', { slug: article.slug })}>{article.title}</a>
|
<a href={resolve('/blog/[slug]', { slug: article.slug })}>{article.title}</a>
|
||||||
|
{#if data.isAdmin && article.status === 'draft'}
|
||||||
|
<span class="text-xs uppercase text-orange-500">draft</span>
|
||||||
|
<a href={resolve('/blog/[slug]/edit', { slug: article.slug })} class="text-xs underline"
|
||||||
|
>edit</a
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
<p>{article.createdAt?.toDateString()}</p>
|
<p>{article.createdAt?.toDateString()}</p>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
{#if createNewArticlePopup}
|
{#if createNewArticlePopup}
|
||||||
<form method="POST" use:enhance>
|
<div class="fixed inset-0 flex items-center justify-center bg-black/40">
|
||||||
<div>
|
<form
|
||||||
<h2>Create New Article</h2>
|
method="POST"
|
||||||
|
use:enhance={() => {
|
||||||
|
return async ({ result }) => {
|
||||||
|
if (result.type !== 'redirect') {
|
||||||
|
createNewArticlePopup = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
class="w-80 rounded bg-white p-6 shadow-xl"
|
||||||
|
>
|
||||||
|
<h2 class="mb-4 text-lg font-bold">Create New Article</h2>
|
||||||
|
<input
|
||||||
|
name="title"
|
||||||
|
placeholder="Article title"
|
||||||
|
required
|
||||||
|
class="mb-4 w-full rounded border border-gray-300 px-3 py-2 text-sm outline-none"
|
||||||
|
/>
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => (createNewArticlePopup = false)}
|
||||||
|
class="rounded px-3 py-2 text-sm cursor-pointer">Cancel</button
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="rounded bg-gray-800 px-3 py-2 text-sm font-bold text-white cursor-pointer"
|
||||||
|
>Create draft</button
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if loginPopup}
|
||||||
|
<div class="fixed inset-0 flex items-center justify-center bg-black/40">
|
||||||
|
<div class="w-80 rounded bg-white p-6 shadow-xl">
|
||||||
|
<h2 class="mb-4 text-lg font-bold">Admin login</h2>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
bind:value={password}
|
||||||
|
placeholder="Password"
|
||||||
|
onkeydown={(e) => e.key === 'Enter' && login()}
|
||||||
|
class="mb-3 w-full rounded border border-gray-300 px-3 py-2 text-sm outline-none"
|
||||||
|
/>
|
||||||
|
{#if loginError}
|
||||||
|
<p class="mb-3 text-xs text-red-500">{loginError}</p>
|
||||||
|
{/if}
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => {
|
||||||
|
loginPopup = false;
|
||||||
|
loginError = '';
|
||||||
|
}}
|
||||||
|
class="rounded px-3 py-2 text-sm cursor-pointer">Cancel</button
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onclick={login}
|
||||||
|
disabled={loginLoading}
|
||||||
|
class="rounded bg-gray-800 px-3 py-2 text-sm font-bold text-white disabled:opacity-50 cursor-pointer disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{loginLoading ? 'Logging in…' : 'Log in'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
35
src/routes/blog/[slug]/+page.server.ts
Normal file
35
src/routes/blog/[slug]/+page.server.ts
Normal file
@@ -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 = {};
|
||||||
21
src/routes/blog/[slug]/+page.svelte
Normal file
21
src/routes/blog/[slug]/+page.svelte
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
|
|
||||||
|
let { data } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head><title>{data.blogArticle.title} - Blog</title></svelte:head>
|
||||||
|
|
||||||
|
<article class="mx-auto max-w-2xl px-4 py-8">
|
||||||
|
<div class="mb-6 flex items-center justify-between">
|
||||||
|
<h1 class="text-2xl font-bold">{data.blogArticle.title}</h1>
|
||||||
|
{#if data.isAdmin}
|
||||||
|
<a
|
||||||
|
href={resolve('/blog/[slug]/edit', { slug: data.blogArticle.slug })}
|
||||||
|
class="text-sm underline">Edit</a
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<p class="mb-6 text-sm text-gray-500">{data.blogArticle.createdAt?.toDateString()}</p>
|
||||||
|
<div class="whitespace-pre-wrap">{data.blogArticle.content}</div>
|
||||||
|
</article>
|
||||||
90
src/routes/blog/[slug]/edit/+page.server.ts
Normal file
90
src/routes/blog/[slug]/edit/+page.server.ts
Normal file
@@ -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}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
73
src/routes/blog/[slug]/edit/+page.svelte
Normal file
73
src/routes/blog/[slug]/edit/+page.svelte
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
|
|
||||||
|
let { data, form } = $props();
|
||||||
|
|
||||||
|
let title = $state(data.blogArticle.title);
|
||||||
|
let content = $state(data.blogArticle.content ?? '');
|
||||||
|
let saving = $state(false);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head><title>Editing {data.blogArticle.title}</title></svelte:head>
|
||||||
|
|
||||||
|
<div class="mx-auto max-w-2xl px-4 py-8">
|
||||||
|
<div class="mb-4 flex items-center justify-between">
|
||||||
|
<h1 class="text-xl font-bold">
|
||||||
|
Editing article
|
||||||
|
<span
|
||||||
|
class="ml-2 rounded px-2 py-0.5 text-xs uppercase {data.blogArticle.status === 'published'
|
||||||
|
? 'bg-green-100 text-green-700'
|
||||||
|
: 'bg-orange-100 text-orange-700'}">{data.blogArticle.status}</span
|
||||||
|
>
|
||||||
|
</h1>
|
||||||
|
<a href={resolve('/blog/[slug]', { slug: data.blogArticle.slug })} class="text-sm underline"
|
||||||
|
>View</a
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if form?.message}
|
||||||
|
<p class="mb-3 text-sm text-red-500">{form.message}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/save"
|
||||||
|
use:enhance={() => {
|
||||||
|
saving = true;
|
||||||
|
return async ({ update }) => {
|
||||||
|
await update();
|
||||||
|
saving = false;
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
class="flex flex-col gap-3"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
name="title"
|
||||||
|
bind:value={title}
|
||||||
|
required
|
||||||
|
class="rounded border border-gray-300 px-3 py-2 text-lg font-bold outline-none"
|
||||||
|
/>
|
||||||
|
<textarea
|
||||||
|
name="content"
|
||||||
|
bind:value={content}
|
||||||
|
rows="20"
|
||||||
|
class="rounded border border-gray-300 px-3 py-2 font-mono text-sm outline-none"></textarea>
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={saving}
|
||||||
|
class="rounded bg-gray-800 px-4 py-2 text-sm font-bold text-white disabled:opacity-50 cursor-pointer disabled:cursor-not-allowed"
|
||||||
|
>Save draft</button
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
formaction="?/publish"
|
||||||
|
disabled={saving}
|
||||||
|
class="rounded bg-green-700 px-4 py-2 text-sm font-bold text-white disabled:opacity-50 cursor-pointer disabled:cursor-not-allowed"
|
||||||
|
>Publish</button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
@@ -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 = {};
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
bu
|
|
||||||
Reference in New Issue
Block a user