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:
12
.dockerignore
Normal file
12
.dockerignore
Normal 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
24
.env.example
Normal 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
14
.gitignore
vendored
Normal 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
|
||||
43
Dockerfile
Normal file
43
Dockerfile
Normal 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
126
README.md
Normal 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
22
docker-compose.yml
Normal 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
10
drizzle.config.ts
Normal 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;
|
||||
80
drizzle/0000_giant_mother_askani.sql
Normal file
80
drizzle/0000_giant_mother_askani.sql
Normal 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`);
|
||||
560
drizzle/meta/0000_snapshot.json
Normal file
560
drizzle/meta/0000_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
13
drizzle/meta/_journal.json
Normal file
13
drizzle/meta/_journal.json
Normal 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
4071
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
42
package.json
Normal file
42
package.json
Normal 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
6
postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {}
|
||||
}
|
||||
};
|
||||
20
scripts/migrate.js
Normal file
20
scripts/migrate.js
Normal 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
141
scripts/seed.js
Normal 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
130
src/app.css
Normal 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
15
src/app.d.ts
vendored
Normal 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
16
src/app.html
Normal 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
32
src/hooks.server.ts
Normal 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);
|
||||
};
|
||||
108
src/lib/components/DashboardEmbed.svelte
Normal file
108
src/lib/components/DashboardEmbed.svelte
Normal 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>
|
||||
47
src/lib/components/Footer.svelte
Normal file
47
src/lib/components/Footer.svelte
Normal 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>
|
||||
223
src/lib/components/Helix.svelte
Normal file
223
src/lib/components/Helix.svelte
Normal 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>
|
||||
75
src/lib/components/LinkChips.svelte
Normal file
75
src/lib/components/LinkChips.svelte
Normal 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>
|
||||
192
src/lib/components/Nav.svelte
Normal file
192
src/lib/components/Nav.svelte
Normal 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>
|
||||
74
src/lib/components/PostCard.svelte
Normal file
74
src/lib/components/PostCard.svelte
Normal 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>
|
||||
98
src/lib/components/ProjectCard.svelte
Normal file
98
src/lib/components/ProjectCard.svelte
Normal 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
34
src/lib/config.ts
Normal 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
31
src/lib/markdown.ts
Normal 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
65
src/lib/server/auth.ts
Normal 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();
|
||||
}
|
||||
13
src/lib/server/db/index.ts
Normal file
13
src/lib/server/db/index.ts
Normal 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
152
src/lib/server/db/schema.ts
Normal 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
87
src/lib/server/gitea.ts
Normal 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);
|
||||
}
|
||||
5
src/routes/+layout.server.ts
Normal file
5
src/routes/+layout.server.ts
Normal 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
26
src/routes/+layout.svelte
Normal 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>
|
||||
34
src/routes/+page.server.ts
Normal file
34
src/routes/+page.server.ts
Normal 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
246
src/routes/+page.svelte
Normal 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&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>
|
||||
19
src/routes/auth/gitea/+server.ts
Normal file
19
src/routes/auth/gitea/+server.ts
Normal 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);
|
||||
};
|
||||
92
src/routes/auth/gitea/callback/+server.ts
Normal file
92
src/routes/auth/gitea/callback/+server.ts
Normal 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, '/');
|
||||
};
|
||||
7
src/routes/login/+page.server.ts
Normal file
7
src/routes/login/+page.server.ts
Normal 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 {};
|
||||
};
|
||||
36
src/routes/login/+page.svelte
Normal file
36
src/routes/login/+page.svelte
Normal 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>
|
||||
8
src/routes/logout/+server.ts
Normal file
8
src/routes/logout/+server.ts
Normal 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, '/');
|
||||
};
|
||||
19
src/routes/posts/+page.server.ts
Normal file
19
src/routes/posts/+page.server.ts
Normal 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 };
|
||||
};
|
||||
62
src/routes/posts/+page.svelte
Normal file
62
src/routes/posts/+page.svelte
Normal 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&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>
|
||||
23
src/routes/posts/[slug]/+page.server.ts
Normal file
23
src/routes/posts/[slug]/+page.server.ts
Normal 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
|
||||
};
|
||||
};
|
||||
84
src/routes/posts/[slug]/+page.svelte
Normal file
84
src/routes/posts/[slug]/+page.svelte
Normal 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>
|
||||
48
src/routes/posts/new/+page.server.ts
Normal file
48
src/routes/posts/new/+page.server.ts
Normal 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}`);
|
||||
}
|
||||
};
|
||||
165
src/routes/posts/new/+page.svelte
Normal file
165
src/routes/posts/new/+page.svelte
Normal 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>
|
||||
21
src/routes/projects/+page.server.ts
Normal file
21
src/routes/projects/+page.server.ts
Normal 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 };
|
||||
};
|
||||
69
src/routes/projects/+page.svelte
Normal file
69
src/routes/projects/+page.svelte
Normal 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&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>
|
||||
33
src/routes/projects/[slug]/+page.server.ts
Normal file
33
src/routes/projects/[slug]/+page.server.ts
Normal 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
|
||||
};
|
||||
};
|
||||
120
src/routes/projects/[slug]/+page.svelte
Normal file
120
src/routes/projects/[slug]/+page.svelte
Normal 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>
|
||||
80
src/routes/projects/new/+page.server.ts
Normal file
80
src/routes/projects/new/+page.server.ts
Normal 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}`);
|
||||
}
|
||||
};
|
||||
297
src/routes/projects/new/+page.svelte
Normal file
297
src/routes/projects/new/+page.svelte
Normal file
@@ -0,0 +1,297 @@
|
||||
<script lang="ts">
|
||||
import { LINK_KINDS, LINK_KIND_LABEL } from '$lib/config';
|
||||
import { enhance } from '$app/forms';
|
||||
|
||||
let { form } = $props();
|
||||
|
||||
type LinkRow = { kind: string; label: string; url: string };
|
||||
let linkRows: LinkRow[] = $state([{ kind: 'gitea', label: '', url: '' }]);
|
||||
|
||||
function addRow() {
|
||||
linkRows = [...linkRows, { kind: 'docs', label: '', url: '' }];
|
||||
}
|
||||
function removeRow(i: number) {
|
||||
linkRows = linkRows.filter((_, idx) => idx !== i);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>New project · HELIX</title>
|
||||
</svelte:head>
|
||||
|
||||
<section class="page">
|
||||
<header class="head">
|
||||
<a href="/projects" class="back">← Projects</a>
|
||||
<h1>New project</h1>
|
||||
<p class="lede">
|
||||
Showcase something. Title, a one-line summary, a markdown body, and links to
|
||||
whatever lives elsewhere — repo, dashboard, demo, paper.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{#if form?.error}
|
||||
<div class="error">{form.error}</div>
|
||||
{/if}
|
||||
|
||||
<form method="POST" class="form" use:enhance>
|
||||
<label>
|
||||
<span>Title</span>
|
||||
<input
|
||||
type="text"
|
||||
name="title"
|
||||
required
|
||||
maxlength="200"
|
||||
value={form?.values?.title ?? ''}
|
||||
placeholder="EVOLV"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span>Slug <em>(optional — auto from title)</em></span>
|
||||
<input
|
||||
type="text"
|
||||
name="slug"
|
||||
maxlength="80"
|
||||
pattern="[a-z0-9-]*"
|
||||
value={form?.values?.slug ?? ''}
|
||||
placeholder="evolv"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span>Summary</span>
|
||||
<input
|
||||
type="text"
|
||||
name="summary"
|
||||
required
|
||||
maxlength="280"
|
||||
value={form?.values?.summary ?? ''}
|
||||
placeholder="One sentence on what this project is."
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span>Cover image URL <em>(optional)</em></span>
|
||||
<input
|
||||
type="url"
|
||||
name="cover_url"
|
||||
value={form?.values?.cover_url ?? ''}
|
||||
placeholder="https://…"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span>Body <em>(markdown)</em></span>
|
||||
<textarea
|
||||
name="body_md"
|
||||
rows="14"
|
||||
placeholder="## What is this? Tell the story. Use headings, lists, code blocks."
|
||||
>{form?.values?.body_md ?? ''}</textarea>
|
||||
</label>
|
||||
|
||||
<fieldset class="links">
|
||||
<legend>Links</legend>
|
||||
<p class="help">
|
||||
Add the repo, any Grafana dashboards, demos, or docs. Dashboard links render
|
||||
as inline embeds on the project page when their host is allowlisted.
|
||||
</p>
|
||||
<div class="link-rows">
|
||||
{#each linkRows as row, i (i)}
|
||||
<div class="link-row">
|
||||
<select name="link_kind" bind:value={row.kind}>
|
||||
{#each LINK_KINDS as k}
|
||||
<option value={k}>{LINK_KIND_LABEL[k]}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
name="link_label"
|
||||
placeholder="Label"
|
||||
bind:value={row.label}
|
||||
/>
|
||||
<input
|
||||
type="url"
|
||||
name="link_url"
|
||||
placeholder="https://…"
|
||||
bind:value={row.url}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="rm"
|
||||
onclick={() => removeRow(i)}
|
||||
aria-label="Remove link"
|
||||
>✕</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<button type="button" class="add" onclick={addRow}>+ Add link</button>
|
||||
</fieldset>
|
||||
|
||||
<div class="actions">
|
||||
<a href="/projects" class="cancel">Cancel</a>
|
||||
<button type="submit" class="submit">Publish project</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.page {
|
||||
max-width: 760px;
|
||||
margin: 0 auto;
|
||||
padding: 3rem 1.5rem 4rem;
|
||||
}
|
||||
.head {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
.back {
|
||||
color: var(--color-helix-ink-dim);
|
||||
text-decoration: none;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.back:hover {
|
||||
color: var(--color-helix-accent);
|
||||
}
|
||||
h1 {
|
||||
margin: 1rem 0 0.6rem;
|
||||
font-size: 2.25rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.lede {
|
||||
color: var(--color-helix-ink-dim);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.error {
|
||||
margin-bottom: 1rem;
|
||||
padding: 0.85rem 1rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #b94a4a;
|
||||
background: rgba(185, 74, 74, 0.12);
|
||||
color: #f5a8a8;
|
||||
}
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
label > span {
|
||||
font-weight: 500;
|
||||
color: var(--color-helix-ink);
|
||||
}
|
||||
label em {
|
||||
color: var(--color-helix-ink-faint);
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
margin-left: 0.4em;
|
||||
}
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
background: var(--color-helix-bg-2);
|
||||
border: 1px solid var(--color-helix-border);
|
||||
border-radius: 8px;
|
||||
padding: 0.65rem 0.8rem;
|
||||
color: var(--color-helix-ink);
|
||||
font: inherit;
|
||||
transition: border-color 160ms ease;
|
||||
}
|
||||
input:focus,
|
||||
textarea:focus,
|
||||
select:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-helix-accent);
|
||||
}
|
||||
textarea {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.92rem;
|
||||
line-height: 1.55;
|
||||
resize: vertical;
|
||||
}
|
||||
fieldset.links {
|
||||
border: 1px solid var(--color-helix-border);
|
||||
border-radius: 10px;
|
||||
padding: 1rem 1.25rem 1.25rem;
|
||||
background: var(--color-helix-bg-2);
|
||||
}
|
||||
legend {
|
||||
padding: 0 0.4rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
.help {
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-helix-ink-dim);
|
||||
margin-bottom: 0.85rem;
|
||||
}
|
||||
.link-rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
.link-row {
|
||||
display: grid;
|
||||
grid-template-columns: 140px 160px 1fr auto;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.rm {
|
||||
background: transparent;
|
||||
border: 1px solid var(--color-helix-border);
|
||||
color: var(--color-helix-ink-dim);
|
||||
border-radius: 8px;
|
||||
padding: 0 0.7rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.rm:hover {
|
||||
color: #f5a8a8;
|
||||
border-color: #b94a4a;
|
||||
}
|
||||
.add {
|
||||
margin-top: 0.85rem;
|
||||
background: transparent;
|
||||
border: 1px dashed var(--color-helix-border);
|
||||
color: var(--color-helix-accent);
|
||||
padding: 0.55rem 1rem;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.add:hover {
|
||||
border-color: var(--color-helix-accent);
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: 0.85rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.cancel {
|
||||
color: var(--color-helix-ink-dim);
|
||||
text-decoration: none;
|
||||
}
|
||||
.submit {
|
||||
background: var(--color-helix-process);
|
||||
border: none;
|
||||
color: white;
|
||||
padding: 0.7rem 1.4rem;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 160ms ease;
|
||||
}
|
||||
.submit:hover {
|
||||
background: var(--color-helix-area);
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.link-row {
|
||||
grid-template-columns: 1fr 1fr auto;
|
||||
}
|
||||
.link-row select {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
8
static/favicon.svg
Normal file
8
static/favicon.svg
Normal 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
10
svelte.config.js
Normal 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
31
tailwind.config.js
Normal 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
14
tsconfig.json
Normal 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
6
vite.config.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [sveltekit()]
|
||||
});
|
||||
Reference in New Issue
Block a user