Compare commits

..

4 Commits

Author SHA1 Message Date
739d8486b2 add footer with git link 2026-09-01 21:57:31 +00:00
0994f31281 add markdown formatting with code support 2026-09-01 11:19:13 +00:00
4822125787 add blog editor 2026-08-31 16:39:56 +00:00
d793400336 update api key imprts and change URLs 2026-08-31 14:40:31 +00:00
20 changed files with 571 additions and 78 deletions

61
package-lock.json generated
View File

@@ -15,6 +15,9 @@
"@sveltejs/adapter-node": "^5.5.7", "@sveltejs/adapter-node": "^5.5.7",
"ai": "^7.0.85", "ai": "^7.0.85",
"drizzle-orm": "^1.0.0-rc.4", "drizzle-orm": "^1.0.0-rc.4",
"highlight.js": "^11.12.0",
"marked": "^18.0.11",
"marked-highlight": "^2.2.4",
"mode-watcher": "^1.1.0", "mode-watcher": "^1.1.0",
"postgres": "^3.4.9", "postgres": "^3.4.9",
"zod": "^4.5.4" "zod": "^4.5.4"
@@ -26,6 +29,7 @@
"@sveltejs/enhanced-img": "^0.11.0", "@sveltejs/enhanced-img": "^0.11.0",
"@sveltejs/kit": "^2.70.3", "@sveltejs/kit": "^2.70.3",
"@sveltejs/vite-plugin-svelte": "^7.3.0", "@sveltejs/vite-plugin-svelte": "^7.3.0",
"@tailwindcss/typography": "^0.5.20",
"@tailwindcss/vite": "^4.3.3", "@tailwindcss/vite": "^4.3.3",
"@types/eslint": "9.6.1", "@types/eslint": "9.6.1",
"@types/pg": "^8.23.1", "@types/pg": "^8.23.1",
@@ -3237,6 +3241,33 @@
"node": ">= 20" "node": ">= 20"
} }
}, },
"node_modules/@tailwindcss/typography": {
"version": "0.5.20",
"resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.20.tgz",
"integrity": "sha512-hwbzQuNUfcPvbegQFatVPl/MY/tcM9KLl963hQ5laJKPh81TEZ1+dNG9PirGvcaDBkp+BCshExAyKVPW91dozw==",
"dev": true,
"license": "MIT",
"dependencies": {
"postcss-selector-parser": "6.0.10"
},
"peerDependencies": {
"tailwindcss": ">=3.0.0 || >=4.0.0 || insiders"
}
},
"node_modules/@tailwindcss/typography/node_modules/postcss-selector-parser": {
"version": "6.0.10",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz",
"integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==",
"dev": true,
"license": "MIT",
"dependencies": {
"cssesc": "^3.0.0",
"util-deprecate": "^1.0.2"
},
"engines": {
"node": ">=4"
}
},
"node_modules/@tailwindcss/vite": { "node_modules/@tailwindcss/vite": {
"version": "4.3.3", "version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz",
@@ -5932,6 +5963,15 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/highlight.js": {
"version": "11.12.0",
"resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.12.0.tgz",
"integrity": "sha512-nbfWpyRMcMrPMmDwJB+dhX/eiaPKtc2RB+0QZskqJ3WjRA/FDS0e9hZrx8EC/lbEv8gXy98FcDbNa/dspAaJMg==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/ignore": { "node_modules/ignore": {
"version": "5.3.2", "version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -6537,6 +6577,27 @@
"integrity": "sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==", "integrity": "sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==",
"license": "BSD-2-Clause" "license": "BSD-2-Clause"
}, },
"node_modules/marked": {
"version": "18.0.11",
"resolved": "https://registry.npmjs.org/marked/-/marked-18.0.11.tgz",
"integrity": "sha512-HnslJfsZkRPBDJRHvVtAaWlZHEpSu7u8LgQuJCELjRKuWR+hpq4A7sLq3p8HaI9ypVoXDXxV34CsQJEe1+J5Aw==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/marked-highlight": {
"version": "2.2.4",
"resolved": "https://registry.npmjs.org/marked-highlight/-/marked-highlight-2.2.4.tgz",
"integrity": "sha512-PZxisNMJDduSjc0q6uvjsnqqHCXc9s0eyzxDO9sB1eNGJnd/H1/Fu+z6g/liC1dfJdFW4SftMwMlLvsBhUPrqQ==",
"license": "MIT",
"peerDependencies": {
"marked": ">=4 <19"
}
},
"node_modules/minimatch": { "node_modules/minimatch": {
"version": "10.2.6", "version": "10.2.6",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",

View File

@@ -28,6 +28,7 @@
"@sveltejs/enhanced-img": "^0.11.0", "@sveltejs/enhanced-img": "^0.11.0",
"@sveltejs/kit": "^2.70.3", "@sveltejs/kit": "^2.70.3",
"@sveltejs/vite-plugin-svelte": "^7.3.0", "@sveltejs/vite-plugin-svelte": "^7.3.0",
"@tailwindcss/typography": "^0.5.20",
"@tailwindcss/vite": "^4.3.3", "@tailwindcss/vite": "^4.3.3",
"@types/eslint": "9.6.1", "@types/eslint": "9.6.1",
"@types/pg": "^8.23.1", "@types/pg": "^8.23.1",
@@ -64,6 +65,9 @@
"@sveltejs/adapter-node": "^5.5.7", "@sveltejs/adapter-node": "^5.5.7",
"ai": "^7.0.85", "ai": "^7.0.85",
"drizzle-orm": "^1.0.0-rc.4", "drizzle-orm": "^1.0.0-rc.4",
"highlight.js": "^11.12.0",
"marked": "^18.0.11",
"marked-highlight": "^2.2.4",
"mode-watcher": "^1.1.0", "mode-watcher": "^1.1.0",
"postgres": "^3.4.9", "postgres": "^3.4.9",
"zod": "^4.5.4" "zod": "^4.5.4"

View File

@@ -1,4 +1,5 @@
@import 'tailwindcss'; @import 'tailwindcss';
@plugin '@tailwindcss/typography';
@theme { @theme {
--color-bg: #f6f4f0; --color-bg: #f6f4f0;

View File

@@ -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' }
});
} }
} }

View File

@@ -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

View File

@@ -0,0 +1,18 @@
import { Marked } from 'marked';
import { markedHighlight } from 'marked-highlight';
import hljs from 'highlight.js';
const marked = new Marked(
markedHighlight({
emptyLangClass: 'hljs',
langPrefix: 'hljs language-',
highlight(code, lang) {
const language = hljs.getLanguage(lang) ? lang : 'plaintext';
return hljs.highlight(code, { language }).value;
}
})
);
export function renderMarkdown(content: string): string {
return marked.parse(content, { async: false });
}

29
src/lib/server/slugify.ts Normal file
View 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;
}

View 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)
});

View File

@@ -48,6 +48,7 @@
]; ];
</script> </script>
<div class="flex flex-col min-h-screen">
<header class="flex justify-between items-center gap-4 py-4 px-4"> <header class="flex justify-between items-center gap-4 py-4 px-4">
<a href={resolve('/')} class="text-2xl font-bold">Stan Runge</a> <a href={resolve('/')} class="text-2xl font-bold">Stan Runge</a>
<nav class="flex flex-wrap justify-center gap-4" aria-label="Social links"> <nav class="flex flex-wrap justify-center gap-4" aria-label="Social links">
@@ -62,4 +63,16 @@
</nav> </nav>
</header> </header>
<main class="flex-1">
{@render children()} {@render children()}
</main>
<footer class="flex justify-center py-8 bg-gray-200">
<p>
Hosted on <a
class="underline text-blue-500"
href="https://git.stanrunge.dev/stan/personal-website">my git server :)</a
>
</p>
</footer>
</div>

View 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 });
};

View File

@@ -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`);
} }
}; };

View File

@@ -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}

View File

@@ -0,0 +1,37 @@
import { db } from '$lib/server/db';
import { blogArticles } from '$lib/server/db/schema';
import { isAdmin } from '$lib/server/auth';
import { renderMarkdown } from '$lib/server/markdown';
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,
contentHtml: renderMarkdown(article.content ?? ''),
isAdmin: admin
};
};
export const actions = {};

View File

@@ -0,0 +1,24 @@
<script lang="ts">
import { resolve } from '$app/paths';
import 'highlight.js/styles/github-dark.css';
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="prose prose-neutral max-w-none">
{@html data.contentHtml}
</div>
</article>

View 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}`);
}
};

View File

@@ -0,0 +1,75 @@
<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-body text-base leading-relaxed text-gray-900 outline-none"
></textarea>
<p class="-mt-2 text-xs text-gray-400">Supports Markdown formatting.</p>
<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>

View File

@@ -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 = {};

View File

@@ -1 +0,0 @@
bu

View File

@@ -63,7 +63,8 @@
const initMap = (center: [number, number], zoom: number) => { const initMap = (center: [number, number], zoom: number) => {
map = new Map({ map = new Map({
container: mapContainer, container: mapContainer,
style: 'https://api.maptiler.com/maps/satellite/style.json?key=' + PUBLIC_MAPTILER_API_KEY, style:
'https://api.maptiler.com/maps/satellite/style.json?key=' + env.PUBLIC_MAPTILER_API_KEY,
center, center,
zoom zoom
}); });
@@ -217,7 +218,7 @@
<div class="absolute top-4 left-1/2 z-10 flex -translate-x-1/2 gap-2"> <div class="absolute top-4 left-1/2 z-10 flex -translate-x-1/2 gap-2">
<a <a
href={resolve('/register')} href={resolve('/projects/animaldex/register')}
class="flex items-center gap-2 rounded-full bg-orange-500 px-4 py-2 text-sm font-semibold text-white shadow-lg hover:bg-orange-600" class="flex items-center gap-2 rounded-full bg-orange-500 px-4 py-2 text-sm font-semibold text-white shadow-lg hover:bg-orange-600"
> >
+ Log a sighting + Log a sighting
@@ -345,7 +346,7 @@
{/each} {/each}
</div> </div>
<a <a
href={resolve(`/register?animalId=${selected.animalId}`)} href={resolve(`/projects/animaldex/register?animalId=${selected.animalId}`)}
class="mt-3 block w-full rounded-lg bg-orange-500 py-2 text-center text-sm font-semibold text-white hover:bg-orange-600" class="mt-3 block w-full rounded-lg bg-orange-500 py-2 text-center text-sm font-semibold text-white hover:bg-orange-600"
> >
I saw this animal too! I saw this animal too!

View File

@@ -73,7 +73,8 @@
map = new Map({ map = new Map({
container: mapContainer, container: mapContainer,
style: 'https://api.maptiler.com/maps/satellite/style.json?key=' + PUBLIC_MAPTILER_API_KEY, style:
'https://api.maptiler.com/maps/satellite/style.json?key=' + env.PUBLIC_MAPTILER_API_KEY,
center: [lng!, lat!], center: [lng!, lat!],
zoom: 15 zoom: 15
}); });