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>
This commit is contained in:
297
src/routes/projects/new/+page.svelte
Normal file
297
src/routes/projects/new/+page.svelte
Normal file
@@ -0,0 +1,297 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user