SvelteKit 2 + Svelte 5 + TypeScript site. SQLite via Drizzle. Gitea OAuth for authoring (RnD org-gated). Pure SVG + CSS DNA helix on landing. What lands - Landing hero with animated two-strand SVG helix + tagline - /projects + /projects/[slug] (markdown body, dashboard embed allowlist) - /posts + /posts/[slug] - Auth-gated /projects/new + /posts/new forms - Gitea OAuth flow (state, code exchange, org-membership check, sessions) - Sliding-window cookie sessions (SHA-256 hashed token storage) - Dockerfile + docker-compose with named-volume SQLite - Idempotent seed (EVOLV + HELIX projects, welcome post) Stack notes - Tailwind v3 (Node 18 compat; v4 needs Node 20+) - drizzle-orm 0.45+ (patched, no SQL-identifier escape vuln) - marked for markdown; iframe embeds gated by DASHBOARD_ALLOWED_HOSTS Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
81 lines
2.7 KiB
TypeScript
81 lines
2.7 KiB
TypeScript
import { fail, redirect } from '@sveltejs/kit';
|
|
import type { Actions, PageServerLoad } from './$types';
|
|
import { db } from '$lib/server/db';
|
|
import { projects, projectLinks } from '$lib/server/db/schema';
|
|
import { LINK_KINDS } from '$lib/config';
|
|
import { newId, slugify } from '$lib/markdown';
|
|
import { eq } from 'drizzle-orm';
|
|
|
|
export const load: PageServerLoad = async ({ locals }) => {
|
|
if (!locals.user) redirect(302, '/login');
|
|
return {};
|
|
};
|
|
|
|
export const actions: Actions = {
|
|
default: async ({ request, locals }) => {
|
|
const data = await request.formData();
|
|
const title = (data.get('title') ?? '').toString().trim();
|
|
const slugRaw = (data.get('slug') ?? '').toString().trim();
|
|
const summary = (data.get('summary') ?? '').toString().trim();
|
|
const bodyMd = (data.get('body_md') ?? '').toString();
|
|
const coverUrl = (data.get('cover_url') ?? '').toString().trim() || null;
|
|
|
|
const values = { title, slug: slugRaw, summary, body_md: bodyMd, cover_url: coverUrl };
|
|
|
|
if (!locals.user) return fail(401, { error: 'Not authenticated', values });
|
|
if (!title) return fail(400, { error: 'Title is required.', values });
|
|
if (!summary) return fail(400, { error: 'Summary is required.', values });
|
|
|
|
const slug = slugify(slugRaw || title);
|
|
if (!slug) return fail(400, { error: 'Slug could not be generated.', values });
|
|
|
|
const exists = db.select({ id: projects.id }).from(projects).where(eq(projects.slug, slug)).get();
|
|
if (exists) {
|
|
return fail(400, { error: `A project with slug "${slug}" already exists.`, values });
|
|
}
|
|
|
|
const kinds = data.getAll('link_kind').map(String);
|
|
const labels = data.getAll('link_label').map(String);
|
|
const urls = data.getAll('link_url').map(String);
|
|
|
|
const linksToInsert: { kind: string; label: string; url: string; position: number }[] = [];
|
|
for (let i = 0; i < kinds.length; i++) {
|
|
const k = kinds[i];
|
|
const l = (labels[i] ?? '').trim();
|
|
const u = (urls[i] ?? '').trim();
|
|
if (!u) continue;
|
|
if (!(LINK_KINDS as readonly string[]).includes(k)) continue;
|
|
linksToInsert.push({ kind: k, label: l || u, url: u, position: i });
|
|
}
|
|
|
|
const id = newId('prj');
|
|
db.insert(projects)
|
|
.values({
|
|
id,
|
|
slug,
|
|
title,
|
|
summary,
|
|
bodyMd,
|
|
coverUrl,
|
|
authorId: locals.user.id,
|
|
status: 'published'
|
|
})
|
|
.run();
|
|
|
|
for (const link of linksToInsert) {
|
|
db.insert(projectLinks)
|
|
.values({
|
|
id: newId('lnk'),
|
|
projectId: id,
|
|
kind: link.kind as (typeof LINK_KINDS)[number],
|
|
label: link.label,
|
|
url: link.url,
|
|
position: link.position
|
|
})
|
|
.run();
|
|
}
|
|
|
|
redirect(303, `/projects/${slug}`);
|
|
}
|
|
};
|