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>
298 lines
6.8 KiB
Svelte
298 lines
6.8 KiB
Svelte
<script lang="ts">
|
|
import { LINK_KINDS, LINK_KIND_LABEL } from '$lib/config';
|
|
import { enhance } from '$app/forms';
|
|
|
|
let { form } = $props();
|
|
|
|
type LinkRow = { kind: string; label: string; url: string };
|
|
let linkRows: LinkRow[] = $state([{ kind: 'gitea', label: '', url: '' }]);
|
|
|
|
function addRow() {
|
|
linkRows = [...linkRows, { kind: 'docs', label: '', url: '' }];
|
|
}
|
|
function removeRow(i: number) {
|
|
linkRows = linkRows.filter((_, idx) => idx !== i);
|
|
}
|
|
</script>
|
|
|
|
<svelte:head>
|
|
<title>New project · HELIX</title>
|
|
</svelte:head>
|
|
|
|
<section class="page">
|
|
<header class="head">
|
|
<a href="/projects" class="back">← Projects</a>
|
|
<h1>New project</h1>
|
|
<p class="lede">
|
|
Showcase something. Title, a one-line summary, a markdown body, and links to
|
|
whatever lives elsewhere — repo, dashboard, demo, paper.
|
|
</p>
|
|
</header>
|
|
|
|
{#if form?.error}
|
|
<div class="error">{form.error}</div>
|
|
{/if}
|
|
|
|
<form method="POST" class="form" use:enhance>
|
|
<label>
|
|
<span>Title</span>
|
|
<input
|
|
type="text"
|
|
name="title"
|
|
required
|
|
maxlength="200"
|
|
value={form?.values?.title ?? ''}
|
|
placeholder="EVOLV"
|
|
/>
|
|
</label>
|
|
|
|
<label>
|
|
<span>Slug <em>(optional — auto from title)</em></span>
|
|
<input
|
|
type="text"
|
|
name="slug"
|
|
maxlength="80"
|
|
pattern="[a-z0-9-]*"
|
|
value={form?.values?.slug ?? ''}
|
|
placeholder="evolv"
|
|
/>
|
|
</label>
|
|
|
|
<label>
|
|
<span>Summary</span>
|
|
<input
|
|
type="text"
|
|
name="summary"
|
|
required
|
|
maxlength="280"
|
|
value={form?.values?.summary ?? ''}
|
|
placeholder="One sentence on what this project is."
|
|
/>
|
|
</label>
|
|
|
|
<label>
|
|
<span>Cover image URL <em>(optional)</em></span>
|
|
<input
|
|
type="url"
|
|
name="cover_url"
|
|
value={form?.values?.cover_url ?? ''}
|
|
placeholder="https://…"
|
|
/>
|
|
</label>
|
|
|
|
<label>
|
|
<span>Body <em>(markdown)</em></span>
|
|
<textarea
|
|
name="body_md"
|
|
rows="14"
|
|
placeholder="## What is this? Tell the story. Use headings, lists, code blocks."
|
|
>{form?.values?.body_md ?? ''}</textarea>
|
|
</label>
|
|
|
|
<fieldset class="links">
|
|
<legend>Links</legend>
|
|
<p class="help">
|
|
Add the repo, any Grafana dashboards, demos, or docs. Dashboard links render
|
|
as inline embeds on the project page when their host is allowlisted.
|
|
</p>
|
|
<div class="link-rows">
|
|
{#each linkRows as row, i (i)}
|
|
<div class="link-row">
|
|
<select name="link_kind" bind:value={row.kind}>
|
|
{#each LINK_KINDS as k}
|
|
<option value={k}>{LINK_KIND_LABEL[k]}</option>
|
|
{/each}
|
|
</select>
|
|
<input
|
|
type="text"
|
|
name="link_label"
|
|
placeholder="Label"
|
|
bind:value={row.label}
|
|
/>
|
|
<input
|
|
type="url"
|
|
name="link_url"
|
|
placeholder="https://…"
|
|
bind:value={row.url}
|
|
/>
|
|
<button
|
|
type="button"
|
|
class="rm"
|
|
onclick={() => removeRow(i)}
|
|
aria-label="Remove link"
|
|
>✕</button>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
<button type="button" class="add" onclick={addRow}>+ Add link</button>
|
|
</fieldset>
|
|
|
|
<div class="actions">
|
|
<a href="/projects" class="cancel">Cancel</a>
|
|
<button type="submit" class="submit">Publish project</button>
|
|
</div>
|
|
</form>
|
|
</section>
|
|
|
|
<style>
|
|
.page {
|
|
max-width: 760px;
|
|
margin: 0 auto;
|
|
padding: 3rem 1.5rem 4rem;
|
|
}
|
|
.head {
|
|
margin-bottom: 2rem;
|
|
}
|
|
.back {
|
|
color: var(--color-helix-ink-dim);
|
|
text-decoration: none;
|
|
font-size: 0.875rem;
|
|
}
|
|
.back:hover {
|
|
color: var(--color-helix-accent);
|
|
}
|
|
h1 {
|
|
margin: 1rem 0 0.6rem;
|
|
font-size: 2.25rem;
|
|
font-weight: 700;
|
|
letter-spacing: -0.02em;
|
|
}
|
|
.lede {
|
|
color: var(--color-helix-ink-dim);
|
|
line-height: 1.5;
|
|
}
|
|
.error {
|
|
margin-bottom: 1rem;
|
|
padding: 0.85rem 1rem;
|
|
border-radius: 8px;
|
|
border: 1px solid #b94a4a;
|
|
background: rgba(185, 74, 74, 0.12);
|
|
color: #f5a8a8;
|
|
}
|
|
.form {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 1.25rem;
|
|
}
|
|
label {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.4rem;
|
|
font-size: 0.92rem;
|
|
}
|
|
label > span {
|
|
font-weight: 500;
|
|
color: var(--color-helix-ink);
|
|
}
|
|
label em {
|
|
color: var(--color-helix-ink-faint);
|
|
font-style: normal;
|
|
font-weight: 400;
|
|
margin-left: 0.4em;
|
|
}
|
|
input,
|
|
textarea,
|
|
select {
|
|
background: var(--color-helix-bg-2);
|
|
border: 1px solid var(--color-helix-border);
|
|
border-radius: 8px;
|
|
padding: 0.65rem 0.8rem;
|
|
color: var(--color-helix-ink);
|
|
font: inherit;
|
|
transition: border-color 160ms ease;
|
|
}
|
|
input:focus,
|
|
textarea:focus,
|
|
select:focus {
|
|
outline: none;
|
|
border-color: var(--color-helix-accent);
|
|
}
|
|
textarea {
|
|
font-family: var(--font-mono);
|
|
font-size: 0.92rem;
|
|
line-height: 1.55;
|
|
resize: vertical;
|
|
}
|
|
fieldset.links {
|
|
border: 1px solid var(--color-helix-border);
|
|
border-radius: 10px;
|
|
padding: 1rem 1.25rem 1.25rem;
|
|
background: var(--color-helix-bg-2);
|
|
}
|
|
legend {
|
|
padding: 0 0.4rem;
|
|
font-weight: 500;
|
|
}
|
|
.help {
|
|
font-size: 0.85rem;
|
|
color: var(--color-helix-ink-dim);
|
|
margin-bottom: 0.85rem;
|
|
}
|
|
.link-rows {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.6rem;
|
|
}
|
|
.link-row {
|
|
display: grid;
|
|
grid-template-columns: 140px 160px 1fr auto;
|
|
gap: 0.5rem;
|
|
}
|
|
.rm {
|
|
background: transparent;
|
|
border: 1px solid var(--color-helix-border);
|
|
color: var(--color-helix-ink-dim);
|
|
border-radius: 8px;
|
|
padding: 0 0.7rem;
|
|
cursor: pointer;
|
|
}
|
|
.rm:hover {
|
|
color: #f5a8a8;
|
|
border-color: #b94a4a;
|
|
}
|
|
.add {
|
|
margin-top: 0.85rem;
|
|
background: transparent;
|
|
border: 1px dashed var(--color-helix-border);
|
|
color: var(--color-helix-accent);
|
|
padding: 0.55rem 1rem;
|
|
border-radius: 8px;
|
|
cursor: pointer;
|
|
}
|
|
.add:hover {
|
|
border-color: var(--color-helix-accent);
|
|
}
|
|
.actions {
|
|
display: flex;
|
|
justify-content: flex-end;
|
|
align-items: center;
|
|
gap: 0.85rem;
|
|
margin-top: 1rem;
|
|
}
|
|
.cancel {
|
|
color: var(--color-helix-ink-dim);
|
|
text-decoration: none;
|
|
}
|
|
.submit {
|
|
background: var(--color-helix-process);
|
|
border: none;
|
|
color: white;
|
|
padding: 0.7rem 1.4rem;
|
|
border-radius: 8px;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
transition: background 160ms ease;
|
|
}
|
|
.submit:hover {
|
|
background: var(--color-helix-area);
|
|
}
|
|
@media (max-width: 600px) {
|
|
.link-row {
|
|
grid-template-columns: 1fr 1fr auto;
|
|
}
|
|
.link-row select {
|
|
grid-column: 1 / -1;
|
|
}
|
|
}
|
|
</style>
|