feat: SPA routing, projects/resume sections, deployment infra
- Add client-side router with History API pushState (router.ts, pages.ts) - Extend overlay with projects grid/category/detail views and resume section - Add Gitea CI/CD workflow and webhook deployment server - Improve Docker Compose with nginx reverse proxy config - Add deployment script and .env.example
This commit is contained in:
63
src/app.ts
63
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)
|
||||
@@ -28,11 +30,23 @@ export class App {
|
||||
|
||||
async start(): Promise<boolean> {
|
||||
this.input = new Input()
|
||||
this.overlay = new DomOverlay(SCENES, {
|
||||
onNavClick: (s) => { this.scene = s },
|
||||
onButtonClick: () => { this.scene = (this.scene + 1) % 5 }
|
||||
|
||||
this.overlay = new DomOverlay(PAGES, {
|
||||
onNavClick: (s) => this.router.navigate(Router.sceneToBasePath(s)),
|
||||
onButtonClick: () => {
|
||||
const next = (this.scene + 1) % PAGES.length
|
||||
this.router.navigate(Router.sceneToBasePath(next))
|
||||
},
|
||||
onNavigate: (path) => this.router.navigate(path),
|
||||
})
|
||||
this.overlay.setScene(this.scene)
|
||||
|
||||
// Initial route from current URL
|
||||
const route = this.router.start()
|
||||
this.applyRoute(route)
|
||||
|
||||
// Subsequent navigation (popstate or pushState)
|
||||
this.router.onChange((r) => this.applyRoute(r))
|
||||
|
||||
this.resize()
|
||||
const ok = await this.renderer.init(this.canvas)
|
||||
if (!ok) return false
|
||||
@@ -47,6 +61,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 +101,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, 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 +129,6 @@ export class App {
|
||||
|
||||
if (this.scene !== this.prevScene) {
|
||||
this.prevScene = this.scene
|
||||
this.overlay.setScene(this.scene)
|
||||
}
|
||||
|
||||
this.computeStemMultipliers(time)
|
||||
@@ -107,8 +138,8 @@ 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 === 4) {
|
||||
} 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) => {
|
||||
|
||||
Reference in New Issue
Block a user