Files
helix/src/routes/projects/new/+page.svelte
Rene De Ren c3d978a7eb feat: initial HELIX scaffold — R&D showcase platform
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>
2026-05-20 11:01:12 +02:00

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?&#10;&#10;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>