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:
Rene De Ren
2026-05-20 11:01:12 +02:00
commit c3d978a7eb
59 changed files with 8140 additions and 0 deletions

12
.dockerignore Normal file
View File

@@ -0,0 +1,12 @@
node_modules
.svelte-kit
build
.git
.env
.env.*
*.db
*.db-journal
*.db-wal
*.db-shm
README.md
.vscode

24
.env.example Normal file
View File

@@ -0,0 +1,24 @@
# HELIX — environment configuration
#
# Copy to .env and fill in the secrets. See README.md "Gitea OAuth setup".
# --- Server ---
PORT=3000
ORIGIN=http://localhost:3000
# --- Database ---
# Path is relative to the process cwd (or absolute).
DATABASE_URL=./helix.db
# --- Gitea OAuth ---
# Create an OAuth2 application at:
# https://gitea.wbd-rd.nl/-/user/settings/applications
# Redirect URI must match GITEA_REDIRECT_URI exactly.
GITEA_BASE_URL=https://gitea.wbd-rd.nl
GITEA_CLIENT_ID=
GITEA_CLIENT_SECRET=
GITEA_REDIRECT_URI=http://localhost:3000/auth/gitea/callback
# Optional: restrict login to members of a specific Gitea organisation.
# Leave empty to allow any authenticated Gitea user.
GITEA_ALLOWED_ORG=RnD

14
.gitignore vendored Normal file
View File

@@ -0,0 +1,14 @@
node_modules
/build
/.svelte-kit
/package
.env
.env.*
!.env.example
*.db
*.db-journal
*.db-wal
*.db-shm
.DS_Store
.vscode/*
!.vscode/extensions.json

1
.nvmrc Normal file
View File

@@ -0,0 +1 @@
20

43
Dockerfile Normal file
View File

@@ -0,0 +1,43 @@
# syntax=docker/dockerfile:1.7
# ---- build stage ----
FROM node:20-bookworm-slim AS build
WORKDIR /app
# better-sqlite3 needs build tools when no prebuilt is available
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 make g++ \
&& rm -rf /var/lib/apt/lists/*
COPY package.json package-lock.json* ./
RUN npm install --include=dev
COPY . .
RUN npm run build
RUN npm prune --omit=dev
# ---- runtime stage ----
FROM node:20-bookworm-slim AS runtime
WORKDIR /app
ENV NODE_ENV=production \
PORT=3000 \
DATABASE_URL=/data/helix.db
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
&& rm -rf /var/lib/apt/lists/* \
&& mkdir -p /data \
&& chown node:node /data
COPY --from=build --chown=node:node /app/build ./build
COPY --from=build --chown=node:node /app/node_modules ./node_modules
COPY --from=build --chown=node:node /app/package.json ./package.json
COPY --from=build --chown=node:node /app/drizzle ./drizzle
COPY --from=build --chown=node:node /app/scripts ./scripts
USER node
EXPOSE 3000
# Run migrations then start the SvelteKit Node server.
CMD ["sh", "-c", "node scripts/migrate.js && node build"]

126
README.md Normal file
View File

@@ -0,0 +1,126 @@
# HELIX
> **EVOLV and every R&D strand, one helix.**
>
> The R&D showcase platform of Waterschap Brabantse Delta. EVOLV at its core,
> every innovation along the strands.
HELIX collects projects, innovations, and updates from across the R&D team in
one place — with deep links to repos, dashboards, and demos. Sign-in is gated
to the `RnD` Gitea organisation; viewing is open.
## Stack
- **SvelteKit 2** + **Svelte 5** + TypeScript
- **Tailwind v4** (CSS-first design tokens)
- **SQLite** (single file) + **Drizzle ORM**
- **Gitea OAuth2** for authoring (no passwords stored)
- Pure **SVG + CSS** helix animation — no WebGL
## Local development
```bash
nvm use # picks up .nvmrc → Node 20
cp .env.example .env
# Fill in GITEA_CLIENT_ID and GITEA_CLIENT_SECRET (see "Gitea OAuth setup" below)
npm install
npm run db:generate # produces drizzle/0000_*.sql from the schema
npm run db:migrate # applies migrations to ./helix.db
npm run db:seed # adds example projects + posts so the landing page isn't empty
npm run dev # http://localhost:3000
```
## Gitea OAuth setup
HELIX uses your existing Gitea identity. Create an OAuth2 application once:
1. Open <https://gitea.wbd-rd.nl/-/user/settings/applications>
2. Click **Create new OAuth2 application**
3. Application name: `HELIX (local)` (or `HELIX (production)`)
4. Redirect URI:
- local: `http://localhost:3000/auth/gitea/callback`
- prod: `https://<your-helix-host>/auth/gitea/callback`
5. Click **Create application**
6. Copy the **Client ID** and **Client Secret** into your `.env`:
```
GITEA_CLIENT_ID=...
GITEA_CLIENT_SECRET=...
```
`GITEA_ALLOWED_ORG=RnD` restricts authoring (sign-in) to members of the `RnD`
organisation. Leave it empty to allow any authenticated Gitea user.
## Adding a dashboard embed allowlist host
`src/lib/config.ts` → `DASHBOARD_ALLOWED_HOSTS` controls which Grafana / dashboard
origins can be embedded inline on a project page. Add a hostname (no scheme,
no path) and redeploy. Hosts not on the list render as a "Open in new tab" card
instead — never blindly iframed.
## Production deploy (Docker)
```bash
cp .env.example .env # fill in Gitea OAuth + ORIGIN
docker compose up -d --build
```
The SQLite database lives in the `helix-data` named volume at `/data/helix.db`.
Back it up with `docker compose exec helix sqlite3 /data/helix.db .dump`.
## Project layout
```
helix/
├── src/
│ ├── lib/
│ │ ├── components/ # Helix.svelte, ProjectCard, PostCard, Nav, Footer, DashboardEmbed
│ │ ├── server/
│ │ │ ├── db/ # Drizzle schema + client (better-sqlite3)
│ │ │ ├── auth.ts # session token + cookie helpers
│ │ │ └── gitea.ts # OAuth2 dance + user fetch
│ │ ├── config.ts # site name, tagline, dashboard allowlist
│ │ └── markdown.ts # marked wrapper + slug/id helpers
│ ├── routes/
│ │ ├── +page.svelte # landing (helix hero + recent projects/posts)
│ │ ├── projects/ # /projects, /[slug], /new
│ │ ├── posts/ # /posts, /[slug], /new
│ │ ├── login/ # sign-in page (kicks off Gitea OAuth)
│ │ ├── logout/+server.ts # POST → invalidates session
│ │ └── auth/gitea/ # OAuth start + callback
│ ├── app.html
│ ├── app.css # Tailwind v4 entry + design tokens
│ ├── app.d.ts # Locals types
│ └── hooks.server.ts # session hydration
├── drizzle/ # generated migrations
├── scripts/
│ ├── migrate.js # run pending migrations
│ └── seed.js # idempotent example data
├── static/favicon.svg
├── Dockerfile
├── docker-compose.yml
├── drizzle.config.ts
├── svelte.config.js
├── vite.config.ts
├── tailwind.config.ts (none — Tailwind v4 uses @theme in app.css)
├── tsconfig.json
└── package.json
```
## How to post
After signing in (top-right → Sign in → Gitea), use:
- **+ New** in the nav (or `/projects/new`) — a project: title, summary, markdown body, links
- **`/posts/new`** — a shorter update / write-up
Both are auth-gated. Anyone in `RnD` on Gitea can post.
## Contributing
This is internal R&D — open a PR against `main` on
[`gitea.wbd-rd.nl/RnD/helix`](https://gitea.wbd-rd.nl/RnD/helix).
## License
Internal — Waterschap Brabantse Delta R&D.

22
docker-compose.yml Normal file
View File

@@ -0,0 +1,22 @@
services:
helix:
build: .
container_name: helix
restart: unless-stopped
ports:
- "${HELIX_PORT:-3000}:3000"
environment:
NODE_ENV: production
PORT: 3000
ORIGIN: ${ORIGIN:-http://localhost:3000}
DATABASE_URL: /data/helix.db
GITEA_BASE_URL: ${GITEA_BASE_URL:-https://gitea.wbd-rd.nl}
GITEA_CLIENT_ID: ${GITEA_CLIENT_ID}
GITEA_CLIENT_SECRET: ${GITEA_CLIENT_SECRET}
GITEA_REDIRECT_URI: ${GITEA_REDIRECT_URI:-http://localhost:3000/auth/gitea/callback}
GITEA_ALLOWED_ORG: ${GITEA_ALLOWED_ORG:-RnD}
volumes:
- helix-data:/data
volumes:
helix-data:

10
drizzle.config.ts Normal file
View File

@@ -0,0 +1,10 @@
import type { Config } from 'drizzle-kit';
export default {
schema: './src/lib/server/db/schema.ts',
out: './drizzle',
dialect: 'sqlite',
dbCredentials: {
url: process.env.DATABASE_URL ?? './helix.db'
}
} satisfies Config;

View File

@@ -0,0 +1,80 @@
CREATE TABLE `post_tags` (
`post_id` text NOT NULL,
`tag_id` text NOT NULL,
PRIMARY KEY(`post_id`, `tag_id`),
FOREIGN KEY (`post_id`) REFERENCES `posts`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`tag_id`) REFERENCES `tags`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `posts` (
`id` text PRIMARY KEY NOT NULL,
`slug` text NOT NULL,
`title` text NOT NULL,
`summary` text DEFAULT '' NOT NULL,
`body_md` text NOT NULL,
`author_id` text,
`published_at` integer,
`created_at` integer DEFAULT (unixepoch()) NOT NULL,
`updated_at` integer DEFAULT (unixepoch()) NOT NULL,
FOREIGN KEY (`author_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE set null
);
--> statement-breakpoint
CREATE UNIQUE INDEX `posts_slug_unique` ON `posts` (`slug`);--> statement-breakpoint
CREATE TABLE `project_links` (
`id` text PRIMARY KEY NOT NULL,
`project_id` text NOT NULL,
`kind` text NOT NULL,
`label` text NOT NULL,
`url` text NOT NULL,
`position` integer DEFAULT 0 NOT NULL,
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `project_tags` (
`project_id` text NOT NULL,
`tag_id` text NOT NULL,
PRIMARY KEY(`project_id`, `tag_id`),
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`tag_id`) REFERENCES `tags`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `projects` (
`id` text PRIMARY KEY NOT NULL,
`slug` text NOT NULL,
`title` text NOT NULL,
`summary` text NOT NULL,
`body_md` text DEFAULT '' NOT NULL,
`cover_url` text,
`author_id` text,
`status` text DEFAULT 'published' NOT NULL,
`created_at` integer DEFAULT (unixepoch()) NOT NULL,
`updated_at` integer DEFAULT (unixepoch()) NOT NULL,
FOREIGN KEY (`author_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE set null
);
--> statement-breakpoint
CREATE UNIQUE INDEX `projects_slug_unique` ON `projects` (`slug`);--> statement-breakpoint
CREATE TABLE `sessions` (
`id` text PRIMARY KEY NOT NULL,
`user_id` text NOT NULL,
`expires_at` integer NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `tags` (
`id` text PRIMARY KEY NOT NULL,
`name` text NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `tags_name_unique` ON `tags` (`name`);--> statement-breakpoint
CREATE TABLE `users` (
`id` text PRIMARY KEY NOT NULL,
`gitea_id` integer NOT NULL,
`username` text NOT NULL,
`name` text,
`email` text,
`avatar_url` text,
`role` text DEFAULT 'editor' NOT NULL,
`created_at` integer DEFAULT (unixepoch()) NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `users_gitea_id_unique` ON `users` (`gitea_id`);

View File

@@ -0,0 +1,560 @@
{
"version": "6",
"dialect": "sqlite",
"id": "4743e43c-8793-465c-9bd5-2e1c50f54396",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"post_tags": {
"name": "post_tags",
"columns": {
"post_id": {
"name": "post_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"tag_id": {
"name": "tag_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"post_tags_post_id_posts_id_fk": {
"name": "post_tags_post_id_posts_id_fk",
"tableFrom": "post_tags",
"tableTo": "posts",
"columnsFrom": [
"post_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"post_tags_tag_id_tags_id_fk": {
"name": "post_tags_tag_id_tags_id_fk",
"tableFrom": "post_tags",
"tableTo": "tags",
"columnsFrom": [
"tag_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"post_tags_post_id_tag_id_pk": {
"columns": [
"post_id",
"tag_id"
],
"name": "post_tags_post_id_tag_id_pk"
}
},
"uniqueConstraints": {},
"checkConstraints": {}
},
"posts": {
"name": "posts",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"slug": {
"name": "slug",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"summary": {
"name": "summary",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"body_md": {
"name": "body_md",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"author_id": {
"name": "author_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"published_at": {
"name": "published_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {
"posts_slug_unique": {
"name": "posts_slug_unique",
"columns": [
"slug"
],
"isUnique": true
}
},
"foreignKeys": {
"posts_author_id_users_id_fk": {
"name": "posts_author_id_users_id_fk",
"tableFrom": "posts",
"tableTo": "users",
"columnsFrom": [
"author_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"project_links": {
"name": "project_links",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"project_id": {
"name": "project_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"kind": {
"name": "kind",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"label": {
"name": "label",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"url": {
"name": "url",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"position": {
"name": "position",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
}
},
"indexes": {},
"foreignKeys": {
"project_links_project_id_projects_id_fk": {
"name": "project_links_project_id_projects_id_fk",
"tableFrom": "project_links",
"tableTo": "projects",
"columnsFrom": [
"project_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"project_tags": {
"name": "project_tags",
"columns": {
"project_id": {
"name": "project_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"tag_id": {
"name": "tag_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"project_tags_project_id_projects_id_fk": {
"name": "project_tags_project_id_projects_id_fk",
"tableFrom": "project_tags",
"tableTo": "projects",
"columnsFrom": [
"project_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"project_tags_tag_id_tags_id_fk": {
"name": "project_tags_tag_id_tags_id_fk",
"tableFrom": "project_tags",
"tableTo": "tags",
"columnsFrom": [
"tag_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"project_tags_project_id_tag_id_pk": {
"columns": [
"project_id",
"tag_id"
],
"name": "project_tags_project_id_tag_id_pk"
}
},
"uniqueConstraints": {},
"checkConstraints": {}
},
"projects": {
"name": "projects",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"slug": {
"name": "slug",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"summary": {
"name": "summary",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"body_md": {
"name": "body_md",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"cover_url": {
"name": "cover_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"author_id": {
"name": "author_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'published'"
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {
"projects_slug_unique": {
"name": "projects_slug_unique",
"columns": [
"slug"
],
"isUnique": true
}
},
"foreignKeys": {
"projects_author_id_users_id_fk": {
"name": "projects_author_id_users_id_fk",
"tableFrom": "projects",
"tableTo": "users",
"columnsFrom": [
"author_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"sessions": {
"name": "sessions",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"sessions_user_id_users_id_fk": {
"name": "sessions_user_id_users_id_fk",
"tableFrom": "sessions",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"tags": {
"name": "tags",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"tags_name_unique": {
"name": "tags_name_unique",
"columns": [
"name"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"gitea_id": {
"name": "gitea_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"avatar_url": {
"name": "avatar_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'editor'"
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {
"users_gitea_id_unique": {
"name": "users_gitea_id_unique",
"columns": [
"gitea_id"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "sqlite",
"entries": [
{
"idx": 0,
"version": "6",
"when": 1779267485591,
"tag": "0000_giant_mother_askani",
"breakpoints": true
}
]
}

4071
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

42
package.json Normal file
View File

@@ -0,0 +1,42 @@
{
"name": "helix",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "HELIX — R&D showcase platform. EVOLV and every R&D strand, one helix.",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"start": "node build",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"db:generate": "drizzle-kit generate",
"db:migrate": "node scripts/migrate.js",
"db:seed": "node scripts/seed.js"
},
"devDependencies": {
"@sveltejs/adapter-node": "^5.2.0",
"@sveltejs/kit": "^2.5.0",
"@sveltejs/vite-plugin-svelte": "^4.0.0",
"@types/better-sqlite3": "^7.6.8",
"autoprefixer": "^10.4.20",
"drizzle-kit": "^0.27.0",
"postcss": "^8.4.47",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"tailwindcss": "^3.4.13",
"typescript": "^5.5.0",
"vite": "^5.4.0"
},
"dependencies": {
"@oslojs/crypto": "^1.0.1",
"@oslojs/encoding": "^1.1.0",
"better-sqlite3": "^11.3.0",
"drizzle-orm": "^0.45.2",
"marked": "^14.0.0"
},
"engines": {
"node": ">=18.13"
}
}

6
postcss.config.js Normal file
View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
};

20
scripts/migrate.js Normal file
View File

@@ -0,0 +1,20 @@
#!/usr/bin/env node
/**
* Runs all pending Drizzle migrations against DATABASE_URL.
*
* node scripts/migrate.js
*/
import Database from 'better-sqlite3';
import { drizzle } from 'drizzle-orm/better-sqlite3';
import { migrate } from 'drizzle-orm/better-sqlite3/migrator';
const url = process.env.DATABASE_URL ?? './helix.db';
const sqlite = new Database(url);
sqlite.pragma('journal_mode = WAL');
sqlite.pragma('foreign_keys = ON');
const db = drizzle(sqlite);
migrate(db, { migrationsFolder: './drizzle' });
console.log(`[helix] migrations applied → ${url}`);
sqlite.close();

141
scripts/seed.js Normal file
View File

@@ -0,0 +1,141 @@
#!/usr/bin/env node
/**
* Seeds the database with a couple of example projects + posts so the landing
* page has something to render on first boot. Safe to re-run — uses INSERT OR
* IGNORE semantics via fixed IDs.
*
* node scripts/seed.js
*/
import Database from 'better-sqlite3';
const url = process.env.DATABASE_URL ?? './helix.db';
const db = new Database(url);
db.pragma('foreign_keys = ON');
const now = Math.floor(Date.now() / 1000);
const projects = [
{
id: 'prj_evolv',
slug: 'evolv',
title: 'EVOLV',
summary:
'ISA-88 Node-RED nodes for wastewater treatment plant automation. The flagship R&D stack.',
body_md: [
'# EVOLV',
'',
'EVOLV is a Node-RED custom-nodes package implementing the **ISA-88 (S88)** batch-control standard for wastewater treatment plants.',
'',
'Each node follows a three-layer architecture: Node-RED wrapper → adapter → pure domain logic. Configuration is JSON-driven; measurement series flow through a chainable container; outputs are normalised for InfluxDB and Grafana.',
'',
'## Highlights',
'',
'- Reactors, settlers, pumping stations, valves, rotating machines, machine groups',
'- Physics-checked: hydraulics, mass balance, biology, rotating-equipment envelopes',
'- Telemetry-first: every state surfaces on Ports 0/1/2 with declared output manifests',
'- Used at Waterschap Brabantse Delta for plant simulation and live control'
].join('\n'),
cover_url: null,
status: 'published'
},
{
id: 'prj_helix',
slug: 'helix',
title: 'HELIX',
summary:
'This very site — the R&D showcase platform. EVOLV and every R&D strand, one helix.',
body_md: [
'# HELIX',
'',
'HELIX is the R&D showcase platform of Waterschap Brabantse Delta. It collects projects, innovations, and updates from across the team in one place — with deep links to the actual repos, dashboards, and demos.',
'',
'## Stack',
'',
'- **SvelteKit 2** + **Svelte 5** + TypeScript',
'- **Tailwind v4** (CSS-first design tokens)',
'- **SQLite** + **Drizzle ORM** — single-file, easy to back up',
'- **Gitea OAuth** for authoring',
'- Pure SVG + CSS for the helix animation — no WebGL',
'',
'## Why?',
'',
'Innovations were scattered: Gitea repos here, Grafana dashboards there, slide decks elsewhere. HELIX is the strand they share.'
].join('\n'),
cover_url: null,
status: 'published'
}
];
const links = [
{
id: 'lnk_evolv_repo',
project_id: 'prj_evolv',
kind: 'gitea',
label: 'gitea.wbd-rd.nl/RnD/EVOLV',
url: 'https://gitea.wbd-rd.nl/RnD/EVOLV',
position: 0
},
{
id: 'lnk_helix_repo',
project_id: 'prj_helix',
kind: 'gitea',
label: 'gitea.wbd-rd.nl/RnD/helix',
url: 'https://gitea.wbd-rd.nl/RnD/helix',
position: 0
}
];
const posts = [
{
id: 'pst_welcome',
slug: 'welcome-to-helix',
title: 'Welcome to HELIX',
summary: 'Why this site exists and how to contribute.',
body_md: [
'# Welcome to HELIX',
'',
'HELIX is the home of R&D output at Waterschap Brabantse Delta. If you have a project, a dashboard, or a one-off experiment worth showing — it belongs here.',
'',
'## How to post',
'',
'Sign in with your Gitea account (top-right). Then:',
'',
'- **Projects** are durable showcases. Title, summary, markdown body, links to repos / dashboards / demos.',
'- **Posts** are updates, write-ups, notes. Anything blog-shaped.',
'',
'Both render with full markdown and can link to live dashboards. Iframe embeds are gated by an allowlist in `src/lib/config.ts`.',
'',
"Don't see your dashboard host in the allowlist? Add it in a PR — one line."
].join('\n'),
published_at: now
}
];
const insertProject = db.prepare(`
INSERT OR IGNORE INTO projects
(id, slug, title, summary, body_md, cover_url, author_id, status, created_at, updated_at)
VALUES
(@id, @slug, @title, @summary, @body_md, @cover_url, NULL, @status, ${now}, ${now})
`);
const insertLink = db.prepare(`
INSERT OR IGNORE INTO project_links
(id, project_id, kind, label, url, position)
VALUES
(@id, @project_id, @kind, @label, @url, @position)
`);
const insertPost = db.prepare(`
INSERT OR IGNORE INTO posts
(id, slug, title, summary, body_md, author_id, published_at, created_at, updated_at)
VALUES
(@id, @slug, @title, @summary, @body_md, NULL, @published_at, ${now}, ${now})
`);
const tx = db.transaction(() => {
for (const p of projects) insertProject.run(p);
for (const l of links) insertLink.run(l);
for (const p of posts) insertPost.run(p);
});
tx();
console.log(`[helix] seeded ${projects.length} projects, ${links.length} links, ${posts.length} posts → ${url}`);
db.close();

130
src/app.css Normal file
View File

@@ -0,0 +1,130 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Design tokens exposed as CSS vars so Svelte component <style> blocks
can use them without going through Tailwind. */
:root {
/* S88-inspired hierarchy palette (mirrors EVOLV) */
--color-helix-area: #0f52a5;
--color-helix-process: #0c99d9;
--color-helix-unit: #50a8d9;
--color-helix-equipment: #86bbdd;
--color-helix-control: #a9daee;
/* Surfaces */
--color-helix-bg: #07111d;
--color-helix-bg-2: #0c1c30;
--color-helix-bg-3: #122842;
--color-helix-border: #1f3a5e;
/* Ink */
--color-helix-ink: #e6f1fb;
--color-helix-ink-dim: #8fa6b8;
--color-helix-ink-faint: #5b7388;
/* Accent (helix glow / R&D signal) */
--color-helix-accent: #4dd0c2;
--color-helix-accent-2: #c084fc;
--font-sans: 'Inter', system-ui, -apple-system, 'Segoe UI', sans-serif;
--font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, monospace;
}
html,
body {
background: var(--color-helix-bg);
color: var(--color-helix-ink);
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
font-feature-settings: 'cv11', 'ss01', 'ss03';
}
/* Subtle radial vignette anchoring the landing page */
body::before {
content: '';
position: fixed;
inset: 0;
background:
radial-gradient(1200px 800px at 20% 0%, rgba(12, 153, 217, 0.18), transparent 60%),
radial-gradient(900px 700px at 90% 20%, rgba(77, 208, 194, 0.12), transparent 60%),
radial-gradient(700px 500px at 50% 100%, rgba(192, 132, 252, 0.08), transparent 60%);
pointer-events: none;
z-index: -1;
}
::selection {
background: var(--color-helix-accent);
color: var(--color-helix-bg);
}
/* Honour reduced-motion globally */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.001ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.001ms !important;
}
}
/* Markdown body (for project + post detail pages) */
.prose-helix {
color: var(--color-helix-ink);
line-height: 1.7;
font-size: 1.05rem;
}
.prose-helix h1,
.prose-helix h2,
.prose-helix h3 {
color: var(--color-helix-ink);
font-weight: 600;
letter-spacing: -0.02em;
margin-top: 2em;
margin-bottom: 0.5em;
}
.prose-helix h1 { font-size: 2rem; }
.prose-helix h2 { font-size: 1.5rem; }
.prose-helix h3 { font-size: 1.2rem; }
.prose-helix p { margin: 1em 0; }
.prose-helix a {
color: var(--color-helix-accent);
text-decoration: underline;
text-decoration-color: rgba(77, 208, 194, 0.4);
text-underline-offset: 3px;
}
.prose-helix a:hover { text-decoration-color: var(--color-helix-accent); }
.prose-helix code {
font-family: var(--font-mono);
font-size: 0.92em;
background: var(--color-helix-bg-2);
padding: 0.15em 0.35em;
border-radius: 4px;
border: 1px solid var(--color-helix-border);
}
.prose-helix pre {
background: var(--color-helix-bg-2);
border: 1px solid var(--color-helix-border);
border-radius: 8px;
padding: 1rem;
overflow-x: auto;
font-family: var(--font-mono);
font-size: 0.9em;
line-height: 1.5;
}
.prose-helix pre code { background: transparent; border: none; padding: 0; }
.prose-helix blockquote {
border-left: 3px solid var(--color-helix-accent);
padding-left: 1rem;
color: var(--color-helix-ink-dim);
margin: 1.5em 0;
}
.prose-helix ul, .prose-helix ol { padding-left: 1.5em; margin: 1em 0; }
.prose-helix li { margin: 0.4em 0; }
.prose-helix img { border-radius: 8px; max-width: 100%; }
.prose-helix hr {
border: none;
border-top: 1px solid var(--color-helix-border);
margin: 2em 0;
}

15
src/app.d.ts vendored Normal file
View File

@@ -0,0 +1,15 @@
import type { User } from '$lib/server/db/schema';
declare global {
namespace App {
interface Locals {
user: User | null;
session: { id: string; expiresAt: Date } | null;
}
interface PageData {
user: User | null;
}
}
}
export {};

16
src/app.html Normal file
View File

@@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" type="image/svg+xml" href="%sveltekit.assets%/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#0c99d9" />
<meta name="description" content="HELIX — the R&D showcase platform of Waterschap Brabantse Delta. EVOLV and every R&D strand, one helix." />
<link rel="preconnect" href="https://rsms.me/" />
<link rel="stylesheet" href="https://rsms.me/inter/inter.css" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

32
src/hooks.server.ts Normal file
View File

@@ -0,0 +1,32 @@
import type { Handle } from '@sveltejs/kit';
import { dev } from '$app/environment';
import { SESSION_COOKIE, validateSessionToken } from '$lib/server/auth';
export const handle: Handle = async ({ event, resolve }) => {
const token = event.cookies.get(SESSION_COOKIE);
if (!token) {
event.locals.user = null;
event.locals.session = null;
return resolve(event);
}
const { user, session } = validateSessionToken(token);
if (session) {
// Refresh the cookie so the sliding window in validateSessionToken takes effect on the client too.
event.cookies.set(SESSION_COOKIE, token, {
path: '/',
httpOnly: true,
sameSite: 'lax',
secure: !dev,
expires: session.expiresAt
});
} else {
event.cookies.delete(SESSION_COOKIE, { path: '/' });
}
event.locals.user = user;
event.locals.session = session;
return resolve(event);
};

View File

@@ -0,0 +1,108 @@
<script lang="ts">
import { DASHBOARD_ALLOWED_HOSTS } from '$lib/config';
let { url, label }: { url: string; label: string } = $props();
function hostOf(u: string): string | null {
try {
return new URL(u).hostname;
} catch {
return null;
}
}
const host = $derived(hostOf(url));
const allowed = $derived(host !== null && DASHBOARD_ALLOWED_HOSTS.includes(host));
</script>
<figure class="embed">
<figcaption>
<span class="label">{label}</span>
<a href={url} target="_blank" rel="noreferrer noopener" class="open">Open ↗</a>
</figcaption>
{#if allowed}
<iframe
src={url}
title={label}
loading="lazy"
referrerpolicy="strict-origin-when-cross-origin"
sandbox="allow-scripts allow-same-origin allow-popups"
></iframe>
{:else}
<div class="blocked">
<p>
Dashboard host <code>{host ?? '?'}</code> is not on the embed allowlist.
</p>
<p class="hint">
Add it to <code>DASHBOARD_ALLOWED_HOSTS</code> in
<code>src/lib/config.ts</code> to embed inline.
</p>
<a href={url} target="_blank" rel="noreferrer noopener" class="open big">Open in new tab ↗</a>
</div>
{/if}
</figure>
<style>
.embed {
margin: 1.5rem 0;
border: 1px solid var(--color-helix-border);
border-radius: 12px;
overflow: hidden;
background: var(--color-helix-bg-2);
}
figcaption {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.7rem 1rem;
background: var(--color-helix-bg-3);
border-bottom: 1px solid var(--color-helix-border);
font-size: 0.85rem;
}
.label {
font-weight: 500;
color: var(--color-helix-ink);
}
.open {
color: var(--color-helix-accent);
text-decoration: none;
}
.open:hover {
text-decoration: underline;
}
iframe {
width: 100%;
height: 520px;
border: none;
display: block;
background: var(--color-helix-bg);
}
.blocked {
padding: 1.25rem;
color: var(--color-helix-ink-dim);
font-size: 0.9rem;
}
.blocked p {
margin: 0.35rem 0;
}
.blocked code {
font-family: var(--font-mono);
font-size: 0.85em;
color: var(--color-helix-ink);
background: var(--color-helix-bg);
padding: 0.1em 0.35em;
border-radius: 4px;
border: 1px solid var(--color-helix-border);
}
.hint {
color: var(--color-helix-ink-faint);
}
.open.big {
display: inline-block;
margin-top: 0.6rem;
padding: 0.45rem 0.9rem;
border-radius: 6px;
border: 1px solid var(--color-helix-accent);
background: transparent;
}
</style>

View File

@@ -0,0 +1,47 @@
<script lang="ts">
import { SITE } from '$lib/config';
const year = new Date().getFullYear();
</script>
<footer class="footer">
<div class="inner">
<div class="left">
<strong class="brand">{SITE.name}</strong> · {SITE.organization} · {year}
</div>
<div class="right">
<a href={SITE.giteaBaseUrl + '/RnD/helix'} target="_blank" rel="noreferrer noopener">
Source ↗
</a>
</div>
</div>
</footer>
<style>
.footer {
border-top: 1px solid var(--color-helix-border);
margin-top: 6rem;
background: var(--color-helix-bg-2);
}
.inner {
max-width: 1200px;
margin: 0 auto;
padding: 1.75rem 1.5rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
color: var(--color-helix-ink-faint);
font-size: 0.85rem;
}
.brand {
color: var(--color-helix-ink);
letter-spacing: 0.08em;
}
.right a {
color: var(--color-helix-ink-dim);
text-decoration: none;
}
.right a:hover {
color: var(--color-helix-accent);
}
</style>

View File

@@ -0,0 +1,223 @@
<script lang="ts">
/**
* Animated DNA helix in pure SVG + CSS.
*
* Two sine-wave strands offset by π, with vertical "rungs" whose width
* and opacity track |sin(kx)| — they look like base-pairs that fade as
* the strands cross. The whole thing scrolls horizontally to give the
* illusion of the helix rotating around its long axis.
*
* No JS framework cost: paths are generated once at module init.
*/
// Viewport units in user-space (matches viewBox)
const W = 1600;
const H = 420;
const CY = H / 2;
const AMP = 130;
const PERIOD = 320;
const STEPS = 320;
const RUNG_SPACING = 24;
const k = (2 * Math.PI) / PERIOD;
function buildStrand(phase: number): string {
const parts: string[] = [];
for (let i = 0; i <= STEPS; i++) {
const x = (W / STEPS) * i;
const y = CY + AMP * Math.sin(k * x + phase);
parts.push(`${i === 0 ? 'M' : 'L'}${x.toFixed(2)} ${y.toFixed(2)}`);
}
return parts.join(' ');
}
const strandA = buildStrand(0);
const strandB = buildStrand(Math.PI);
type Rung = { x: number; y1: number; y2: number; depth: number; front: boolean };
const rungs: Rung[] = [];
for (let x = 0; x <= W; x += RUNG_SPACING) {
const sA = Math.sin(k * x);
const y1 = CY + AMP * sA;
const y2 = CY - AMP * sA;
const depth = Math.abs(sA); // 0 at crossings, 1 at extremes
rungs.push({ x, y1, y2, depth, front: sA > 0 });
}
</script>
<div class="helix-wrap" aria-hidden="true">
<svg
class="helix"
viewBox="0 0 {W} {H}"
preserveAspectRatio="xMidYMid slice"
role="presentation"
>
<defs>
<linearGradient id="helix-strand-a" x1="0" x2="1" y1="0" y2="0">
<stop offset="0%" stop-color="#0f52a5" />
<stop offset="45%" stop-color="#0c99d9" />
<stop offset="100%" stop-color="#4dd0c2" />
</linearGradient>
<linearGradient id="helix-strand-b" x1="0" x2="1" y1="0" y2="0">
<stop offset="0%" stop-color="#c084fc" />
<stop offset="45%" stop-color="#50a8d9" />
<stop offset="100%" stop-color="#a9daee" />
</linearGradient>
<filter id="helix-glow" x="-10%" y="-30%" width="120%" height="160%">
<feGaussianBlur stdDeviation="4" result="b" />
<feMerge>
<feMergeNode in="b" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
<radialGradient id="helix-spot" cx="0.5" cy="0.5" r="0.6">
<stop offset="0%" stop-color="#4dd0c2" stop-opacity="0.35" />
<stop offset="100%" stop-color="#4dd0c2" stop-opacity="0" />
</radialGradient>
</defs>
<!-- Soft accent glow behind the helix -->
<rect x="0" y="0" width={W} height={H} fill="url(#helix-spot)" />
<g class="helix-flow">
<g class="helix-pair">
<!-- Rungs (back-pass: those where strand A is in front) -->
{#each rungs as r}
{#if !r.front}
<line
x1={r.x}
y1={r.y1}
x2={r.x}
y2={r.y2}
stroke="#86bbdd"
stroke-opacity={0.10 + 0.45 * r.depth}
stroke-width={0.8 + 1.6 * r.depth}
stroke-linecap="round"
/>
{/if}
{/each}
<!-- Strand B behind -->
<path
d={strandB}
fill="none"
stroke="url(#helix-strand-b)"
stroke-width="3.2"
stroke-linecap="round"
stroke-opacity="0.85"
filter="url(#helix-glow)"
/>
<!-- Front rungs -->
{#each rungs as r}
{#if r.front}
<line
x1={r.x}
y1={r.y1}
x2={r.x}
y2={r.y2}
stroke="#a9daee"
stroke-opacity={0.20 + 0.55 * r.depth}
stroke-width={1.2 + 1.8 * r.depth}
stroke-linecap="round"
/>
{/if}
{/each}
<!-- Strand A in front -->
<path
d={strandA}
fill="none"
stroke="url(#helix-strand-a)"
stroke-width="3.6"
stroke-linecap="round"
filter="url(#helix-glow)"
/>
</g>
<!-- Seamless duplicate -->
<g class="helix-pair" transform="translate({W} 0)">
{#each rungs as r}
{#if !r.front}
<line
x1={r.x}
y1={r.y1}
x2={r.x}
y2={r.y2}
stroke="#86bbdd"
stroke-opacity={0.10 + 0.45 * r.depth}
stroke-width={0.8 + 1.6 * r.depth}
stroke-linecap="round"
/>
{/if}
{/each}
<path
d={strandB}
fill="none"
stroke="url(#helix-strand-b)"
stroke-width="3.2"
stroke-linecap="round"
stroke-opacity="0.85"
filter="url(#helix-glow)"
/>
{#each rungs as r}
{#if r.front}
<line
x1={r.x}
y1={r.y1}
x2={r.x}
y2={r.y2}
stroke="#a9daee"
stroke-opacity={0.20 + 0.55 * r.depth}
stroke-width={1.2 + 1.8 * r.depth}
stroke-linecap="round"
/>
{/if}
{/each}
<path
d={strandA}
fill="none"
stroke="url(#helix-strand-a)"
stroke-width="3.6"
stroke-linecap="round"
filter="url(#helix-glow)"
/>
</g>
</g>
</svg>
</div>
<style>
.helix-wrap {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
mask-image: linear-gradient(
90deg,
transparent 0%,
black 8%,
black 92%,
transparent 100%
);
}
.helix {
width: 100%;
height: 100%;
display: block;
}
@media (prefers-reduced-motion: no-preference) {
.helix-flow {
animation: helix-flow 22s linear infinite;
will-change: transform;
}
}
@keyframes helix-flow {
from {
transform: translateX(0);
}
to {
transform: translateX(-1600px);
}
}
</style>

View File

@@ -0,0 +1,75 @@
<script lang="ts">
import { LINK_KIND_LABEL, type LinkKind } from '$lib/config';
import type { ProjectLink } from '$lib/server/db/schema';
let { links }: { links: ProjectLink[] } = $props();
const iconFor: Record<LinkKind, string> = {
gitea: '⎇',
dashboard: '◐',
demo: '▶',
docs: '✎',
paper: '⌬',
video: '◻'
};
</script>
{#if links.length > 0}
<ul class="chips">
{#each links as l}
<li>
<a href={l.url} target="_blank" rel="noreferrer noopener" class="chip" data-kind={l.kind}>
<span class="icon" aria-hidden="true">{iconFor[l.kind as LinkKind] ?? '·'}</span>
<span class="kind">{LINK_KIND_LABEL[l.kind as LinkKind] ?? l.kind}</span>
<span class="label">{l.label}</span>
<span class="ext"></span>
</a>
</li>
{/each}
</ul>
{/if}
<style>
.chips {
display: flex;
flex-wrap: wrap;
gap: 0.6rem;
margin: 1.25rem 0;
padding: 0;
list-style: none;
}
.chip {
display: inline-flex;
align-items: center;
gap: 0.55rem;
padding: 0.5rem 0.85rem;
border-radius: 999px;
background: var(--color-helix-bg-2);
border: 1px solid var(--color-helix-border);
color: var(--color-helix-ink);
text-decoration: none;
font-size: 0.875rem;
transition: border-color 160ms ease, background 160ms ease;
}
.chip:hover {
border-color: var(--color-helix-accent);
background: var(--color-helix-bg-3);
}
.icon {
color: var(--color-helix-accent);
font-family: var(--font-mono);
}
.kind {
color: var(--color-helix-ink-faint);
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.label {
color: var(--color-helix-ink);
}
.ext {
color: var(--color-helix-ink-faint);
font-size: 0.85rem;
}
</style>

View File

@@ -0,0 +1,192 @@
<script lang="ts">
import { page } from '$app/state';
import { SITE } from '$lib/config';
import type { User } from '$lib/server/db/schema';
let { user }: { user: User | null } = $props();
const links = [
{ href: '/projects', label: 'Projects' },
{ href: '/posts', label: 'Posts' },
{ href: 'https://gitea.wbd-rd.nl/RnD', label: 'Gitea', external: true }
];
function isActive(href: string) {
return page.url.pathname === href || page.url.pathname.startsWith(href + '/');
}
</script>
<header class="nav">
<div class="inner">
<a href="/" class="brand">
<span class="mark" aria-hidden="true">
<svg viewBox="0 0 24 24" width="22" height="22">
<path
d="M4 4 C 4 10, 20 14, 20 20"
fill="none"
stroke="#4dd0c2"
stroke-width="2"
stroke-linecap="round"
/>
<path
d="M4 20 C 4 14, 20 10, 20 4"
fill="none"
stroke="#0c99d9"
stroke-width="2"
stroke-linecap="round"
/>
</svg>
</span>
<span class="wordmark">{SITE.name}</span>
</a>
<nav class="links">
{#each links as l}
{#if l.external}
<a href={l.href} target="_blank" rel="noreferrer noopener">{l.label}</a>
{:else}
<a href={l.href} class:active={isActive(l.href)}>{l.label}</a>
{/if}
{/each}
</nav>
<div class="account">
{#if user}
<a href="/projects/new" class="post">+ New</a>
<div class="user">
{#if user.avatarUrl}
<img src={user.avatarUrl} alt="" class="avatar" />
{/if}
<span class="username">{user.username}</span>
</div>
<form method="POST" action="/logout">
<button type="submit" class="logout" aria-label="Sign out">Sign out</button>
</form>
{:else}
<a href="/login" class="signin">Sign in</a>
{/if}
</div>
</div>
</header>
<style>
.nav {
position: sticky;
top: 0;
z-index: 40;
backdrop-filter: blur(14px);
background: color-mix(in oklab, var(--color-helix-bg) 70%, transparent);
border-bottom: 1px solid var(--color-helix-border);
}
.inner {
max-width: 1200px;
margin: 0 auto;
padding: 0.85rem 1.5rem;
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 1.5rem;
}
.brand {
display: inline-flex;
align-items: center;
gap: 0.6rem;
text-decoration: none;
color: var(--color-helix-ink);
}
.mark {
display: grid;
place-items: center;
width: 32px;
height: 32px;
border-radius: 8px;
background: var(--color-helix-bg-2);
border: 1px solid var(--color-helix-border);
}
.wordmark {
font-weight: 700;
letter-spacing: 0.08em;
font-size: 0.95rem;
}
.links {
display: flex;
gap: 1.5rem;
justify-content: center;
}
.links a {
text-decoration: none;
color: var(--color-helix-ink-dim);
font-size: 0.92rem;
transition: color 160ms ease;
}
.links a:hover {
color: var(--color-helix-ink);
}
.links a.active {
color: var(--color-helix-accent);
}
.account {
display: flex;
align-items: center;
gap: 0.85rem;
}
.signin,
.post {
text-decoration: none;
font-size: 0.88rem;
font-weight: 500;
padding: 0.45rem 0.9rem;
border-radius: 7px;
border: 1px solid var(--color-helix-border);
color: var(--color-helix-ink);
transition: border-color 160ms ease, background 160ms ease;
}
.signin:hover,
.post:hover {
border-color: var(--color-helix-accent);
}
.post {
background: var(--color-helix-process);
border-color: var(--color-helix-process);
color: white;
}
.post:hover {
background: var(--color-helix-area);
border-color: var(--color-helix-area);
}
.user {
display: flex;
align-items: center;
gap: 0.5rem;
color: var(--color-helix-ink-dim);
font-size: 0.9rem;
}
.avatar {
width: 26px;
height: 26px;
border-radius: 50%;
border: 1px solid var(--color-helix-border);
}
.logout {
background: none;
border: none;
color: var(--color-helix-ink-faint);
cursor: pointer;
font-size: 0.85rem;
transition: color 160ms ease;
}
.logout:hover {
color: var(--color-helix-ink);
}
@media (max-width: 720px) {
.inner {
grid-template-columns: auto 1fr;
}
.links {
display: none;
}
.username {
display: none;
}
}
</style>

View File

@@ -0,0 +1,74 @@
<script lang="ts">
import type { Post } from '$lib/server/db/schema';
let { post }: { post: Pick<Post, 'slug' | 'title' | 'summary' | 'publishedAt'> } = $props();
const date = $derived(
post.publishedAt
? new Date(post.publishedAt).toLocaleDateString('en-GB', {
day: 'numeric',
month: 'short',
year: 'numeric'
})
: ''
);
</script>
<a href="/posts/{post.slug}" class="row group">
<div class="date">{date}</div>
<div class="body">
<h3 class="title">{post.title}</h3>
{#if post.summary}<p class="summary">{post.summary}</p>{/if}
</div>
<div class="arrow"></div>
</a>
<style>
.row {
display: grid;
grid-template-columns: 120px 1fr auto;
align-items: baseline;
gap: 1.5rem;
padding: 1.25rem 0;
border-bottom: 1px solid var(--color-helix-border);
text-decoration: none;
color: inherit;
transition: padding 240ms ease;
}
.row:hover {
padding-left: 0.5rem;
}
.date {
font-family: var(--font-mono);
font-size: 0.8rem;
color: var(--color-helix-ink-faint);
text-transform: uppercase;
letter-spacing: 0.06em;
}
.title {
font-size: 1.1rem;
font-weight: 600;
letter-spacing: -0.01em;
}
.summary {
margin-top: 0.25rem;
color: var(--color-helix-ink-dim);
font-size: 0.95rem;
}
.arrow {
color: var(--color-helix-accent);
align-self: center;
transition: transform 240ms ease;
}
.row:hover .arrow {
transform: translateX(4px);
}
@media (max-width: 640px) {
.row {
grid-template-columns: 1fr auto;
}
.date {
grid-column: 1 / -1;
}
}
</style>

View File

@@ -0,0 +1,98 @@
<script lang="ts">
import type { Project } from '$lib/server/db/schema';
let { project }: { project: Pick<Project, 'slug' | 'title' | 'summary' | 'coverUrl'> } = $props();
</script>
<a href="/projects/{project.slug}" class="card group">
<div class="cover" style:background-image={project.coverUrl ? `url(${project.coverUrl})` : ''}>
{#if !project.coverUrl}
<div class="cover-fallback">
<span class="cover-glyph"></span>
</div>
{/if}
</div>
<div class="body">
<h3 class="title">{project.title}</h3>
<p class="summary">{project.summary}</p>
<span class="cta">Open <span class="arrow"></span></span>
</div>
</a>
<style>
.card {
display: flex;
flex-direction: column;
border-radius: 14px;
overflow: hidden;
background: linear-gradient(180deg, var(--color-helix-bg-2), var(--color-helix-bg));
border: 1px solid var(--color-helix-border);
transition: border-color 240ms ease, transform 240ms ease, box-shadow 240ms ease;
text-decoration: none;
color: inherit;
}
.card:hover {
border-color: var(--color-helix-accent);
transform: translateY(-2px);
box-shadow: 0 12px 32px -16px rgba(77, 208, 194, 0.45);
}
.cover {
aspect-ratio: 16 / 9;
background-size: cover;
background-position: center;
background-color: var(--color-helix-bg-3);
position: relative;
}
.cover-fallback {
position: absolute;
inset: 0;
display: grid;
place-items: center;
background: radial-gradient(
circle at 50% 60%,
rgba(12, 153, 217, 0.25),
transparent 60%
);
}
.cover-glyph {
font-size: 3rem;
color: var(--color-helix-process);
opacity: 0.55;
font-family: var(--font-mono);
}
.body {
padding: 1.25rem 1.25rem 1.4rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.title {
font-size: 1.15rem;
font-weight: 600;
letter-spacing: -0.01em;
}
.summary {
color: var(--color-helix-ink-dim);
font-size: 0.95rem;
line-height: 1.5;
/* clamp */
display: -webkit-box;
-webkit-line-clamp: 3;
line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.cta {
margin-top: 0.5rem;
color: var(--color-helix-accent);
font-size: 0.875rem;
font-weight: 500;
}
.arrow {
display: inline-block;
transition: transform 240ms ease;
}
.card:hover .arrow {
transform: translateX(3px);
}
</style>

34
src/lib/config.ts Normal file
View File

@@ -0,0 +1,34 @@
/**
* Site-wide configuration. Edit here to rebrand without touching components.
*/
export const SITE = {
name: 'HELIX',
tagline: 'EVOLV and every R&D strand, one helix.',
description:
'The R&D showcase platform of Waterschap Brabantse Delta. EVOLV at its core, every innovation along the strands.',
organization: 'Waterschap Brabantse Delta R&D',
giteaOrg: 'RnD',
giteaBaseUrl: 'https://gitea.wbd-rd.nl'
} as const;
/**
* Hostnames whose iframe embeds are allowed on project pages.
* Add Grafana / dashboard hosts here. The render layer enforces this allowlist.
*/
export const DASHBOARD_ALLOWED_HOSTS: ReadonlyArray<string> = [
'grafana.wbd-rd.nl',
'dashboards.wbd-rd.nl',
'localhost'
];
export const LINK_KINDS = ['gitea', 'dashboard', 'demo', 'docs', 'paper', 'video'] as const;
export type LinkKind = (typeof LINK_KINDS)[number];
export const LINK_KIND_LABEL: Record<LinkKind, string> = {
gitea: 'Repository',
dashboard: 'Dashboard',
demo: 'Live demo',
docs: 'Documentation',
paper: 'Paper',
video: 'Video'
};

31
src/lib/markdown.ts Normal file
View File

@@ -0,0 +1,31 @@
import { marked } from 'marked';
marked.setOptions({
gfm: true,
breaks: false
});
/**
* Renders trusted markdown to HTML.
*
* Trust model: authoring is gated to members of the configured Gitea org,
* so we render markdown as-is (raw HTML in markdown is passed through).
* If HELIX is opened to untrusted authors, swap this for a DOMPurify pass.
*/
export function renderMarkdown(md: string): string {
return marked.parse(md) as string;
}
export function slugify(s: string): string {
return s
.toLowerCase()
.normalize('NFKD')
.replace(/[̀-ͯ]/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 80);
}
export function newId(prefix: string): string {
return `${prefix}_${crypto.randomUUID().replace(/-/g, '').slice(0, 12)}`;
}

65
src/lib/server/auth.ts Normal file
View File

@@ -0,0 +1,65 @@
import { sha256 } from '@oslojs/crypto/sha2';
import { encodeBase32LowerCaseNoPadding, encodeHexLowerCase } from '@oslojs/encoding';
import { eq } from 'drizzle-orm';
import { db } from './db';
import { sessions, users, type User, type Session } from './db/schema';
export const SESSION_COOKIE = 'helix-session';
export const OAUTH_STATE_COOKIE = 'helix-oauth-state';
const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
const SESSION_REFRESH_THRESHOLD_MS = 15 * 24 * 60 * 60 * 1000; // refresh when <15d left
/**
* Random opaque token shown to the client (base32, 32 chars).
* The server stores only its SHA-256 hash; the raw token is the bearer.
*/
export function createSessionToken(): string {
const bytes = new Uint8Array(20);
crypto.getRandomValues(bytes);
return encodeBase32LowerCaseNoPadding(bytes);
}
function hashToken(token: string): string {
return encodeHexLowerCase(sha256(new TextEncoder().encode(token)));
}
export function createSession(token: string, userId: string): Session {
const id = hashToken(token);
const expiresAt = new Date(Date.now() + SESSION_TTL_MS);
db.insert(sessions).values({ id, userId, expiresAt }).run();
return { id, userId, expiresAt };
}
export function validateSessionToken(
token: string
): { user: User | null; session: Session | null } {
const id = hashToken(token);
const row = db.select().from(sessions).where(eq(sessions.id, id)).get();
if (!row) return { user: null, session: null };
const now = Date.now();
if (now >= row.expiresAt.getTime()) {
db.delete(sessions).where(eq(sessions.id, id)).run();
return { user: null, session: null };
}
// Sliding expiry: extend when <15d remaining
let session = row;
if (now >= row.expiresAt.getTime() - SESSION_REFRESH_THRESHOLD_MS) {
const expiresAt = new Date(now + SESSION_TTL_MS);
db.update(sessions).set({ expiresAt }).where(eq(sessions.id, id)).run();
session = { ...row, expiresAt };
}
const user = db.select().from(users).where(eq(users.id, session.userId)).get();
return { user: user ?? null, session };
}
export function invalidateSession(id: string): void {
db.delete(sessions).where(eq(sessions.id, id)).run();
}
export function invalidateAllSessionsForUser(userId: string): void {
db.delete(sessions).where(eq(sessions.userId, userId)).run();
}

View File

@@ -0,0 +1,13 @@
import Database from 'better-sqlite3';
import { drizzle } from 'drizzle-orm/better-sqlite3';
import { env } from '$env/dynamic/private';
import * as schema from './schema';
const url = env.DATABASE_URL ?? './helix.db';
const sqlite = new Database(url);
sqlite.pragma('journal_mode = WAL');
sqlite.pragma('foreign_keys = ON');
export const db = drizzle(sqlite, { schema });
export { schema };

152
src/lib/server/db/schema.ts Normal file
View File

@@ -0,0 +1,152 @@
import { sqliteTable, text, integer, primaryKey } from 'drizzle-orm/sqlite-core';
import { relations, sql } from 'drizzle-orm';
/* --- Users & sessions ------------------------------------------------- */
export const users = sqliteTable('users', {
id: text('id').primaryKey(),
giteaId: integer('gitea_id').notNull().unique(),
username: text('username').notNull(),
name: text('name'),
email: text('email'),
avatarUrl: text('avatar_url'),
role: text('role', { enum: ['viewer', 'editor', 'admin'] })
.notNull()
.default('editor'),
createdAt: integer('created_at', { mode: 'timestamp' })
.notNull()
.default(sql`(unixepoch())`)
});
export const sessions = sqliteTable('sessions', {
id: text('id').primaryKey(),
userId: text('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
expiresAt: integer('expires_at', { mode: 'timestamp' }).notNull()
});
/* --- Projects --------------------------------------------------------- */
export const projects = sqliteTable('projects', {
id: text('id').primaryKey(),
slug: text('slug').notNull().unique(),
title: text('title').notNull(),
summary: text('summary').notNull(),
bodyMd: text('body_md').notNull().default(''),
coverUrl: text('cover_url'),
authorId: text('author_id').references(() => users.id, { onDelete: 'set null' }),
status: text('status', { enum: ['draft', 'published'] })
.notNull()
.default('published'),
createdAt: integer('created_at', { mode: 'timestamp' })
.notNull()
.default(sql`(unixepoch())`),
updatedAt: integer('updated_at', { mode: 'timestamp' })
.notNull()
.default(sql`(unixepoch())`)
});
export const projectLinks = sqliteTable('project_links', {
id: text('id').primaryKey(),
projectId: text('project_id')
.notNull()
.references(() => projects.id, { onDelete: 'cascade' }),
kind: text('kind', {
enum: ['gitea', 'dashboard', 'demo', 'docs', 'paper', 'video']
}).notNull(),
label: text('label').notNull(),
url: text('url').notNull(),
position: integer('position').notNull().default(0)
});
/* --- Posts ------------------------------------------------------------ */
export const posts = sqliteTable('posts', {
id: text('id').primaryKey(),
slug: text('slug').notNull().unique(),
title: text('title').notNull(),
summary: text('summary').notNull().default(''),
bodyMd: text('body_md').notNull(),
authorId: text('author_id').references(() => users.id, { onDelete: 'set null' }),
publishedAt: integer('published_at', { mode: 'timestamp' }),
createdAt: integer('created_at', { mode: 'timestamp' })
.notNull()
.default(sql`(unixepoch())`),
updatedAt: integer('updated_at', { mode: 'timestamp' })
.notNull()
.default(sql`(unixepoch())`)
});
/* --- Tags ------------------------------------------------------------- */
export const tags = sqliteTable('tags', {
id: text('id').primaryKey(),
name: text('name').notNull().unique()
});
export const projectTags = sqliteTable(
'project_tags',
{
projectId: text('project_id')
.notNull()
.references(() => projects.id, { onDelete: 'cascade' }),
tagId: text('tag_id')
.notNull()
.references(() => tags.id, { onDelete: 'cascade' })
},
(t) => ({ pk: primaryKey({ columns: [t.projectId, t.tagId] }) })
);
export const postTags = sqliteTable(
'post_tags',
{
postId: text('post_id')
.notNull()
.references(() => posts.id, { onDelete: 'cascade' }),
tagId: text('tag_id')
.notNull()
.references(() => tags.id, { onDelete: 'cascade' })
},
(t) => ({ pk: primaryKey({ columns: [t.postId, t.tagId] }) })
);
/* --- Relations -------------------------------------------------------- */
export const usersRelations = relations(users, ({ many }) => ({
projects: many(projects),
posts: many(posts),
sessions: many(sessions)
}));
export const projectsRelations = relations(projects, ({ one, many }) => ({
author: one(users, { fields: [projects.authorId], references: [users.id] }),
links: many(projectLinks),
tags: many(projectTags)
}));
export const projectLinksRelations = relations(projectLinks, ({ one }) => ({
project: one(projects, { fields: [projectLinks.projectId], references: [projects.id] })
}));
export const postsRelations = relations(posts, ({ one, many }) => ({
author: one(users, { fields: [posts.authorId], references: [users.id] }),
tags: many(postTags)
}));
export const tagsRelations = relations(tags, ({ many }) => ({
projects: many(projectTags),
posts: many(postTags)
}));
/* --- Types ------------------------------------------------------------ */
export type User = typeof users.$inferSelect;
export type NewUser = typeof users.$inferInsert;
export type Project = typeof projects.$inferSelect;
export type NewProject = typeof projects.$inferInsert;
export type ProjectLink = typeof projectLinks.$inferSelect;
export type NewProjectLink = typeof projectLinks.$inferInsert;
export type Post = typeof posts.$inferSelect;
export type NewPost = typeof posts.$inferInsert;
export type Session = typeof sessions.$inferSelect;

87
src/lib/server/gitea.ts Normal file
View File

@@ -0,0 +1,87 @@
import { env } from '$env/dynamic/private';
import { encodeBase32LowerCaseNoPadding } from '@oslojs/encoding';
const baseURL = env.GITEA_BASE_URL ?? 'https://gitea.wbd-rd.nl';
const clientId = env.GITEA_CLIENT_ID ?? '';
const clientSecret = env.GITEA_CLIENT_SECRET ?? '';
const redirectURI = env.GITEA_REDIRECT_URI ?? 'http://localhost:3000/auth/gitea/callback';
const allowedOrg = env.GITEA_ALLOWED_ORG ?? '';
export const GITEA_BASE_URL = baseURL;
export const GITEA_ALLOWED_ORG = allowedOrg;
export interface GiteaUser {
id: number;
login: string;
full_name?: string;
email?: string;
avatar_url?: string;
}
interface GiteaOrg {
username?: string;
name?: string;
}
export function generateState(): string {
const bytes = new Uint8Array(20);
crypto.getRandomValues(bytes);
return encodeBase32LowerCaseNoPadding(bytes);
}
export function buildAuthorizationURL(state: string): string {
if (!clientId) throw new Error('GITEA_CLIENT_ID is not configured');
const u = new URL(`${baseURL}/login/oauth/authorize`);
u.searchParams.set('client_id', clientId);
u.searchParams.set('redirect_uri', redirectURI);
u.searchParams.set('response_type', 'code');
u.searchParams.set('state', state);
// Gitea recognises space-separated scopes. read:user + read:organization
// are sufficient for identity and org-membership checks.
u.searchParams.set('scope', 'read:user read:organization');
return u.toString();
}
export async function exchangeCode(code: string): Promise<string> {
if (!clientSecret) throw new Error('GITEA_CLIENT_SECRET is not configured');
const res = await fetch(`${baseURL}/login/oauth/access_token`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({
client_id: clientId,
client_secret: clientSecret,
code,
grant_type: 'authorization_code',
redirect_uri: redirectURI
})
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Gitea token exchange failed (${res.status}): ${text}`);
}
const data = (await res.json()) as { access_token?: string };
if (!data.access_token) throw new Error('Gitea response missing access_token');
return data.access_token;
}
export async function fetchGiteaUser(accessToken: string): Promise<GiteaUser> {
const res = await fetch(`${baseURL}/api/v1/user`, {
headers: { Authorization: `Bearer ${accessToken}` }
});
if (!res.ok) throw new Error(`Failed to fetch Gitea user: ${res.status}`);
return (await res.json()) as GiteaUser;
}
export async function isUserInOrg(
accessToken: string,
login: string,
org: string
): Promise<boolean> {
if (!org) return true;
const res = await fetch(`${baseURL}/api/v1/users/${encodeURIComponent(login)}/orgs`, {
headers: { Authorization: `Bearer ${accessToken}` }
});
if (!res.ok) return false;
const orgs = (await res.json()) as GiteaOrg[];
return orgs.some((o) => o.username === org || o.name === org);
}

View File

@@ -0,0 +1,5 @@
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = async ({ locals }) => ({
user: locals.user
});

26
src/routes/+layout.svelte Normal file
View File

@@ -0,0 +1,26 @@
<script lang="ts">
import '../app.css';
import Nav from '$lib/components/Nav.svelte';
import Footer from '$lib/components/Footer.svelte';
import { SITE } from '$lib/config';
let { data, children } = $props();
</script>
<svelte:head>
<title>{SITE.name} · {SITE.tagline}</title>
</svelte:head>
<Nav user={data.user} />
<main>
{@render children()}
</main>
<Footer />
<style>
main {
min-height: 80vh;
}
</style>

View File

@@ -0,0 +1,34 @@
import type { PageServerLoad } from './$types';
import { db } from '$lib/server/db';
import { projects, posts } from '$lib/server/db/schema';
import { desc, eq, isNotNull } from 'drizzle-orm';
export const load: PageServerLoad = async () => {
const recentProjects = db
.select({
slug: projects.slug,
title: projects.title,
summary: projects.summary,
coverUrl: projects.coverUrl
})
.from(projects)
.where(eq(projects.status, 'published'))
.orderBy(desc(projects.updatedAt))
.limit(6)
.all();
const recentPosts = db
.select({
slug: posts.slug,
title: posts.title,
summary: posts.summary,
publishedAt: posts.publishedAt
})
.from(posts)
.where(isNotNull(posts.publishedAt))
.orderBy(desc(posts.publishedAt))
.limit(5)
.all();
return { recentProjects, recentPosts };
};

246
src/routes/+page.svelte Normal file
View File

@@ -0,0 +1,246 @@
<script lang="ts">
import Helix from '$lib/components/Helix.svelte';
import ProjectCard from '$lib/components/ProjectCard.svelte';
import PostCard from '$lib/components/PostCard.svelte';
import { SITE } from '$lib/config';
let { data } = $props();
</script>
<section class="hero">
<div class="helix-layer">
<Helix />
</div>
<div class="hero-content">
<p class="eyebrow">R&amp;D · {SITE.organization}</p>
<h1 class="title">
<span class="word">HELIX</span>
</h1>
<p class="tagline">{SITE.tagline}</p>
<p class="lede">{SITE.description}</p>
<div class="cta-row">
<a href="/projects" class="cta primary">Explore projects <span class="arrow"></span></a>
<a href="/posts" class="cta">Read updates</a>
</div>
</div>
<div class="scroll-hint" aria-hidden="true">
<span>scroll</span>
<span class="bar"></span>
</div>
</section>
<section class="band">
<header class="band-head">
<h2>Recent projects</h2>
<a href="/projects" class="more">All projects →</a>
</header>
{#if data.recentProjects.length === 0}
<p class="empty">No projects yet — be the first to <a href="/projects/new">add one</a>.</p>
{:else}
<div class="grid">
{#each data.recentProjects as p}
<ProjectCard project={p} />
{/each}
</div>
{/if}
</section>
<section class="band">
<header class="band-head">
<h2>Latest from the lab</h2>
<a href="/posts" class="more">All posts →</a>
</header>
{#if data.recentPosts.length === 0}
<p class="empty">No posts yet — be the first to <a href="/posts/new">write one</a>.</p>
{:else}
<div class="post-list">
{#each data.recentPosts as p}
<PostCard post={p} />
{/each}
</div>
{/if}
</section>
<style>
.hero {
position: relative;
min-height: 78vh;
display: grid;
place-items: center;
overflow: hidden;
padding: 4rem 1.5rem 6rem;
}
.helix-layer {
position: absolute;
inset: 0;
z-index: 0;
opacity: 0.85;
}
.helix-layer::after {
content: '';
position: absolute;
inset: 0;
background: radial-gradient(
900px 500px at 50% 65%,
rgba(7, 17, 29, 0.0),
rgba(7, 17, 29, 0.55) 60%,
rgba(7, 17, 29, 0.95) 100%
);
}
.hero-content {
position: relative;
z-index: 1;
text-align: center;
max-width: 720px;
}
.eyebrow {
font-family: var(--font-mono);
font-size: 0.75rem;
letter-spacing: 0.2em;
text-transform: uppercase;
color: var(--color-helix-ink-faint);
}
.title {
margin: 0.8rem 0 0;
font-size: clamp(3.5rem, 12vw, 7rem);
font-weight: 800;
letter-spacing: 0.04em;
line-height: 1;
background: linear-gradient(
90deg,
var(--color-helix-area) 0%,
var(--color-helix-process) 30%,
var(--color-helix-accent) 65%,
var(--color-helix-accent-2) 100%
);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.tagline {
margin-top: 1.25rem;
font-size: clamp(1.1rem, 2.4vw, 1.5rem);
color: var(--color-helix-ink);
font-weight: 500;
letter-spacing: -0.01em;
}
.lede {
margin: 0.9rem auto 0;
max-width: 580px;
color: var(--color-helix-ink-dim);
line-height: 1.6;
}
.cta-row {
margin-top: 2rem;
display: flex;
justify-content: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.cta {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.7rem 1.4rem;
border-radius: 10px;
border: 1px solid var(--color-helix-border);
color: var(--color-helix-ink);
text-decoration: none;
font-weight: 500;
transition: border-color 160ms ease, background 160ms ease, transform 160ms ease;
}
.cta:hover {
border-color: var(--color-helix-accent);
transform: translateY(-1px);
}
.cta.primary {
background: linear-gradient(135deg, var(--color-helix-process), var(--color-helix-accent));
border-color: transparent;
color: var(--color-helix-bg);
font-weight: 600;
}
.cta.primary:hover {
box-shadow: 0 10px 30px -10px rgba(77, 208, 194, 0.55);
}
.arrow {
transition: transform 200ms ease;
}
.cta:hover .arrow {
transform: translateX(3px);
}
.scroll-hint {
position: absolute;
bottom: 2rem;
left: 50%;
transform: translateX(-50%);
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
color: var(--color-helix-ink-faint);
font-family: var(--font-mono);
font-size: 0.7rem;
letter-spacing: 0.2em;
text-transform: uppercase;
}
.scroll-hint .bar {
width: 1px;
height: 28px;
background: linear-gradient(180deg, var(--color-helix-accent), transparent);
animation: scroll-bar 2.4s ease-in-out infinite;
}
@keyframes scroll-bar {
0%,
100% {
transform: scaleY(0.4);
transform-origin: top;
}
50% {
transform: scaleY(1);
transform-origin: top;
}
}
.band {
max-width: 1200px;
margin: 5rem auto 0;
padding: 0 1.5rem;
}
.band-head {
display: flex;
align-items: baseline;
justify-content: space-between;
margin-bottom: 1.5rem;
}
.band-head h2 {
font-size: 1.5rem;
font-weight: 600;
letter-spacing: -0.01em;
}
.more {
color: var(--color-helix-accent);
text-decoration: none;
font-size: 0.9rem;
}
.grid {
display: grid;
gap: 1.25rem;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
}
.post-list {
display: flex;
flex-direction: column;
}
.empty {
color: var(--color-helix-ink-dim);
padding: 2rem 0;
}
.empty a {
color: var(--color-helix-accent);
}
</style>

View File

@@ -0,0 +1,19 @@
import { redirect } from '@sveltejs/kit';
import { dev } from '$app/environment';
import { buildAuthorizationURL, generateState } from '$lib/server/gitea';
import { OAUTH_STATE_COOKIE } from '$lib/server/auth';
export const GET = async ({ cookies }) => {
const state = generateState();
const url = buildAuthorizationURL(state);
cookies.set(OAUTH_STATE_COOKIE, state, {
path: '/',
httpOnly: true,
sameSite: 'lax',
secure: !dev,
maxAge: 60 * 10
});
redirect(302, url);
};

View File

@@ -0,0 +1,92 @@
import { error, redirect } from '@sveltejs/kit';
import { dev } from '$app/environment';
import { eq } from 'drizzle-orm';
import { db } from '$lib/server/db';
import { users } from '$lib/server/db/schema';
import {
exchangeCode,
fetchGiteaUser,
isUserInOrg,
GITEA_ALLOWED_ORG
} from '$lib/server/gitea';
import {
createSession,
createSessionToken,
OAUTH_STATE_COOKIE,
SESSION_COOKIE
} from '$lib/server/auth';
export const GET = async ({ url, cookies }) => {
const code = url.searchParams.get('code');
const state = url.searchParams.get('state');
const storedState = cookies.get(OAUTH_STATE_COOKIE);
cookies.delete(OAUTH_STATE_COOKIE, { path: '/' });
if (!code || !state || !storedState || state !== storedState) {
error(400, 'Invalid OAuth state — please try signing in again.');
}
let accessToken: string;
try {
accessToken = await exchangeCode(code);
} catch (e) {
console.error('[helix] Gitea token exchange failed:', e);
error(400, 'Token exchange with Gitea failed.');
}
let giteaUser;
try {
giteaUser = await fetchGiteaUser(accessToken);
} catch (e) {
console.error('[helix] Gitea /user fetch failed:', e);
error(502, 'Could not fetch your Gitea profile.');
}
if (GITEA_ALLOWED_ORG) {
const allowed = await isUserInOrg(accessToken, giteaUser.login, GITEA_ALLOWED_ORG);
if (!allowed) {
error(403, `HELIX is restricted to members of the "${GITEA_ALLOWED_ORG}" Gitea organisation.`);
}
}
const existing = db.select().from(users).where(eq(users.giteaId, giteaUser.id)).get();
let userId: string;
if (existing) {
userId = existing.id;
db.update(users)
.set({
username: giteaUser.login,
name: giteaUser.full_name || giteaUser.login,
email: giteaUser.email ?? null,
avatarUrl: giteaUser.avatar_url ?? null
})
.where(eq(users.id, userId))
.run();
} else {
userId = crypto.randomUUID();
db.insert(users)
.values({
id: userId,
giteaId: giteaUser.id,
username: giteaUser.login,
name: giteaUser.full_name || giteaUser.login,
email: giteaUser.email ?? null,
avatarUrl: giteaUser.avatar_url ?? null,
role: 'editor'
})
.run();
}
const token = createSessionToken();
const session = createSession(token, userId);
cookies.set(SESSION_COOKIE, token, {
path: '/',
httpOnly: true,
sameSite: 'lax',
secure: !dev,
expires: session.expiresAt
});
redirect(302, '/');
};

View File

@@ -0,0 +1,7 @@
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals }) => {
if (locals.user) redirect(302, '/');
return {};
};

View File

@@ -0,0 +1,36 @@
<script lang="ts">
import { SITE } from '$lib/config';
</script>
<svelte:head>
<title>Sign in · {SITE.name}</title>
</svelte:head>
<section class="mx-auto flex min-h-[70vh] max-w-md flex-col justify-center px-6 py-16">
<div class="rounded-2xl border border-helix-border bg-helix-bg-2/60 p-10 backdrop-blur">
<h1 class="text-3xl font-semibold tracking-tight">Sign in to {SITE.name}</h1>
<p class="mt-3 text-helix-ink-dim">
HELIX uses your <strong class="text-helix-ink">Gitea</strong> account at
<code class="font-mono text-sm">{SITE.giteaBaseUrl.replace('https://', '')}</code>.
Anyone can read; authoring is restricted to the
<code class="font-mono text-sm">{SITE.giteaOrg}</code> organisation.
</p>
<a
href="/auth/gitea"
class="mt-8 inline-flex w-full items-center justify-center gap-3 rounded-lg bg-helix-process px-5 py-3 text-base font-medium text-white shadow-lg shadow-helix-process/30 transition hover:bg-helix-area focus:outline-none focus-visible:ring-2 focus-visible:ring-helix-accent"
>
<svg viewBox="0 0 24 24" class="h-5 w-5" aria-hidden="true">
<path
fill="currentColor"
d="M12 2a10 10 0 0 0-3.16 19.5c.5.1.68-.21.68-.47v-1.7c-2.78.6-3.37-1.34-3.37-1.34-.45-1.16-1.11-1.47-1.11-1.47-.9-.62.07-.6.07-.6 1 .07 1.52 1.03 1.52 1.03.89 1.52 2.34 1.08 2.91.82.09-.65.35-1.08.63-1.33-2.22-.25-4.56-1.11-4.56-4.94 0-1.09.39-1.99 1.03-2.69-.1-.25-.45-1.27.1-2.65 0 0 .84-.27 2.75 1.02a9.6 9.6 0 0 1 5 0c1.9-1.29 2.74-1.02 2.74-1.02.55 1.38.2 2.4.1 2.65.64.7 1.03 1.6 1.03 2.69 0 3.84-2.34 4.69-4.57 4.94.36.31.68.92.68 1.86v2.76c0 .27.18.58.69.48A10 10 0 0 0 12 2"
/>
</svg>
Continue with Gitea
</a>
<p class="mt-6 text-xs text-helix-ink-faint">
You'll be redirected to Gitea to approve, then back here. No password is stored by HELIX.
</p>
</div>
</section>

View File

@@ -0,0 +1,8 @@
import { redirect } from '@sveltejs/kit';
import { invalidateSession, SESSION_COOKIE } from '$lib/server/auth';
export const POST = async ({ locals, cookies }) => {
if (locals.session) invalidateSession(locals.session.id);
cookies.delete(SESSION_COOKIE, { path: '/' });
redirect(302, '/');
};

View File

@@ -0,0 +1,19 @@
import type { PageServerLoad } from './$types';
import { db } from '$lib/server/db';
import { posts } from '$lib/server/db/schema';
import { desc, isNotNull } from 'drizzle-orm';
export const load: PageServerLoad = async () => {
const all = db
.select({
slug: posts.slug,
title: posts.title,
summary: posts.summary,
publishedAt: posts.publishedAt
})
.from(posts)
.where(isNotNull(posts.publishedAt))
.orderBy(desc(posts.publishedAt))
.all();
return { posts: all };
};

View File

@@ -0,0 +1,62 @@
<script lang="ts">
import PostCard from '$lib/components/PostCard.svelte';
let { data } = $props();
</script>
<svelte:head>
<title>Posts · HELIX</title>
</svelte:head>
<section class="page">
<header class="page-head">
<p class="eyebrow">From the lab</p>
<h1>Posts</h1>
<p class="lede">
Updates, write-ups, and notes from across the R&amp;D team. Short or long, regular or rare.
</p>
</header>
{#if data.posts.length === 0}
<p class="empty">No posts published yet.</p>
{:else}
<div class="list">
{#each data.posts as p}
<PostCard post={p} />
{/each}
</div>
{/if}
</section>
<style>
.page {
max-width: 800px;
margin: 0 auto;
padding: 4rem 1.5rem 2rem;
}
.page-head {
margin-bottom: 2rem;
}
.eyebrow {
font-family: var(--font-mono);
font-size: 0.75rem;
letter-spacing: 0.2em;
text-transform: uppercase;
color: var(--color-helix-ink-faint);
}
h1 {
margin: 0.4rem 0 0.8rem;
font-size: clamp(2rem, 5vw, 3rem);
font-weight: 700;
letter-spacing: -0.02em;
}
.lede {
color: var(--color-helix-ink-dim);
line-height: 1.6;
max-width: 60ch;
}
.empty {
color: var(--color-helix-ink-dim);
padding: 3rem 0;
}
</style>

View File

@@ -0,0 +1,23 @@
import { error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import { db } from '$lib/server/db';
import { posts, users } from '$lib/server/db/schema';
import { eq, isNotNull } from 'drizzle-orm';
import { renderMarkdown } from '$lib/markdown';
export const load: PageServerLoad = async ({ params }) => {
const post = db.select().from(posts).where(eq(posts.slug, params.slug)).get();
if (!post || !post.publishedAt) error(404, 'Post not found');
const author = post.authorId
? db.select().from(users).where(eq(users.id, post.authorId)).get()
: null;
return {
post,
bodyHtml: renderMarkdown(post.bodyMd),
author: author
? { username: author.username, name: author.name, avatarUrl: author.avatarUrl }
: null
};
};

View File

@@ -0,0 +1,84 @@
<script lang="ts">
let { data } = $props();
const published = $derived(
data.post.publishedAt
? new Date(data.post.publishedAt).toLocaleDateString('en-GB', {
day: 'numeric',
month: 'long',
year: 'numeric'
})
: ''
);
</script>
<svelte:head>
<title>{data.post.title} · HELIX</title>
<meta name="description" content={data.post.summary || data.post.title} />
</svelte:head>
<article class="post">
<header class="head">
<a href="/posts" class="back">← Posts</a>
<h1>{data.post.title}</h1>
<div class="meta">
{#if data.author}
<span class="author">
{#if data.author.avatarUrl}
<img src={data.author.avatarUrl} alt="" />
{/if}
{data.author.name ?? data.author.username}
</span>
<span class="dot">·</span>
{/if}
<time>{published}</time>
</div>
</header>
<div class="prose-helix">
{@html data.bodyHtml}
</div>
</article>
<style>
.post {
max-width: 700px;
margin: 0 auto;
padding: 3rem 1.5rem 2rem;
}
.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.5rem;
font-size: clamp(1.8rem, 4.5vw, 2.6rem);
font-weight: 700;
letter-spacing: -0.02em;
line-height: 1.15;
}
.meta {
display: inline-flex;
align-items: center;
gap: 0.5rem;
font-size: 0.85rem;
color: var(--color-helix-ink-faint);
}
.author {
display: inline-flex;
align-items: center;
gap: 0.4rem;
}
.author img {
width: 22px;
height: 22px;
border-radius: 50%;
border: 1px solid var(--color-helix-border);
}
</style>

View File

@@ -0,0 +1,48 @@
import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { db } from '$lib/server/db';
import { posts } from '$lib/server/db/schema';
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 values = { title, slug: slugRaw, summary, body_md: bodyMd };
if (!locals.user) return fail(401, { error: 'Not authenticated', values });
if (!title) return fail(400, { error: 'Title is required.', values });
if (!bodyMd.trim()) return fail(400, { error: 'Body 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: posts.id }).from(posts).where(eq(posts.slug, slug)).get();
if (exists) return fail(400, { error: `A post with slug "${slug}" already exists.`, values });
const id = newId('pst');
db.insert(posts)
.values({
id,
slug,
title,
summary,
bodyMd,
authorId: locals.user.id,
publishedAt: new Date()
})
.run();
redirect(303, `/posts/${slug}`);
}
};

View File

@@ -0,0 +1,165 @@
<script lang="ts">
import { enhance } from '$app/forms';
let { form } = $props();
</script>
<svelte:head>
<title>New post · HELIX</title>
</svelte:head>
<section class="page">
<header class="head">
<a href="/posts" class="back">← Posts</a>
<h1>New post</h1>
<p class="lede">A short update, a write-up, a note. Markdown supported.</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 ?? ''}
/>
</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 ?? ''}
/>
</label>
<label>
<span>Summary <em>(optional)</em></span>
<input
type="text"
name="summary"
maxlength="280"
value={form?.values?.summary ?? ''}
/>
</label>
<label>
<span>Body <em>(markdown)</em></span>
<textarea name="body_md" rows="20" required>{form?.values?.body_md ?? ''}</textarea>
</label>
<div class="actions">
<a href="/posts" class="cancel">Cancel</a>
<button type="submit" class="submit">Publish</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;
}
label em {
color: var(--color-helix-ink-faint);
font-style: normal;
font-weight: 400;
margin-left: 0.4em;
}
input,
textarea {
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 {
outline: none;
border-color: var(--color-helix-accent);
}
textarea {
font-family: var(--font-mono);
font-size: 0.92rem;
line-height: 1.55;
resize: vertical;
}
.actions {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 0.85rem;
}
.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;
}
.submit:hover {
background: var(--color-helix-area);
}
</style>

View File

@@ -0,0 +1,21 @@
import type { PageServerLoad } from './$types';
import { db } from '$lib/server/db';
import { projects } from '$lib/server/db/schema';
import { desc, eq } from 'drizzle-orm';
export const load: PageServerLoad = async () => {
const all = db
.select({
slug: projects.slug,
title: projects.title,
summary: projects.summary,
coverUrl: projects.coverUrl,
updatedAt: projects.updatedAt
})
.from(projects)
.where(eq(projects.status, 'published'))
.orderBy(desc(projects.updatedAt))
.all();
return { projects: all };
};

View File

@@ -0,0 +1,69 @@
<script lang="ts">
import ProjectCard from '$lib/components/ProjectCard.svelte';
let { data } = $props();
</script>
<svelte:head>
<title>Projects · HELIX</title>
</svelte:head>
<section class="page">
<header class="page-head">
<p class="eyebrow">Showcase</p>
<h1>Projects</h1>
<p class="lede">
Every R&amp;D initiative that's worth showing — repos, dashboards, demos, and
the stories behind them.
</p>
</header>
{#if data.projects.length === 0}
<p class="empty">No projects published yet.</p>
{:else}
<div class="grid">
{#each data.projects as p}
<ProjectCard project={p} />
{/each}
</div>
{/if}
</section>
<style>
.page {
max-width: 1200px;
margin: 0 auto;
padding: 4rem 1.5rem 2rem;
}
.page-head {
margin-bottom: 2.5rem;
max-width: 720px;
}
.eyebrow {
font-family: var(--font-mono);
font-size: 0.75rem;
letter-spacing: 0.2em;
text-transform: uppercase;
color: var(--color-helix-ink-faint);
}
h1 {
margin: 0.4rem 0 0.8rem;
font-size: clamp(2rem, 5vw, 3rem);
font-weight: 700;
letter-spacing: -0.02em;
}
.lede {
color: var(--color-helix-ink-dim);
line-height: 1.6;
max-width: 60ch;
}
.grid {
display: grid;
gap: 1.25rem;
grid-template-columns: repeat(auto-fill, minmax(290px, 1fr));
}
.empty {
color: var(--color-helix-ink-dim);
padding: 3rem 0;
}
</style>

View File

@@ -0,0 +1,33 @@
import { error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import { db } from '$lib/server/db';
import { projects, projectLinks, users } from '$lib/server/db/schema';
import { asc, eq } from 'drizzle-orm';
import { renderMarkdown } from '$lib/markdown';
export const load: PageServerLoad = async ({ params }) => {
const project = db.select().from(projects).where(eq(projects.slug, params.slug)).get();
if (!project || project.status !== 'published') error(404, 'Project not found');
const links = db
.select()
.from(projectLinks)
.where(eq(projectLinks.projectId, project.id))
.orderBy(asc(projectLinks.position))
.all();
const author = project.authorId
? db.select().from(users).where(eq(users.id, project.authorId)).get()
: null;
return {
project,
bodyHtml: renderMarkdown(project.bodyMd),
links,
dashboards: links.filter((l) => l.kind === 'dashboard'),
nonDashboardLinks: links.filter((l) => l.kind !== 'dashboard'),
author: author
? { username: author.username, name: author.name, avatarUrl: author.avatarUrl }
: null
};
};

View File

@@ -0,0 +1,120 @@
<script lang="ts">
import LinkChips from '$lib/components/LinkChips.svelte';
import DashboardEmbed from '$lib/components/DashboardEmbed.svelte';
let { data } = $props();
const updated = $derived(
new Date(data.project.updatedAt).toLocaleDateString('en-GB', {
day: 'numeric',
month: 'long',
year: 'numeric'
})
);
</script>
<svelte:head>
<title>{data.project.title} · HELIX</title>
<meta name="description" content={data.project.summary} />
</svelte:head>
<article class="detail">
<header class="head">
<a href="/projects" class="back">← Projects</a>
<h1>{data.project.title}</h1>
<p class="summary">{data.project.summary}</p>
<div class="meta">
{#if data.author}
<span class="author">
{#if data.author.avatarUrl}
<img src={data.author.avatarUrl} alt="" />
{/if}
{data.author.name ?? data.author.username}
</span>
<span class="dot">·</span>
{/if}
<span>Updated {updated}</span>
</div>
<LinkChips links={data.nonDashboardLinks} />
</header>
{#if data.project.coverUrl}
<img src={data.project.coverUrl} alt="" class="cover" />
{/if}
<div class="prose-helix">
{@html data.bodyHtml}
</div>
{#if data.dashboards.length > 0}
<section class="dashboards">
<h2>Dashboards</h2>
{#each data.dashboards as d}
<DashboardEmbed url={d.url} label={d.label} />
{/each}
</section>
{/if}
</article>
<style>
.detail {
max-width: 760px;
margin: 0 auto;
padding: 3rem 1.5rem 2rem;
}
.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: clamp(2rem, 5vw, 3rem);
font-weight: 700;
letter-spacing: -0.02em;
}
.summary {
color: var(--color-helix-ink-dim);
font-size: 1.1rem;
line-height: 1.5;
}
.meta {
margin-top: 0.8rem;
display: inline-flex;
align-items: center;
gap: 0.5rem;
font-size: 0.85rem;
color: var(--color-helix-ink-faint);
}
.author {
display: inline-flex;
align-items: center;
gap: 0.4rem;
}
.author img {
width: 22px;
height: 22px;
border-radius: 50%;
border: 1px solid var(--color-helix-border);
}
.cover {
width: 100%;
border-radius: 12px;
margin: 1.5rem 0;
border: 1px solid var(--color-helix-border);
}
.dashboards {
margin-top: 3rem;
}
.dashboards h2 {
font-size: 1.4rem;
font-weight: 600;
margin-bottom: 0.5rem;
letter-spacing: -0.01em;
}
</style>

View File

@@ -0,0 +1,80 @@
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}`);
}
};

View 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?&#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>

8
static/favicon.svg Normal file
View File

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<rect width="24" height="24" rx="5" fill="#07111d"/>
<path d="M5 4 C 5 10, 19 14, 19 20" fill="none" stroke="#4dd0c2" stroke-width="2.2" stroke-linecap="round"/>
<path d="M5 20 C 5 14, 19 10, 19 4" fill="none" stroke="#0c99d9" stroke-width="2.2" stroke-linecap="round"/>
<line x1="6" y1="6" x2="6" y2="6.5" stroke="#a9daee" stroke-width="1.2"/>
<line x1="12" y1="12" x2="12" y2="12.5" stroke="#a9daee" stroke-width="1.2"/>
<line x1="18" y1="18" x2="18" y2="18.5" stroke="#a9daee" stroke-width="1.2"/>
</svg>

After

Width:  |  Height:  |  Size: 581 B

10
svelte.config.js Normal file
View File

@@ -0,0 +1,10 @@
import adapter from '@sveltejs/adapter-node';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */
export default {
preprocess: vitePreprocess(),
kit: {
adapter: adapter()
}
};

31
tailwind.config.js Normal file
View File

@@ -0,0 +1,31 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./src/**/*.{html,js,svelte,ts}'],
theme: {
extend: {
colors: {
helix: {
area: '#0f52a5',
process: '#0c99d9',
unit: '#50a8d9',
equipment: '#86bbdd',
control: '#a9daee',
bg: '#07111d',
'bg-2': '#0c1c30',
'bg-3': '#122842',
border: '#1f3a5e',
ink: '#e6f1fb',
'ink-dim': '#8fa6b8',
'ink-faint': '#5b7388',
accent: '#4dd0c2',
'accent-2': '#c084fc'
}
},
fontFamily: {
sans: ['Inter', 'system-ui', '-apple-system', 'Segoe UI', 'sans-serif'],
mono: ['JetBrains Mono', 'ui-monospace', 'SFMono-Regular', 'monospace']
}
}
},
plugins: []
};

14
tsconfig.json Normal file
View File

@@ -0,0 +1,14 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
}

6
vite.config.ts Normal file
View File

@@ -0,0 +1,6 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [sveltekit()]
});