Merge branch 'master' of https://git.djosen.one/djoser/webgpu-portfolio
This commit is contained in:
@@ -3,7 +3,10 @@ dist
|
||||
.git
|
||||
.gitignore
|
||||
*.md
|
||||
!README.md
|
||||
.DS_Store
|
||||
.vscode
|
||||
*.log
|
||||
.env
|
||||
.env.example
|
||||
nginx/
|
||||
webhook/
|
||||
scripts/
|
||||
docker-compose.yml
|
||||
.hermes/
|
||||
|
||||
14
.env.example
Normal file
14
.env.example
Normal file
@@ -0,0 +1,14 @@
|
||||
# Environment variables for the webhook server
|
||||
# Copy this to .env and fill in the secret:
|
||||
# cp .env.example .env
|
||||
|
||||
# Gitea webhook secret — generate with: openssl rand -hex 32
|
||||
WEBHOOK_SECRET=
|
||||
|
||||
# Path to the repo containing docker-compose.yml
|
||||
REPO_DIR=/home/djoser/webgpu-portfolio
|
||||
|
||||
# Bind address and port for the webhook listener
|
||||
# Must be 127.0.0.1 — the webhook is served through system nginx proxy
|
||||
LISTEN_HOST=127.0.0.1
|
||||
LISTEN_PORT=9000
|
||||
32
.gitea/workflows/ci.yml
Normal file
32
.gitea/workflows/ci.yml
Normal file
@@ -0,0 +1,32 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
pull_request:
|
||||
branches: [master]
|
||||
|
||||
jobs:
|
||||
test-and-build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Type-check
|
||||
run: npm run typecheck
|
||||
|
||||
- name: Tests
|
||||
run: npm test
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
53
.gitea/workflows/deploy.yml
Normal file
53
.gitea/workflows/deploy.yml
Normal file
@@ -0,0 +1,53 @@
|
||||
# Gitea Actions workflow — build, test, deploy
|
||||
# Shows deployment status on the repo page, Actions tab, and commits.
|
||||
name: Build & Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
|
||||
jobs:
|
||||
# ---- Job 1: Typecheck & Build ----
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Typecheck
|
||||
run: npm run typecheck
|
||||
|
||||
# Vite build is done inside Docker, so this is just a fast sanity check.
|
||||
# Full build + test happens in the Docker multi-stage build below.
|
||||
|
||||
# ---- Job 2: Docker build + deploy ----
|
||||
deploy:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build Docker image (includes npm test gate)
|
||||
run: docker compose build
|
||||
|
||||
- name: Deploy via docker compose
|
||||
run: |
|
||||
docker compose up -d --remove-orphans
|
||||
sleep 3
|
||||
docker compose ps
|
||||
|
||||
- name: Smoke test
|
||||
run: |
|
||||
curl -sf --retry 5 --retry-delay 2 http://localhost:80/ && echo "OK — site is live"
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -17,3 +17,6 @@ test-*.js
|
||||
|
||||
# LaTeX resume (unrelated to the site)
|
||||
resume.tex
|
||||
|
||||
# CI/CD — webhook secret + deployment env
|
||||
.env
|
||||
|
||||
78
AGENTS.md
78
AGENTS.md
@@ -326,3 +326,81 @@ renderer.updateText(layout)
|
||||
|
||||
Font files go in `public/` and are fetched at runtime. Uses `opentype.js` (runtime
|
||||
dep) + `@types/opentype.js` (dev dep).
|
||||
|
||||
## Deployment (Docker + nginx + webhook CI/CD)
|
||||
|
||||
The stack runs in Docker Compose with two containers on a shared internal network:
|
||||
|
||||
| Container | Role | Port exposed |
|
||||
|---------------------|-----------------------------------------|--------------|
|
||||
| `webgpu-portfolio-web` | App: nginx serving the built dist/ | internal:80 |
|
||||
| `webgpu-portfolio-nginx` | Reverse proxy in front of web | host:80 |
|
||||
|
||||
- `nginx/reverse.conf` — reverse proxy config, routes `djosen.one` → `web:80`
|
||||
- `nginx.default.conf` — internal nginx config in the app container (SPA, gzip, caching)
|
||||
- `docker-compose.yml` — full stack, health checks, auto-restart
|
||||
- `.dockerignore` — excludes node_modules, dist, git, and CI files from the build context
|
||||
|
||||
### CI/CD webhook
|
||||
|
||||
A Python stdlib webhook server (`webhook/server.py`) listens on port 9000, verifies
|
||||
Gitea's `X-Gitea-Signature` HMAC-SHA256, and on `push` events runs:
|
||||
|
||||
```
|
||||
git pull origin master
|
||||
docker compose up -d --build --remove-orphans
|
||||
```
|
||||
|
||||
Minimal downtime (~1–2 s) because only the `web` container is rebuilt; nginx-proxy
|
||||
stays running and buffers connections during the restart.
|
||||
|
||||
### Setup on the server (first time)
|
||||
|
||||
```bash
|
||||
# 1. Clone the repo
|
||||
git clone https://git.djosen.one/djoser/webgpu-portfolio /home/djoser/webgpu-portfolio
|
||||
cd /home/djoser/webgpu-portfolio
|
||||
|
||||
# 2. Create .env with a webhook secret
|
||||
openssl rand -hex 32 > /tmp/secret
|
||||
echo "WEBHOOK_SECRET=$(cat /tmp/secret)" > .env
|
||||
echo "REPO_DIR=/home/djoser/webgpu-portfolio" >> .env
|
||||
|
||||
# 3. Start the stack
|
||||
docker compose up -d
|
||||
|
||||
# 4. Install & start the webhook service
|
||||
sudo cp webhook/webhook.service /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now webhook-webhook.service
|
||||
|
||||
# 5. Configure Gitea webhook
|
||||
# - Go to repo Settings → Webhooks → Add Webhook (Gitea)
|
||||
# - Target URL: http://<server-ip>:9000
|
||||
# - Secret: <content of /tmp/secret>
|
||||
# - Events: Push events
|
||||
# - Active: ✓
|
||||
```
|
||||
|
||||
### Manual deploy (no webhook)
|
||||
|
||||
```bash
|
||||
bash scripts/deploy.sh
|
||||
```
|
||||
|
||||
### Verify everything is working
|
||||
|
||||
```bash
|
||||
# Stack health
|
||||
docker compose ps
|
||||
curl -s http://localhost:80/health
|
||||
|
||||
# Webhook health
|
||||
curl -s http://localhost:9000/health
|
||||
|
||||
# Test webhook (simulate a push)
|
||||
curl -X POST http://localhost:9000/ \
|
||||
-H "X-Gitea-Event: push" \
|
||||
-H "X-Gitea-Signature: ..." \
|
||||
-d '{"ref":"refs/heads/master"}'
|
||||
```
|
||||
18
Dockerfile
18
Dockerfile
@@ -9,14 +9,26 @@ RUN npm ci
|
||||
# Copy source and build
|
||||
COPY tsconfig.json vite.config.ts index.html ./
|
||||
COPY src/ src/
|
||||
|
||||
# Run tests first — gates the build
|
||||
RUN npm test
|
||||
|
||||
RUN npm run build
|
||||
|
||||
# ---- stage 2: serve ----
|
||||
FROM nginx:alpine-slim AS serve
|
||||
|
||||
# curl needed for the healthcheck in docker-compose
|
||||
RUN apk add --no-cache curl
|
||||
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
|
||||
# Optional: custom nginx config for caching / security
|
||||
COPY nginx.default.conf /etc/nginx/conf.d/default.conf
|
||||
# Wipe any default server blocks that could conflict with our config.
|
||||
RUN rm -f /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 80
|
||||
# Deploy our custom nginx config and validate it at build time
|
||||
COPY nginx.default.conf /etc/nginx/conf.d/default.conf
|
||||
RUN nginx -t
|
||||
|
||||
EXPOSE 8080
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# WebGPU Portfolio
|
||||
|
||||
[](https://git.djosen.one/djoser/webgpu-portfolio/actions)
|
||||
|
||||
Generative-art portfolio site. WebGPU canvas background, DOM overlay UI.
|
||||
|
||||
## Dev
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
services:
|
||||
web:
|
||||
build: .
|
||||
ports:
|
||||
- "8080:80"
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
image: webgpu-portfolio:latest
|
||||
container_name: webgpu-portfolio-web
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
# Loopback only — system nginx proxies from :443 → :8080
|
||||
- "127.0.0.1:8080:8080"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -sf http://127.0.0.1:8080/ || exit 1"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 5s
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
server {
|
||||
listen 80;
|
||||
listen 8080 default_server;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
|
||||
44
nginx/reverse.conf
Normal file
44
nginx/reverse.conf
Normal file
@@ -0,0 +1,44 @@
|
||||
# nginx reverse proxy — sits in front of the app container
|
||||
# This config is mounted into the nginx-proxy container in docker-compose.
|
||||
upstream app {
|
||||
# Points to the web service by container name (docker DNS)
|
||||
server web:8080;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name djosen.one www.djosen.one;
|
||||
|
||||
# Proxy all requests to the app container
|
||||
location / {
|
||||
proxy_pass http://app;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# WebSocket support (not currently used, but forward-looking)
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
|
||||
# Buffering: off for WebGPU/streaming, but on for static is fine.
|
||||
# We serve static from the app container's nginx, so keep this simple.
|
||||
}
|
||||
|
||||
# Health-check endpoint — used by the webhook deploy script to verify liveness
|
||||
location /health {
|
||||
access_log off;
|
||||
default_type text/plain;
|
||||
return 200 "ok";
|
||||
}
|
||||
|
||||
# Security
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Frame-Options "DENY" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
|
||||
# Logs
|
||||
access_log /var/log/nginx/access.log;
|
||||
error_log /var/log/nginx/error.log;
|
||||
}
|
||||
75
nginx/system-default.conf
Normal file
75
nginx/system-default.conf
Normal file
@@ -0,0 +1,75 @@
|
||||
server {
|
||||
listen 80 default_server;
|
||||
listen [::]:80 default_server;
|
||||
|
||||
server_name _;
|
||||
|
||||
root /var/www/html;
|
||||
index index.html index.htm index.nginx-debian.html;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ =404;
|
||||
}
|
||||
|
||||
location /valentine {
|
||||
proxy_pass http://localhost:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
}
|
||||
server {
|
||||
server_name djosen.one; # managed by Certbot
|
||||
|
||||
# SSL termination — Certbot managed
|
||||
listen [::]:443 ssl ipv6only=on; # managed by Certbot
|
||||
listen 443 ssl; # managed by Certbot
|
||||
ssl_certificate /etc/letsencrypt/live/djosen.one/fullchain.pem; # managed by Certbot
|
||||
ssl_certificate_key /etc/letsencrypt/live/djosen.one/privkey.pem; # managed by Certbot
|
||||
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
|
||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
|
||||
|
||||
# valentine app (unchanged)
|
||||
location /valentine {
|
||||
proxy_pass http://localhost:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
|
||||
# Health-check endpoint for deploy scripts
|
||||
location /health {
|
||||
access_log off;
|
||||
default_type text/plain;
|
||||
return 200 "ok";
|
||||
}
|
||||
|
||||
# Everything else → Docker portfolio container
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
}
|
||||
}
|
||||
server {
|
||||
if ($host = djosen.one) {
|
||||
return 301 https://$host$request_uri;
|
||||
} # managed by Certbot
|
||||
|
||||
|
||||
listen 80 ;
|
||||
listen [::]:80 ;
|
||||
server_name djosen.one;
|
||||
return 404; # managed by Certbot
|
||||
|
||||
|
||||
}
|
||||
@@ -7,7 +7,8 @@
|
||||
"dev": "vite",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit"
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/opentype.js": "^1.3.10",
|
||||
|
||||
28
scripts/deploy.sh
Executable file
28
scripts/deploy.sh
Executable file
@@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env bash
|
||||
# deploy.sh — manual deploy (pull + rebuild + health check)
|
||||
# Run this on the server when you don't want to wait for a webhook trigger.
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
cd "$REPO_DIR"
|
||||
|
||||
echo "=== Pulling latest code ==="
|
||||
git pull origin master
|
||||
|
||||
echo "=== Running tests ==="
|
||||
npm ci --silent
|
||||
npm test
|
||||
|
||||
echo "=== Rebuilding & restarting ==="
|
||||
docker compose up -d --build --remove-orphans
|
||||
|
||||
echo "=== Waiting for health checks ==="
|
||||
sleep 3
|
||||
docker compose ps
|
||||
|
||||
echo "=== Smoke test ==="
|
||||
curl -sf http://localhost:80/health > /dev/null && echo "OK — /health passed" || echo "WARN — /health failed"
|
||||
|
||||
echo ""
|
||||
echo "Done. App is live."
|
||||
45
src/app.ts
45
src/app.ts
@@ -1,7 +1,8 @@
|
||||
import { Renderer, UiState } from './engine/renderer'
|
||||
import { Input } from './ui/input'
|
||||
import { DomOverlay } from './ui/overlay'
|
||||
import { SCENES } from './data/scenes'
|
||||
import { Router, Route } from './router'
|
||||
import { PAGES, PROJECTS_SCENE, RESUME_SCENE } from './data/pages'
|
||||
|
||||
const DPR_CAP = 2
|
||||
|
||||
@@ -10,6 +11,7 @@ export class App {
|
||||
private renderer = new Renderer()
|
||||
private input!: Input
|
||||
private overlay!: DomOverlay
|
||||
private router = new Router()
|
||||
|
||||
private dpr = 1
|
||||
private res = new Float32Array(2)
|
||||
@@ -47,6 +49,31 @@ export class App {
|
||||
cancelAnimationFrame(this.rafId)
|
||||
}
|
||||
|
||||
// ---- routing ----
|
||||
|
||||
private applyRoute(route: Route): void {
|
||||
this.scene = route.scene
|
||||
this.overlay.setScene(this.scene)
|
||||
|
||||
if (route.scene === PROJECTS_SCENE && 'view' in route) {
|
||||
if (route.view === 'category' && 'catId' in route && route.catId) {
|
||||
this.overlay.showCategory(route.catId)
|
||||
} else if (
|
||||
route.view === 'detail' &&
|
||||
'catId' in route && route.catId &&
|
||||
'projectId' in route && route.projectId
|
||||
) {
|
||||
this.overlay.showProjectDetail(route.projectId, route.catId)
|
||||
} else {
|
||||
this.overlay.showCategoryGrid()
|
||||
}
|
||||
} else if (route.scene === RESUME_SCENE) {
|
||||
this.overlay.showResume()
|
||||
}
|
||||
}
|
||||
|
||||
// ---- rendering ----
|
||||
|
||||
private resize(): void {
|
||||
this.dpr = Math.min(window.devicePixelRatio || 1, DPR_CAP)
|
||||
const w = Math.floor(window.innerWidth * this.dpr)
|
||||
@@ -62,23 +89,16 @@ export class App {
|
||||
|
||||
private computeStemMultipliers(time: number): void {
|
||||
const sm = this.stemMultipliers
|
||||
// scene-based color identity and speed
|
||||
const colorBase = [0.0, 0.6, 1.2, 2.4]
|
||||
const speedBase = [0.0, 0.2, 0.1, 0.15]
|
||||
const colorBase = [0.0, 0.6, 1.2, 1.8, 2.4]
|
||||
const speedBase = [0.0, 0.2, 0.1, 0.0, 0.15]
|
||||
const scene = this.scene
|
||||
|
||||
// [0] = (camera speed, 0, color offset, twist/flash)
|
||||
sm[0] = speedBase[scene]
|
||||
sm[2] = colorBase[scene] * 0.2
|
||||
sm[3] = 0.3 + 0.3 * Math.sin(time * 0.5)
|
||||
|
||||
// [1] = (color offset, 0, 0, 0)
|
||||
sm[4] = colorBase[scene] * 0.4
|
||||
|
||||
// [2] = (size scale, 0, 0, 0)
|
||||
sm[8] = 0.1// + 0.4 * Math.sin(time * 0.3 + scene * 3.0)
|
||||
|
||||
// rest of the array stays zero
|
||||
sm[8] = 0.1
|
||||
}
|
||||
|
||||
private loop = (): void => {
|
||||
@@ -97,7 +117,6 @@ export class App {
|
||||
|
||||
if (this.scene !== this.prevScene) {
|
||||
this.prevScene = this.scene
|
||||
this.overlay.setScene(this.scene)
|
||||
}
|
||||
|
||||
this.computeStemMultipliers(time)
|
||||
@@ -107,7 +126,7 @@ export class App {
|
||||
time,
|
||||
scene: this.scene,
|
||||
mouse: this.mouse,
|
||||
stemMultipliers: this.stemMultipliers
|
||||
stemMultipliers: this.stemMultipliers,
|
||||
}
|
||||
this.renderer.render(state)
|
||||
}
|
||||
|
||||
67
src/data/pages.ts
Normal file
67
src/data/pages.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
// Single source of truth for every page on the site.
|
||||
// Add a new entry here and routing + navigation wire up automatically.
|
||||
//
|
||||
// Slug conventions:
|
||||
// '' (empty string) → home page at /
|
||||
// 'about' → /about
|
||||
// 'projects' → /projects (sub-routes handled by the router)
|
||||
// 'resume' → /resume
|
||||
//
|
||||
// Scene index = position in the array. Keep scenes 0-1 as simple pages
|
||||
// and reserve scene 2 for projects (which has sub-navigation via categories).
|
||||
|
||||
export interface PageDef {
|
||||
slug: string
|
||||
title: string
|
||||
subtitle: string
|
||||
body: string
|
||||
}
|
||||
|
||||
export const PAGES: PageDef[] = [
|
||||
{
|
||||
slug: '',
|
||||
title: 'Ahmed A. Mohamed',
|
||||
subtitle: 'Creative Developer & Graphics Engineer',
|
||||
body: "Welcome to my personal website, there's a lot going on in my life that I will be sharing here randomly over time so take a look.",
|
||||
},
|
||||
{
|
||||
slug: 'about',
|
||||
title: 'ABOUT',
|
||||
subtitle: 'Who I am',
|
||||
body: 'I build creative web experiences\nusing cutting-edge graphics technology.',
|
||||
},
|
||||
{
|
||||
slug: 'projects',
|
||||
title: 'PROJECTS',
|
||||
subtitle: 'Things I have built',
|
||||
body: 'A collection of work exploring\nWebGPU, shaders, and generative art.',
|
||||
},
|
||||
{
|
||||
slug: 'contact',
|
||||
title: 'CONTACT',
|
||||
subtitle: 'Get in touch',
|
||||
body: 'Reach me at hello@example.com\nor find me on GitHub.',
|
||||
},
|
||||
{
|
||||
slug: 'resume',
|
||||
title: 'RESUME',
|
||||
subtitle: 'Ahmed Mohamed',
|
||||
body: 'Graphics & Systems Engineer',
|
||||
},
|
||||
]
|
||||
|
||||
// ---- derived lookup maps (built once, used by the router) ----
|
||||
|
||||
/** path (e.g. '/about') → scene index */
|
||||
export const PATH_TO_SCENE: ReadonlyMap<string, number> = new Map(
|
||||
PAGES.map((p, i) => [p.slug === '' ? '/' : `/${p.slug}`, i]),
|
||||
)
|
||||
|
||||
/** scene index → base path (e.g. 1 → '/about') */
|
||||
export const SCENE_TO_PATH: ReadonlyMap<number, string> = new Map(
|
||||
PAGES.map((_, i) => [i, PAGES[i].slug === '' ? '/' : `/${PAGES[i].slug}`]),
|
||||
)
|
||||
|
||||
/** Scene indices resolved by slug — update PAGES and these auto-track. */
|
||||
export const PROJECTS_SCENE = PAGES.findIndex((p) => p.slug === 'projects')
|
||||
export const RESUME_SCENE = PAGES.findIndex((p) => p.slug === 'resume')
|
||||
146
src/router.ts
Normal file
146
src/router.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
// Minimal client-side router using the History API.
|
||||
// Maps are derived dynamically from src/data/pages.ts — add a page there
|
||||
// and routing wires up automatically.
|
||||
//
|
||||
// The nginx config already has try_files → /index.html so server-side
|
||||
// everything is ready for clean paths like /resume, /projects/graphics, etc.
|
||||
|
||||
import { PATH_TO_SCENE, SCENE_TO_PATH } from './data/pages'
|
||||
|
||||
// ---------- route types ----------
|
||||
|
||||
export interface SimpleRoute {
|
||||
scene: number
|
||||
path: string
|
||||
}
|
||||
|
||||
export interface ProjectsGridRoute {
|
||||
scene: number
|
||||
view: 'grid'
|
||||
path: string
|
||||
}
|
||||
|
||||
export interface ProjectsCategoryRoute {
|
||||
scene: number
|
||||
view: 'category'
|
||||
catId: string
|
||||
path: string
|
||||
}
|
||||
|
||||
export interface ProjectsDetailRoute {
|
||||
scene: number
|
||||
view: 'detail'
|
||||
catId: string
|
||||
projectId: string
|
||||
path: string
|
||||
}
|
||||
|
||||
export type Route =
|
||||
| SimpleRoute
|
||||
| ProjectsGridRoute
|
||||
| ProjectsCategoryRoute
|
||||
| ProjectsDetailRoute
|
||||
|
||||
export type RouteChangeHandler = (route: Route) => void
|
||||
|
||||
// ---- resolve the projects scene index dynamically ----
|
||||
|
||||
const PROJECTS_SCENE = PATH_TO_SCENE.get('/projects')
|
||||
const PROJECTS_BASE = '/projects'
|
||||
|
||||
// ---------- Router ----------
|
||||
|
||||
export class Router {
|
||||
private handler: RouteChangeHandler | null = null
|
||||
|
||||
constructor() {
|
||||
window.addEventListener('popstate', () => {
|
||||
const route = Router.parse(window.location.pathname)
|
||||
this.handler?.(route)
|
||||
})
|
||||
}
|
||||
|
||||
/** Register the single route-change callback. */
|
||||
onChange(handler: RouteChangeHandler): void {
|
||||
this.handler = handler
|
||||
}
|
||||
|
||||
/** Parse the current URL and return the initial route. */
|
||||
start(): Route {
|
||||
return Router.parse(window.location.pathname)
|
||||
}
|
||||
|
||||
/** Push a new history entry and fire the route change. No-op if same path. */
|
||||
navigate(path: string): void {
|
||||
if (window.location.pathname === path) return
|
||||
history.pushState(null, '', path)
|
||||
const route = Router.parse(path)
|
||||
this.handler?.(route)
|
||||
}
|
||||
|
||||
/** Replace the current history entry (used for redirects). */
|
||||
replace(path: string): void {
|
||||
history.replaceState(null, '', path)
|
||||
}
|
||||
|
||||
// ---- static helpers ----
|
||||
|
||||
/** Parse a pathname into a Route. Unknown paths → home redirect. */
|
||||
static parse(pathname: string): Route {
|
||||
const path = pathname === '/' ? '/' : pathname.replace(/\/+$/, '')
|
||||
const parts = path.split('/').filter(Boolean)
|
||||
|
||||
if (parts.length === 0) {
|
||||
return { scene: 0, path: '/' }
|
||||
}
|
||||
|
||||
const base = '/' + parts[0]
|
||||
const scene = PATH_TO_SCENE.get(base)
|
||||
|
||||
if (scene === undefined) {
|
||||
return { scene: 0, path: '/' }
|
||||
}
|
||||
|
||||
// Projects sub-routes: /projects[/:catId[/:projectId]]
|
||||
if (scene === PROJECTS_SCENE && base === PROJECTS_BASE) {
|
||||
if (parts.length === 1) {
|
||||
return { scene, view: 'grid', path: PROJECTS_BASE }
|
||||
}
|
||||
if (parts.length === 2) {
|
||||
return { scene, view: 'category', catId: parts[1], path: `/${parts.join('/')}` }
|
||||
}
|
||||
return {
|
||||
scene,
|
||||
view: 'detail',
|
||||
catId: parts[1],
|
||||
projectId: parts[2],
|
||||
path: `/${parts.join('/')}`,
|
||||
}
|
||||
}
|
||||
|
||||
return { scene, path: base }
|
||||
}
|
||||
|
||||
/** Build a path string from a route. */
|
||||
static toPath(route: Route): string {
|
||||
if (
|
||||
route.scene === PROJECTS_SCENE &&
|
||||
'view' in route
|
||||
) {
|
||||
if (route.view === 'category' && 'catId' in route && route.catId) {
|
||||
return `/projects/${route.catId}`
|
||||
}
|
||||
if (route.view === 'detail' && 'catId' in route && route.catId &&
|
||||
'projectId' in route && route.projectId) {
|
||||
return `/projects/${route.catId}/${route.projectId}`
|
||||
}
|
||||
return '/projects'
|
||||
}
|
||||
return SCENE_TO_PATH.get(route.scene) ?? '/'
|
||||
}
|
||||
|
||||
/** Scene index → base path (resets any project sub-navigation). */
|
||||
static sceneToBasePath(scene: number): string {
|
||||
return SCENE_TO_PATH.get(scene) ?? '/'
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,17 @@
|
||||
import { RESUME, ResumeData, ResumeEntry, SkillGroup } from '../data/resume'
|
||||
import { PROJECTS, CATEGORIES, ProjectPage, ProjectCategory } from '../data/projects/index'
|
||||
import type { SceneContent } from '../data/scenes'
|
||||
import { PROJECTS_SCENE, RESUME_SCENE } from '../data/pages'
|
||||
|
||||
interface SceneContent {
|
||||
title: string
|
||||
subtitle: string
|
||||
body: string
|
||||
}
|
||||
|
||||
export interface OverlayCallbacks {
|
||||
onNavClick: (scene: number) => void
|
||||
onButtonClick: () => void
|
||||
onNavigate: (path: string) => void
|
||||
}
|
||||
|
||||
// ---------- HTML builders ----------
|
||||
@@ -134,8 +141,6 @@ export class DomOverlay {
|
||||
private readonly resumeSectionEl: HTMLElement
|
||||
private readonly cycleBtnEl: HTMLElement
|
||||
private readonly scenes: SceneContent[]
|
||||
private projectsBuilt = false
|
||||
private resumeBuilt = false
|
||||
|
||||
constructor(scenes: SceneContent[], callbacks: OverlayCallbacks) {
|
||||
this.scenes = scenes
|
||||
@@ -154,59 +159,62 @@ export class DomOverlay {
|
||||
|
||||
this.cycleBtnEl.addEventListener('click', () => callbacks.onButtonClick())
|
||||
|
||||
// delegate clicks inside projects section
|
||||
// delegate clicks inside projects section → onNavigate callback
|
||||
this.projectsSectionEl.addEventListener('click', (e) => {
|
||||
const target = e.target as HTMLElement
|
||||
|
||||
// category card click → open category
|
||||
// category card click
|
||||
const catCard = target.closest<HTMLElement>('.cat-card')
|
||||
if (catCard && catCard.dataset.catId) {
|
||||
this.showCategory(catCard.dataset.catId)
|
||||
if (catCard?.dataset.catId) {
|
||||
callbacks.onNavigate(`/projects/${catCard.dataset.catId}`)
|
||||
return
|
||||
}
|
||||
|
||||
// project card click → open detail
|
||||
// project card click
|
||||
const projCard = target.closest<HTMLElement>('.project-card')
|
||||
if (projCard && projCard.dataset.projectId) {
|
||||
// find which category this project belongs to
|
||||
if (projCard?.dataset.projectId) {
|
||||
const cat = CATEGORIES.find((c) =>
|
||||
c.projects.some((p) => p.id === projCard.dataset.projectId)
|
||||
)
|
||||
if (cat) {
|
||||
this.showProjectDetail(projCard.dataset.projectId, cat.id)
|
||||
callbacks.onNavigate(`/projects/${cat.id}/${projCard.dataset.projectId}`)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// back button
|
||||
// back buttons
|
||||
const back = target.closest<HTMLElement>('.project-back')
|
||||
if (back && back.dataset.back === 'grid') {
|
||||
this.showCategoryGrid()
|
||||
if (back?.dataset.back === 'grid') {
|
||||
callbacks.onNavigate('/projects')
|
||||
return
|
||||
}
|
||||
if (back && back.dataset.back === 'cat' && back.dataset.catId) {
|
||||
this.showCategory(back.dataset.catId)
|
||||
if (back?.dataset.back === 'cat' && back.dataset.catId) {
|
||||
callbacks.onNavigate(`/projects/${back.dataset.catId}`)
|
||||
return
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private showCategoryGrid(): void {
|
||||
showCategoryGrid(): void {
|
||||
this.projectsSectionEl.innerHTML = buildCategoryGridHTML(CATEGORIES)
|
||||
}
|
||||
|
||||
private showCategory(id: string): void {
|
||||
showCategory(id: string): void {
|
||||
const cat = CATEGORIES.find((c) => c.id === id)
|
||||
if (!cat) return
|
||||
this.projectsSectionEl.innerHTML = buildCategoryListHTML(cat)
|
||||
}
|
||||
|
||||
private showProjectDetail(projectId: string, catId: string): void {
|
||||
showProjectDetail(projectId: string, catId: string): void {
|
||||
const p = PROJECTS.find((p) => p.id === projectId)
|
||||
if (!p) return
|
||||
this.projectsSectionEl.innerHTML = buildProjectDetailHTML(p, catId)
|
||||
}
|
||||
|
||||
showResume(): void {
|
||||
this.resumeSectionEl.innerHTML = buildResumeHTML(RESUME)
|
||||
}
|
||||
|
||||
setScene(scene: number): void {
|
||||
const content = this.scenes[scene]
|
||||
if (content) {
|
||||
@@ -221,25 +229,14 @@ export class DomOverlay {
|
||||
this.resumeSectionEl.classList.remove('visible')
|
||||
this.cycleBtnEl.style.display = ''
|
||||
|
||||
if (scene === 2) {
|
||||
if (scene === PROJECTS_SCENE) {
|
||||
this.sceneContentEl.classList.add('hidden')
|
||||
this.projectsSectionEl.classList.add('visible')
|
||||
this.cycleBtnEl.style.display = 'none'
|
||||
if (!this.projectsBuilt) {
|
||||
this.showCategoryGrid()
|
||||
this.projectsBuilt = true
|
||||
} else {
|
||||
// always reset to category grid when landing on projects
|
||||
this.showCategoryGrid()
|
||||
}
|
||||
} else if (scene === 3) {
|
||||
} else if (scene === RESUME_SCENE) {
|
||||
this.sceneContentEl.classList.add('hidden')
|
||||
this.resumeSectionEl.classList.add('visible')
|
||||
this.cycleBtnEl.style.display = 'none'
|
||||
if (!this.resumeBuilt) {
|
||||
this.resumeSectionEl.innerHTML = buildResumeHTML(RESUME)
|
||||
this.resumeBuilt = true
|
||||
}
|
||||
}
|
||||
|
||||
this.navItems.forEach((item, i) => {
|
||||
|
||||
22
webhook/gitea-runner.service
Normal file
22
webhook/gitea-runner.service
Normal file
@@ -0,0 +1,22 @@
|
||||
[Unit]
|
||||
Description=Gitea Actions Runner (webgpu-portfolio)
|
||||
After=network.target docker.service
|
||||
Requires=docker.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=djoser
|
||||
ExecStart=/usr/local/bin/act_runner daemon --config /home/djoser/.gitea-act-runner/config.yaml
|
||||
WorkingDirectory=/home/djoser/.gitea-act-runner
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
# Security
|
||||
NoNewPrivileges=yes
|
||||
PrivateTmp=yes
|
||||
ProtectSystem=strict
|
||||
ProtectHome=read-only
|
||||
ReadWritePaths=/home/djoser/.gitea-act-runner /home/djoser/.cache/actcache /var/run/docker.sock
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
138
webhook/server.py
Executable file
138
webhook/server.py
Executable file
@@ -0,0 +1,138 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Gitea webhook receiver — listens for push events and triggers a docker-compose
|
||||
redeploy with zero-downtime (rebuild + restart only the changed service).
|
||||
|
||||
Usage:
|
||||
WEBHOOK_SECRET=... REPO_DIR=/home/djoser/webgpu-portfolio python3 server.py
|
||||
|
||||
Environment:
|
||||
WEBHOOK_SECRET Gitea webhook secret (HMAC-SHA256) [required]
|
||||
REPO_DIR Path to the repo with docker-compose.yml [default: .]
|
||||
LISTEN_HOST Bind address [default: 0.0.0.0]
|
||||
LISTEN_PORT Port [default: 9000]
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import http.server
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
# ---- config ----
|
||||
|
||||
SECRET = os.environ.get("WEBHOOK_SECRET", "").encode()
|
||||
REPO_DIR = Path(os.environ.get("REPO_DIR", Path.cwd()))
|
||||
HOST = os.environ.get("LISTEN_HOST", "0.0.0.0")
|
||||
PORT = int(os.environ.get("LISTEN_PORT", "9000"))
|
||||
|
||||
|
||||
def verify_signature(body: bytes, header: str) -> bool:
|
||||
"""Verify Gitea's X-Gitea-Signature header against the shared secret."""
|
||||
if not SECRET:
|
||||
print("[webhook] WARNING: WEBHOOK_SECRET is empty — accepting all requests", flush=True)
|
||||
return True
|
||||
if not header:
|
||||
return False
|
||||
expected = hmac.new(SECRET, body, hashlib.sha256).hexdigest()
|
||||
return hmac.compare_digest(expected, header)
|
||||
|
||||
|
||||
def deploy() -> tuple[int, str]:
|
||||
"""Run 'git pull' + 'docker compose up -d --build' in REPO_DIR."""
|
||||
steps = [
|
||||
# Pull latest code
|
||||
(["git", "pull", "origin", "master"], "git pull"),
|
||||
# Rebuild + restart only the web container (nginx-proxy doesn't change)
|
||||
(["docker", "compose", "up", "-d", "--build", "--remove-orphans"], "docker compose up"),
|
||||
]
|
||||
|
||||
for cmd, label in steps:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
cwd=REPO_DIR,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return 1, f"{label} failed (exit {result.returncode}):\n{result.stderr.strip()}"
|
||||
print(f"[webhook] {label}: ok", flush=True)
|
||||
|
||||
return 0, "deploy ok"
|
||||
|
||||
|
||||
class WebhookHandler(http.server.BaseHTTPRequestHandler):
|
||||
def log_message(self, fmt: str, *args) -> None:
|
||||
print(f"[webhook] {self.client_address[0]} — {fmt % args}", flush=True)
|
||||
|
||||
def do_POST(self) -> None:
|
||||
content_length = int(self.headers.get("Content-Length", "0"))
|
||||
body = self.rfile.read(content_length)
|
||||
|
||||
# Verify signature
|
||||
signature = self.headers.get("X-Gitea-Signature", "")
|
||||
if not verify_signature(body, signature):
|
||||
self.send_error(403, "Bad signature")
|
||||
return
|
||||
|
||||
# Parse event
|
||||
event_type = self.headers.get("X-Gitea-Event", "")
|
||||
if event_type != "push":
|
||||
self.send_response(200)
|
||||
self.end_headers()
|
||||
self.wfile.write(f'{{"status":"ignored","event":"{event_type}"}}\n'.encode())
|
||||
return
|
||||
|
||||
try:
|
||||
payload = json.loads(body)
|
||||
except json.JSONDecodeError:
|
||||
self.send_error(400, "Invalid JSON")
|
||||
return
|
||||
|
||||
ref = payload.get("ref", "")
|
||||
print(f"[webhook] push on {ref} — deploying...", flush=True)
|
||||
|
||||
exit_code, msg = deploy()
|
||||
if exit_code == 0:
|
||||
self.send_response(200)
|
||||
self.end_headers()
|
||||
self.wfile.write(b'{"status":"ok"}\n')
|
||||
else:
|
||||
self.send_response(500)
|
||||
self.end_headers()
|
||||
self.wfile.write(f'{{"status":"error","detail":{json.dumps(msg)}}}\n'.encode())
|
||||
|
||||
def do_GET(self) -> None:
|
||||
"""Health-check endpoint."""
|
||||
if self.path == "/health":
|
||||
self.send_response(200)
|
||||
self.end_headers()
|
||||
self.wfile.write(b"ok\n")
|
||||
else:
|
||||
self.send_error(405, "Method not allowed")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
if not SECRET:
|
||||
print(
|
||||
"[webhook] WARNING: WEBHOOK_SECRET is empty. Set it for security.\n"
|
||||
" Generate one: openssl rand -hex 32",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
server = http.server.HTTPServer((HOST, PORT), WebhookHandler)
|
||||
print(f"[webhook] Listening on {HOST}:{PORT} (repo dir: {REPO_DIR})", flush=True)
|
||||
try:
|
||||
server.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
print("\n[webhook] Shutting down.", flush=True)
|
||||
server.shutdown()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
23
webhook/webhook.service
Normal file
23
webhook/webhook.service
Normal file
@@ -0,0 +1,23 @@
|
||||
[Unit]
|
||||
Description=WebGPU Portfolio webhook receiver
|
||||
After=network.target docker.service
|
||||
Requires=docker.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=djoser
|
||||
WorkingDirectory=/home/djoser/webgpu-portfolio
|
||||
EnvironmentFile=/home/djoser/webgpu-portfolio/.env
|
||||
ExecStart=/usr/bin/python3 /home/djoser/webgpu-portfolio/webhook/server.py
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
# Security hardening
|
||||
NoNewPrivileges=yes
|
||||
PrivateTmp=yes
|
||||
ProtectSystem=strict
|
||||
ProtectHome=read-only
|
||||
ReadWritePaths=/home/djoser/webgpu-portfolio /var/run/docker.sock /home/djoser/.docker
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
Reference in New Issue
Block a user