diff --git a/content/about.md b/content/about.md new file mode 100644 index 0000000..65f918c --- /dev/null +++ b/content/about.md @@ -0,0 +1,33 @@ +# About Me + +Hello — I'm **Gauvain Boiché**, a computer science student and developer based in France. + +I build things at the intersection of systems programming, web technology, and good design. This terminal is itself a small project: a static portfolio that navigates like a Unix filesystem. + +--- + +## Background + +I started programming by taking apart old software and curiosity about how things work at a low level. That thread has run through everything since — from writing bare-metal code to designing distributed systems to crafting interfaces that feel just right. + +## Education + +- **Master's in Computer Science** — in progress +- Coursework: algorithms & complexity, operating systems, distributed computing, machine learning fundamentals +- Strong academic record with particular interest in systems and compilers + +## What Drives Me + +> The best technology disappears. You stop thinking about the tool and start thinking about the problem. + +I care deeply about **correctness**, **performance**, and **clarity** — in code, in interfaces, and in communication. I prefer to understand things from first principles rather than cargo-cult patterns. + +--- + +## Outside the Terminal + +When I'm not at a keyboard, I'm probably reading about computing history, tinkering with vintage hardware, or trying to convince someone that Vim is not that complicated. + +--- + +*Navigate with ↑↓ and ENTER — press Q or ESC to go back.* diff --git a/content/contact.md b/content/contact.md new file mode 100644 index 0000000..40b9f12 --- /dev/null +++ b/content/contact.md @@ -0,0 +1,40 @@ +# Contact + +I'm open to collaborations, internship opportunities, and interesting conversations about technology. + +--- + +## How to Reach Me + +**Email** +`gauvain.boiche [at] gmail [dot] com` + +**GitHub** +`github.com/gauvainboiche` + +**LinkedIn** +`linkedin.com/in/gauvainboiche` + +--- + +## What I'm Looking For + +- **Internships / apprenticeships** in systems engineering, backend development, or ML infrastructure +- **Open source collaboration** — especially on compilers, tools, or developer infrastructure +- **Freelance work** — web applications, APIs, automation scripts + +--- + +## Response Time + +I typically respond within 48 hours. If you're reaching out about a time-sensitive opportunity, please mention it in the subject line. + +--- + +## A Note on This Site + +This portfolio is intentionally low-tech: a single HTML file, one CSS file, one JS file, and a folder of Markdown. No framework, no build step, no tracking. The source is on GitHub if you're curious. + +--- + +*Press Q or ESC to return.* diff --git a/content/manifest.json b/content/manifest.json new file mode 100644 index 0000000..1b3dc74 --- /dev/null +++ b/content/manifest.json @@ -0,0 +1,42 @@ +{ + "name": "~", + "type": "dir", + "children": [ + { + "name": "about.md", + "type": "file", + "path": "content/about.md" + }, + { + "name": "skills.md", + "type": "file", + "path": "content/skills.md" + }, + { + "name": "projects", + "type": "dir", + "children": [ + { + "name": "web_dev.md", + "type": "file", + "path": "content/projects/web_dev.md" + }, + { + "name": "systems.md", + "type": "file", + "path": "content/projects/systems.md" + }, + { + "name": "ai_ml.md", + "type": "file", + "path": "content/projects/ai_ml.md" + } + ] + }, + { + "name": "contact.md", + "type": "file", + "path": "content/contact.md" + } + ] +} \ No newline at end of file diff --git a/content/projects/ai_ml.md b/content/projects/ai_ml.md new file mode 100644 index 0000000..d483335 --- /dev/null +++ b/content/projects/ai_ml.md @@ -0,0 +1,71 @@ +# AI & Machine Learning Projects + +Experiments at the boundary of classical algorithms and modern deep learning. + +--- + +## Handwritten Digit Classifier + +A convolutional neural network trained on MNIST, built from scratch using **PyTorch** without relying on pretrained weights. + +**Architecture:** + +``` +Input (1×28×28) + → Conv2d(1, 32, 3) + ReLU + MaxPool + → Conv2d(32, 64, 3) + ReLU + MaxPool + → Dropout(0.25) + → Linear(64×5×5 → 128) + ReLU + → Dropout(0.5) + → Linear(128 → 10) + → LogSoftmax +``` + +**Results:** 99.2 % test accuracy after 10 epochs on a single CPU. + +--- + +## Sentence Similarity Engine + +A small semantic search tool that encodes sentences into embeddings and retrieves the most similar entries from a knowledge base. + +**Approach:** + +- Sentence embeddings via a fine-tuned BERT variant (`sentence-transformers`) +- FAISS index for approximate nearest-neighbour search at scale +- CLI interface — `search.py "your query"` + +**Use case:** powering a private personal knowledge base search over Markdown notes. + +--- + +## Reinforcement Learning: Grid World + +A from-scratch implementation of Q-Learning and SARSA applied to a configurable grid-world environment. + +**Implemented:** + +- Tabular Q-learning with ε-greedy exploration +- SARSA (on-policy variant) +- Policy iteration and value iteration for comparison +- Visualiser showing the learned value function as a heatmap + +**Written in pure Python + NumPy** — no RL libraries — for learning purposes. + +--- + +## Anomaly Detection on Time Series + +A pipeline for detecting anomalies in server metric data (CPU, memory, latency). + +**Methods compared:** + +- Z-score baseline +- Isolation Forest +- LSTM autoencoder (reconstruction error threshold) + +**Outcome:** LSTM autoencoder outperformed statistical methods by ~18 % precision on labelled incidents from a personal homelab dataset. + +--- + +*Press Q or ESC to return.* diff --git a/content/projects/systems.md b/content/projects/systems.md new file mode 100644 index 0000000..b830922 --- /dev/null +++ b/content/projects/systems.md @@ -0,0 +1,69 @@ +# Systems Programming Projects + +Low-level work: operating systems, compilers, networking, and performance engineering. + +--- + +## Tiny HTTP/1.1 Server (C) + +A from-scratch HTTP server written in C that handles concurrent connections using `epoll` on Linux. + +**What it does:** + +- Serves static files with correct MIME types +- Handles `keep-alive` connections +- Parses request headers manually (no third-party parser) +- ~1,200 lines of C — intentionally minimal + +**Interesting problems solved:** + +- Non-blocking I/O with an event loop +- Incremental parsing of chunked request bodies +- Safe path traversal (no `../` escapes from the document root) + +--- + +## Shell Implementation (C) + +A POSIX-compatible shell (`mysh`) supporting pipelines, redirections, background jobs, and a small built-in set. + +**Supported features:** + +- Pipelines: `cmd1 | cmd2 | cmd3` +- I/O redirections: `>`, `>>`, `<`, `2>` +- Background jobs: `cmd &` +- Job control: `fg`, `bg`, `jobs` +- Signal handling (SIGINT, SIGTSTP, SIGHUP) + +**Focus areas:** `fork/exec` model, `waitpid`, file descriptor management, signal masking. + +--- + +## Memory Allocator + +A drop-in `malloc` replacement using `mmap`-backed free lists with coalescing. + +**Algorithms implemented:** + +- First-fit and best-fit strategies (switchable at compile time) +- Boundary-tag coalescing to reduce fragmentation +- Thread-local caches for small allocations (reduces lock contention) + +**Benchmarked** against `glibc malloc` on synthetic workloads — within 2× for mixed-size allocations. + +--- + +## Bytecode Interpreter (Python → C) + +A small virtual machine that executes a simple bytecode format, written in C. The front-end compiler is written in Python and produces `.bco` files. + +**VM features:** + +- Stack-based architecture (inspired by CPython) +- 30 opcodes: arithmetic, comparisons, jumps, function calls, closures +- Mark-and-sweep garbage collector +- Disassembler for debugging (`--disasm` flag) + +--- + +*Press Q or ESC to return.* diff --git a/content/projects/web_dev.md b/content/projects/web_dev.md new file mode 100644 index 0000000..25ef49a --- /dev/null +++ b/content/projects/web_dev.md @@ -0,0 +1,56 @@ +# Web Development Projects + +A selection of web projects — from small tools to more complete applications. + +--- + +## Terminal Portfolio *(this site)* + +> The page you are currently reading was itself a project. + +A fully static portfolio designed to look and behave like a Unix terminal. No framework, no build step — just HTML, CSS, and vanilla JavaScript. + +**Key features:** + +- CRT aesthetics: scanlines, flicker animation, bloom/glow on text +- Blinking rectangular cursor that follows mouse clicks +- Markdown content tree loaded from a manifest — add `.md` files without touching `index.html` +- Keyboard navigation (arrows, Enter, Escape, Q) + mouse +- Dynamically sized terminal window (responds to viewport changes) + +**Stack:** HTML · CSS (animations, custom properties) · JavaScript (ES2020) · marked.js + +--- + +## Personal Finance Tracker + +A small single-page application to track income and expenses with monthly breakdowns. + +**Highlights:** + +- No backend — all data stored in `localStorage` with a clean JSON schema +- CSV import/export for moving data to spreadsheets +- Chart visualisation using the Canvas API (no libraries) +- Fully keyboard-navigable + +**Stack:** JavaScript · CSS Grid · Canvas API + +--- + +## REST API Boilerplate + +A reusable Node.js/Express skeleton with authentication, request validation, and structured logging built in — designed to be forked and extended. + +**Features:** + +- JWT-based auth with refresh tokens +- Zod schema validation on all endpoints +- Structured JSON logs (pino) +- Docker Compose stack (app + PostgreSQL) +- OpenAPI spec auto-generated from route definitions + +**Stack:** Node.js · Express · PostgreSQL · Docker · Zod + +--- + +*Press Q or ESC to return.* diff --git a/content/skills.md b/content/skills.md new file mode 100644 index 0000000..99509f9 --- /dev/null +++ b/content/skills.md @@ -0,0 +1,46 @@ +# Skills + +A snapshot of the tools and technologies I work with regularly, and where I place my confidence. + +--- + +## Languages + +| Language | Proficiency | Notes | +|------------|-------------|--------------------------------------------| +| C | ★★★★★ | Systems, embedded, performance-critical code | +| Python | ★★★★★ | Scripting, data pipelines, ML prototyping | +| JavaScript | ★★★★☆ | Full-stack, Node.js, browser APIs | +| Rust | ★★★☆☆ | Learning — memory safety, async runtimes | +| Java | ★★★☆☆ | Coursework, Spring ecosystem | +| Bash / Zsh | ★★★★☆ | Automation, CI scripting | + +--- + +## Systems & Infrastructure + +- **Operating Systems** — Linux administration, kernel concepts, process/memory management +- **Networking** — TCP/IP, HTTP, DNS, socket programming +- **Containerisation** — Docker, Docker Compose +- **Version Control** — Git (including rebase, worktrees, bisect) +- **CI/CD** — GitHub Actions, basic pipeline design + +--- + +## Web Development + +- **Frontend** — Vanilla JS/CSS, React (functional, hooks), accessible HTML +- **Backend** — Node.js/Express, Python/FastAPI, REST API design +- **Databases** — PostgreSQL, SQLite, basic Redis usage + +--- + +## Other + +- **Algorithms & Data Structures** — competitive-programming level +- **Compilers** — lexing, parsing, AST construction (hobby project) +- **Machine Learning** — PyTorch, scikit-learn, classical + deep methods + +--- + +*Press Q or ESC to return.* diff --git a/index.html b/index.html index a03ab1e..f1d543a 100644 --- a/index.html +++ b/index.html @@ -1,57 +1,39 @@ - + + - Gauvain Boiché - Portfolio - + Gauvain Boiché — Portfolio + + + + -
-

Bienvenue sur mon Portfolio Informatique

- -
+
+
+ GAUVAIN BOICHÉ — PORTFOLIO TERMINAL + ~ +
+
+
+ +
+
+ + +
+
+
-
-

Mes Projets

-

Voici quelques-uns de mes projets récents :

- -
- -
-

Mes Compétences

-

Je maîtrise les technologies suivantes :

- -
- -
-

Contactez-moi

-

Pour toute question ou collaboration, n'hésitez pas à me contacter :

-
-
-

-
-

- -
-
- - + + + \ No newline at end of file diff --git a/resources/css/index.css b/resources/css/index.css index d66a664..2fef131 100644 --- a/resources/css/index.css +++ b/resources/css/index.css @@ -1,104 +1,449 @@ -/* Global aspect */ +/* ============================================================ + TERMINAL PORTFOLIO — CSS + Golden CRT aesthetic with flicker, bloom, scanlines + ============================================================ */ + +:root { + --gold: #e8c200; + --gold-bright: #ffe060; + --gold-glow: #ffcf00; + --gold-dim: #a88a18; + --gold-dark: #5c4800; + --bg: #060400; + --bg-surface: #0e0900; + --font: 'Inconsolata', 'Courier New', monospace; + --item-h: 28px; +} * { - box-sizing: border-box; /* permet de calculer les bordures dans les tailles affichées */ + box-sizing: border-box; + margin: 0; + padding: 0; } - /* THEME VERT -:root { - --primary-color: #34cc26; - --secondary-color: #1fe743; - --primary-text-color: #FFFFFF; - --secondary-text-color: #4bb04b; - --background-color: #214e21; - --font-family: 'Inconsolata', monospace; -} -*/ -:root { - --primary-color: #ccb626; - --secondary-color: #e7b21f; - --primary-text-color: #FFFFFF; - --secondary-text-color: #b0954b; - --background-color: #454019; - --font-family: 'Inconsolata', monospace; +html, +body { + width: 100%; + height: 100%; + overflow: hidden; + background: #000; } body { - background: var(--background-color); - background: radial-gradient(circle,var(--background-color) 0%, black 100%); - background-size: cover; - color: var(--primary-text-color); - text-shadow: 0 0 5px var(--secondary-color); - font: 1rem Inconsolata, monospace; - height: 100vh; - width: 800px; - display: grid; - grid-template-columns: repeat(2, 400px); + font-family: var(--font); + font-size: 16px; + line-height: 1.6; + color: var(--gold); + background: radial-gradient(ellipse at 50% 35%, #1a1100 0%, #090600 45%, #000 100%); + position: relative; } +/* ── Scanlines overlay ─────────────────────────────────────── */ +body::before { + content: ''; + position: fixed; + inset: 0; + background: repeating-linear-gradient(to bottom, + transparent 0px, + transparent 2px, + rgba(0, 0, 0, 0.10) 2px, + rgba(0, 0, 0, 0.10) 3px); + pointer-events: none; + z-index: 900; +} + +/* ── CRT vignette ──────────────────────────────────────────── */ body::after { - content: ""; + content: ''; + position: fixed; + inset: 0; + background: + radial-gradient(ellipse at center, transparent 48%, rgba(0, 0, 0, 0.65) 100%); + pointer-events: none; + z-index: 901; +} + + + +/* ── Terminal window ───────────────────────────────────────── */ +#terminal { + position: fixed; + top: 14px; + left: 14px; + right: 14px; + bottom: 14px; + display: flex; + flex-direction: column; + border: 3px solid var(--gold); + background: radial-gradient(ellipse at 50% 28%, var(--bg-surface) 0%, var(--bg) 100%); + z-index: 10; + box-shadow: inset 0 0 40px rgba(0, 0, 0, 0.7); +} + +/* ── Title bar ─────────────────────────────────────────────── */ +#titlebar { + display: flex; + justify-content: space-between; + align-items: center; + padding: 6px 18px; + border-bottom: 2px solid var(--gold-dim); + font-size: 13px; + font-weight: 700; + letter-spacing: 3px; + text-transform: uppercase; + color: var(--gold-glow); + + flex-shrink: 0; + background: rgba(255, 183, 0, 0.025); + user-select: none; +} + +#current-path { + color: var(--gold-dim); + letter-spacing: 1px; + font-weight: 400; + font-size: 13px; +} + +/* ── Terminal body ─────────────────────────────────────────── */ +#terminal-body { + flex: 1; + overflow: hidden; + position: relative; +} + +.view { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + padding: 8px 16px; +} + +.view.hidden { + display: none; +} + +/* ── Tree view ─────────────────────────────────────────────── */ +#tree-view { + /* populated by JS */ +} + +.tree-prompt { + color: var(--gold-glow); + font-weight: 700; + flex-shrink: 0; + white-space: pre; + font-size: 15px; + margin-bottom: 4px; + user-select: none; +} + +.tree-sep { + border: none; + border-top: 1px solid var(--gold-dim); + flex-shrink: 0; + margin: 3px 0; + opacity: 0.6; +} + +.tree-items-wrap { + flex: 1; + overflow: hidden; + position: relative; +} + +.tree-items-inner { position: absolute; top: 0; left: 0; - width: 100vw; - height: 100vh; - background: repeating-linear-gradient( - 0deg, - rgba(0, 0, 0, 0.15), - rgba(0, 0, 0, 0.15) 1px, - transparent 1px, - transparent 2px - ); - pointer-events: none; + right: 0; + /* translateY controlled by JS for scrolling */ } +.tree-item { + height: var(--item-h); + line-height: var(--item-h); + padding: 0 8px; + white-space: pre; + cursor: pointer; + color: var(--gold); + font-size: 15px; + + user-select: none; + transition: background 0.05s; +} + +.tree-item.item-dir { + color: var(--gold-bright); + font-weight: 700; +} + +.tree-item.item-parent { + color: var(--gold-dim); +} + +.tree-item:hover:not(.selected) { + background: rgba(200, 164, 0, 0.10); +} + +.tree-item.selected { + background: var(--gold); + color: #000 !important; + text-shadow: none !important; + animation: none; + font-weight: 700; +} + +.tree-item.item-dir.selected { + background: var(--gold-bright); +} + +.tree-scroll-info { + flex-shrink: 0; + font-size: 13px; + color: var(--gold-dim); + padding: 0 8px; + height: var(--item-h); + line-height: var(--item-h); + user-select: none; +} + +/* ── Page view ─────────────────────────────────────────────── */ +#page-header { + flex-shrink: 0; + color: var(--gold-glow); + font-weight: 700; + font-size: 15px; + white-space: pre; + margin-bottom: 4px; + user-select: none; +} + +#page-scroll-area { + flex: 1; + overflow-y: auto; + scrollbar-width: none; + -ms-overflow-style: none; + border-top: 1px solid var(--gold-dim); + padding-top: 8px; +} + +#page-scroll-area::-webkit-scrollbar { + display: none; +} + +#page-content { + color: var(--gold); + font-size: 15px; + line-height: 1.75; + padding-bottom: 20px; +} + +/* Markdown content styling */ +#page-content h1 { + font-size: 1.5em; + color: var(--gold-bright); + text-transform: uppercase; + letter-spacing: 2px; + border-bottom: 2px double var(--gold-dim); + padding-bottom: 6px; + margin-bottom: 16px; + +} + +#page-content h2 { + font-size: 1.2em; + color: var(--gold-glow); + letter-spacing: 1px; + margin: 20px 0 10px; + +} + +#page-content h3 { + font-size: 1.05em; + color: var(--gold-bright); + margin: 14px 0 6px; + +} + +#page-content p { + margin-bottom: 10px; + line-height: 1.65; +} + +#page-content ul, +#page-content ol { + padding-left: 22px; + margin-bottom: 10px; +} + +#page-content li { + margin-bottom: 3px; +} + +#page-content strong { + color: var(--gold-bright); + font-weight: 700; +} + +#page-content em { + color: var(--gold-glow); + font-style: italic; +} + +#page-content hr { + border: none; + border-top: 1px solid var(--gold-dim); + margin: 14px 0; + opacity: 0.5; +} + +#page-content a { + color: var(--gold-bright); + text-decoration: underline; +} + +#page-content code { + background: rgba(200, 164, 0, 0.12); + border: 1px solid var(--gold-dark); + padding: 1px 4px; + font-family: var(--font); + color: var(--gold-bright); + font-size: 0.92em; +} + +#page-content pre { + background: rgba(0, 0, 0, 0.45); + border: 1px solid var(--gold-dim); + padding: 10px; + overflow-x: auto; + margin-bottom: 12px; +} + +#page-content pre code { + background: none; + border: none; + padding: 0; + color: var(--gold-glow); +} + +#page-content blockquote { + border-left: 3px solid var(--gold-dim); + padding-left: 14px; + color: var(--gold-glow); + margin: 12px 0; + font-style: italic; + +} + +/* ── Text bloom animation ──────────────────────────────────── */ +@keyframes text-bloom { + + 0%, + 100% { + text-shadow: 0 0 7px var(--gold), 0 0 14px rgba(232, 194, 0, 0.45); + } + + 50% { + text-shadow: 0 0 10px var(--gold-bright), 0 0 22px var(--gold-glow), 0 0 38px rgba(255, 220, 60, 0.28); + } +} + +/* ── Status bar ────────────────────────────────────────────── */ +#statusbar { + display: flex; + justify-content: space-between; + align-items: center; + padding: 6px 18px; + border-top: 2px solid var(--gold-dim); + font-size: 13px; + letter-spacing: 1px; + color: var(--gold); + flex-shrink: 0; + background: rgba(255, 183, 0, 0.02); + user-select: none; +} + +.key { + color: var(--gold-bright); + font-weight: 700; + cursor: pointer; + padding: 2px 7px; + border: 1px solid var(--gold); + letter-spacing: 0; + transition: background 0.1s; +} + +.key:hover { + background: rgba(200, 164, 0, 0.2); +} + +/* ── Rectangular cursor block ──────────────────────────────── */ +#cursor-block { + position: fixed; + width: 9px; + height: 18px; + background: var(--gold-bright); + pointer-events: none; + z-index: 2000; + opacity: 0; + transform: translate(0, -9px); +} + +#cursor-block.active { + animation: cursor-blink 0.52s step-end infinite; +} + +@keyframes cursor-blink { + + 0%, + 100% { + opacity: 1; + } + + 50% { + opacity: 0; + } +} + +/* ── Boot screen ───────────────────────────────────────────── */ +#boot-screen { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + justify-content: center; + padding: 30px 40px; + z-index: 50; + font-size: 16px; + line-height: 2; + color: var(--gold); + +} + +#boot-screen.hidden { + display: none; +} + +.boot-line { + white-space: pre; +} + +.boot-ok { + color: var(--gold-bright); +} + +.boot-dim { + color: var(--gold-dim); +} + +/* ── Selection ─────────────────────────────────────────────── */ ::selection { - background: #0080FF; + background: rgba(200, 164, 0, 0.35); text-shadow: none; } -/* Generecis elements */ - -header { - grid-column: 1 / 3; -} - -h1, h2, p, li, form { - width: 75%; - padding: 10px; - margin: 0 auto; -} - -h1, h2 { - font-size: 2.5rem; - text-align: center; - color:var(--secondary-text-color); - border: thick double var(--secondary-text-color); -} - -p { - font-size: 1.2rem; - line-height: 1.5; - width: 75%; - text-align: justify; - border: solid 1px var(--secondary-text-color); -} - -a { - color: var(--primary-color); - text-decoration: none; - transition: color 0.3s ease; -} - -a:hover { - color: var(--secondary-color); - text-decoration: none; - transition: color 0.3s ease; -} - -/* CLASS elements */ - /* ID elements */ \ No newline at end of file diff --git a/resources/js/terminal.js b/resources/js/terminal.js new file mode 100644 index 0000000..6cf6a65 --- /dev/null +++ b/resources/js/terminal.js @@ -0,0 +1,417 @@ +/* ============================================================ + TERMINAL PORTFOLIO — JS + Tree navigation + Markdown page viewer + ============================================================ */ + +'use strict'; + +// ── Constants ────────────────────────────────────────────── +const ITEM_H = 28; // px, must match --item-h CSS var +const SCROLL_STEP = 80; // px per arrow key in page view +const BOOT_LINES = [ + { text: 'GAUVAIN BOICHÉ PORTFOLIO TERMINAL v1.0', cls: 'boot-ok' }, + { text: '─────────────────────────────────────────', cls: 'boot-dim' }, + { text: 'Initializing display subsystem... OK', cls: '' }, + { text: 'Loading content tree... OK', cls: '' }, + { text: 'Mounting virtual filesystem... OK', cls: '' }, + { text: '─────────────────────────────────────────', cls: 'boot-dim' }, + { text: 'System ready. Use ↑↓ + ENTER to navigate.', cls: 'boot-ok' }, +]; +const BOOT_MS = 60; // delay between boot lines (ms) +const BOOT_HOLD = 800; // ms after last line before switching to tree + +// ── State ────────────────────────────────────────────────── +const state = { + mode: 'boot', // 'boot' | 'tree' | 'page' + dirStack: [], // [{node, selectedIdx, scrollOffset}, …] +}; + +let manifest = null; + +// ── Utility ───────────────────────────────────────────────── +function esc(str) { + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +function el(id) { return document.getElementById(id); } + +// ── Path helpers ──────────────────────────────────────────── +function buildPath() { + const parts = state.dirStack.map(d => d.node.name); + return parts.join('/').replace(/^~\//, '~/') || '~'; +} + +function currentDirState() { + return state.dirStack[state.dirStack.length - 1]; +} + +function getDirItems(dirNode, isRoot) { + const items = isRoot ? [] : [{ name: '..', type: 'parent' }]; + if (dirNode.children) { + const sorted = [...dirNode.children].sort((a, b) => { + if (a.type === 'dir' && b.type !== 'dir') return -1; + if (a.type !== 'dir' && b.type === 'dir') return 1; + return a.name.localeCompare(b.name); + }); + items.push(...sorted); + } + return items; +} + +// ── Render dispatcher ──────────────────────────────────────── +function render() { + if (state.mode === 'tree') renderTree(); + else if (state.mode === 'page') renderPage(); +} + +// ── Tree view ──────────────────────────────────────────────── +function renderTree() { + const ds = currentDirState(); + const isRoot = state.dirStack.length === 1; + const items = getDirItems(ds.node, isRoot); + const path = buildPath(); + + el('current-path').textContent = path; + + // How many items fit in the viewport + const bodyH = el('terminal-body').clientHeight; + // Rough overhead: prompt(22) + sep(10) + sep(10) + scrollinfo(22) + padding(16) + const overhead = 80; + const visible = Math.max(1, Math.floor((bodyH - overhead) / ITEM_H)); + + // Clamp scroll so selected item stays visible + let scroll = ds.scrollOffset || 0; + if (ds.selectedIdx < scroll) scroll = ds.selectedIdx; + if (ds.selectedIdx >= scroll + visible) scroll = ds.selectedIdx - visible + 1; + scroll = Math.max(0, Math.min(scroll, Math.max(0, items.length - visible))); + ds.scrollOffset = scroll; + + const slice = items.slice(scroll, scroll + visible); + const above = scroll; + const below = Math.max(0, items.length - scroll - visible); + + // Prompt line + const prompt = `gauvain@portfolio:${path}$ ls`; + + // Build HTML + let html = `
${esc(prompt)}
`; + html += `
`; + html += `
`; + + slice.forEach((item, vi) => { + const realIdx = scroll + vi; + const sel = realIdx === ds.selectedIdx; + const isDir = item.type === 'dir'; + const isPar = item.type === 'parent'; + const icon = isPar ? '../' : (isDir ? `${item.name}/` : item.name); + const cls = [ + 'tree-item', + sel ? 'selected' : '', + isDir ? 'item-dir' : '', + isPar ? 'item-parent' : '', + ].filter(Boolean).join(' '); + + html += `
${esc(icon)}
`; + }); + + html += `
`; + html += `
`; + + const scrollInfo = [ + above > 0 ? `↑ ${above} above` : '', + below > 0 ? `↓ ${below} below` : '', + ].filter(Boolean).join(' '); + html += `
${scrollInfo}
`; + + el('tree-view').innerHTML = html; + el('tree-view').classList.remove('hidden'); + el('page-view').classList.add('hidden'); + + // Update status bar + el('status-left').textContent = `${items.length} item(s)`; + el('status-right').innerHTML = + `↑↓ SELECT  ` + + `ENTER OPEN  ` + + `ESC BACK`; + + // Event delegation on items + const inner = el('tree-items-inner'); + if (inner) { + inner.addEventListener('click', onTreeItemClick); + inner.addEventListener('dblclick', onTreeItemDblClick); + } +} + +function onTreeItemClick(e) { + const itemEl = e.target.closest('.tree-item'); + if (!itemEl) return; + const idx = parseInt(itemEl.dataset.idx, 10); + currentDirState().selectedIdx = idx; + renderTree(); +} + +function onTreeItemDblClick(e) { + const itemEl = e.target.closest('.tree-item'); + if (!itemEl) return; + const idx = parseInt(itemEl.dataset.idx, 10); + currentDirState().selectedIdx = idx; + openSelected(); +} + +// ── Open selected item ──────────────────────────────────────── +function openSelected() { + const ds = currentDirState(); + const isRoot = state.dirStack.length === 1; + const items = getDirItems(ds.node, isRoot); + const item = items[ds.selectedIdx]; + if (!item) return; + + if (item.type === 'parent') { + navigateBack(); + return; + } + + if (item.type === 'dir') { + state.dirStack.push({ node: item, selectedIdx: 0, scrollOffset: 0 }); + renderTree(); + return; + } + + // It's a file — load it + loadPage(item.path, item.name); +} + +// ── Navigate back ───────────────────────────────────────────── +function navigateBack() { + if (state.mode === 'page') { + state.mode = 'tree'; + render(); + return; + } + if (state.dirStack.length > 1) { + state.dirStack.pop(); + renderTree(); + } +} + +// ── Page view ───────────────────────────────────────────────── +async function loadPage(path, name) { + state.mode = 'page'; + + el('tree-view').classList.add('hidden'); + el('page-view').classList.remove('hidden'); + el('page-header').textContent = `gauvain@portfolio:${buildPath()}$ cat ${name}`; + el('page-content').innerHTML = `

Loading…

`; + el('current-path').textContent = buildPath() + '/' + name; + + updatePageStatus(); + + try { + const res = await fetch(path); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const md = await res.text(); + el('page-content').innerHTML = marked.parse(md); + el('page-scroll-area').scrollTop = 0; + } catch (err) { + el('page-content').innerHTML = + `

ERROR: Could not load ${esc(path)}
${esc(err.message)}

`; + } + + updatePageStatus(); +} + +function renderPage() { + el('tree-view').classList.add('hidden'); + el('page-view').classList.remove('hidden'); + updatePageStatus(); +} + +function updatePageStatus() { + el('status-left').textContent = state.mode === 'page' ? 'READING' : ''; + el('status-right').innerHTML = + ` SCROLL UP  ` + + ` SCROLL DOWN  ` + + `Q QUIT`; + + const sa = el('page-scroll-area'); + const btnUp = el('btn-up'); + const btnDn = el('btn-dn'); + const btnQ = el('btn-q'); + if (btnUp) btnUp.onclick = () => { sa.scrollBy({ top: -SCROLL_STEP, behavior: 'smooth' }); }; + if (btnDn) btnDn.onclick = () => { sa.scrollBy({ top: SCROLL_STEP, behavior: 'smooth' }); }; + if (btnQ) btnQ.onclick = navigateBack; +} + +// ── Keyboard events ─────────────────────────────────────────── +function setupKeyboard() { + document.addEventListener('keydown', (e) => { + if (state.mode === 'boot') return; + + if (state.mode === 'tree') { + handleTreeKey(e); + } else if (state.mode === 'page') { + handlePageKey(e); + } + }); +} + +function handleTreeKey(e) { + const ds = currentDirState(); + const isRoot = state.dirStack.length === 1; + const items = getDirItems(ds.node, isRoot); + + switch (e.key) { + case 'ArrowUp': + e.preventDefault(); + ds.selectedIdx = Math.max(0, ds.selectedIdx - 1); + renderTree(); + break; + case 'ArrowDown': + e.preventDefault(); + ds.selectedIdx = Math.min(items.length - 1, ds.selectedIdx + 1); + renderTree(); + break; + case 'Enter': + e.preventDefault(); + openSelected(); + break; + case 'Escape': + case 'ArrowLeft': + e.preventDefault(); + navigateBack(); + break; + case 'ArrowRight': + e.preventDefault(); + openSelected(); + break; + } +} + +function handlePageKey(e) { + const sa = el('page-scroll-area'); + switch (e.key) { + case 'ArrowUp': + case 'k': + e.preventDefault(); + sa.scrollBy({ top: -SCROLL_STEP, behavior: 'smooth' }); + break; + case 'ArrowDown': + case 'j': + e.preventDefault(); + sa.scrollBy({ top: SCROLL_STEP, behavior: 'smooth' }); + break; + case 'Home': + e.preventDefault(); + sa.scrollTo({ top: 0, behavior: 'smooth' }); + break; + case 'End': + e.preventDefault(); + sa.scrollTo({ top: sa.scrollHeight, behavior: 'smooth' }); + break; + case 'PageUp': + e.preventDefault(); + sa.scrollBy({ top: -(sa.clientHeight * 0.85), behavior: 'smooth' }); + break; + case 'PageDown': + e.preventDefault(); + sa.scrollBy({ top: (sa.clientHeight * 0.85), behavior: 'smooth' }); + break; + case 'q': + case 'Q': + case 'Escape': + e.preventDefault(); + navigateBack(); + break; + } +} + +// ── Cursor block (click simulation) ────────────────────────── +function setupCursor() { + const cursor = el('cursor-block'); + let hideTimer = null; + + document.addEventListener('click', (e) => { + // Skip clicks on status-bar keys (they have their own handlers) + if (e.target.classList.contains('key')) return; + + cursor.style.left = e.clientX + 'px'; + cursor.style.top = e.clientY + 'px'; + cursor.classList.add('active'); + cursor.style.opacity = '1'; + + clearTimeout(hideTimer); + hideTimer = setTimeout(() => { + cursor.classList.remove('active'); + cursor.style.opacity = '0'; + }, 2800); + }); +} + +// ── Boot sequence ───────────────────────────────────────────── +async function runBoot() { + // Inject a temporary boot screen inside the terminal body + const bootDiv = document.createElement('div'); + bootDiv.id = 'boot-screen'; + + // Append to tree-view placeholder (it's empty at this point) + el('tree-view').appendChild(bootDiv); + el('tree-view').classList.remove('hidden'); + + for (let i = 0; i < BOOT_LINES.length; i++) { + await sleep(BOOT_MS); + const { text, cls } = BOOT_LINES[i]; + const line = document.createElement('div'); + line.className = 'boot-line ' + (cls || ''); + line.textContent = text; + bootDiv.appendChild(line); + } + + await sleep(BOOT_HOLD); + bootDiv.remove(); +} + +function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +// ── Init ────────────────────────────────────────────────────── +document.addEventListener('DOMContentLoaded', async () => { + try { + // Fetch manifest first (fail fast) + const res = await fetch('content/manifest.json'); + if (!res.ok) throw new Error(`Manifest HTTP ${res.status}`); + manifest = await res.json(); + } catch (err) { + showFatal('Failed to load content/manifest.json — ' + err.message); + return; + } + + state.dirStack = [{ node: manifest, selectedIdx: 0, scrollOffset: 0 }]; + + // Boot screen then tree + state.mode = 'boot'; + await runBoot(); + + state.mode = 'tree'; + renderTree(); + + setupKeyboard(); + setupCursor(); +}); + +function showFatal(msg) { + const treeView = el('tree-view'); + treeView.classList.remove('hidden'); + treeView.innerHTML = ` +
+
SYSTEM ERROR
+
${esc(msg)}
+
+ Make sure content/manifest.json exists and the page is served over HTTP. +
+
`; +}