Built and maintained by Coded Vision Design. Security policy Β· Contributing Β· Code of conduct
A pixel-faithful Windows 11 desktop, in the browser, as a portfolio.
Two parallel builds of the same OS ship from the same repo and the same assets/:
| Track | Stack | URL |
|---|---|---|
| v1 | PHP 8 Β· HTMX Β· Alpine.js Β· Tailwind | / |
| v2 | React 19 Β· TypeScript Β· Vite Β· Zustand Β· Tailwind | /v2/ |
A toggle in each taskbar flips between the two builds so visitors (and recruiters) can compare the same product written two different ways.
Portfolios that link to other portfolios are forgettable. This one is the work:
- A working desktop OS β taskbar, start menu, quick settings, snapping, dragβresize, context menus, recycle bin with restore, file explorer, lock screen, telemetry pipeline, admin dashboard.
- 25 windowed apps in v2 (calculator, paint, photos, video, notepad, word, excel, powerpoint, outlook, edge, vscode, terminal, explorer, photoshop, fl studio, docker, putty, filezilla, ssmsβstyle SQL viewer, task manager, event viewer, settings, pdf reader, admin console). v1 ships 23 of the same partials.
- The whole thing is intentionally overβengineered: SPA routing, lazyβloaded codeβsplit bundles, a typed state store, an admin OAuth flow, a Chart.js analytics dashboard, and a CI/CD pipeline that builds both tracks and rsyncs them to Hostinger atomically.
Frontend (v2) β React 19.2 Β· TypeScript 6 Β· Vite 8 Β· React Router 6 Β· Tailwind 3.4 Β· Zustand 5 with persist Β· Chart.js 4 / reactβchartjsβ2
Frontend (v1) β Alpine.js Β· HTMX Β· Tailwind 3 Β· vanilla JS modules
Backend β PHP 8 (no framework) Β· MySQL Β· PDO with prepared statements Β· HMACβSHA256 session cookies (no JWT lib) Β· Google OAuth (Identity Services + tokeninfo verification)
Tooling β Vitest 4 (118 unit tests) Β· Playwright 1 (e2e) Β· concurrently for dualβserver dev Β· actionlint for CI Β· git-filter-repo for history hygiene
Infra / CI β GitHub Actions β rsync over SSH to Hostinger Β· atomic releases with snapshot for oneβclick rollback Β· .htaccess rewrites for SPA + PHP coexistence
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β devante.johnson-rose.co.uk β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βββββββββββββββ΄ββββββββββββββ
β Apache + .htaccess β
βββββββββββββββ¬ββββββββββββββ
β
βββββββββββββ / βββββββββ΄ββββββββ /v2/ βββββββββ
βΌ βΌ
ββββββββββββββββββ ββββββββββββββββββββ
β v1 β PHP/Alp β β v2 β React SPA β
β index.php β β /v2/dist/... β
β partials/* β β static + index β
β Alpine store β β Zustand store β
ββββββββββ¬ββββββββ ββββββββββ¬ββββββββββ
β β
ββββββββββββββ¬βββββββββββββββββββββββββββββββ
βΌ
ββββββββββββββββββββ
β /api/*.php β log Β· stats Β· admin_auth Β·
β β admin_logout Β· admin_me Β·
β shared API β database_query Β· send_email Β·
β β news Β· weather Β· app
ββββββββββ¬ββββββββββ
βΌ
βββββββββββββ
β MySQL β event_logs Β· email_logs
βββββββββββββ
Both tracks talk to the same PHP API. The React build is just a static SPA mounted at /v2/; it has no privileged knowledge the PHP build doesn't have, which kept porting honest.
The admin dashboard is gated by Google OAuth. To avoid pulling a JWT dep, I sign a tiny JSON payload with hash_hmac('sha256', ...) and store it in a HttpOnly, SameSite=Lax, Secure cookie. Verification is constantβtime via hash_equals. (bootstrap.php:49-103)
function issueAdminSession($email, $secret, $ttlSeconds = 86400) {
$payload = json_encode(['email' => $email, 'exp' => time() + $ttlSeconds]);
$encoded = b64UrlEncode($payload);
$sig = b64UrlEncode(hash_hmac('sha256', $encoded, $secret, true));
setcookie(adminCookieName(), $encoded . '.' . $sig, [
'expires' => time() + $ttlSeconds,
'path' => '/',
'httponly' => true,
'samesite' => 'Lax',
'secure' => !in_array(strtok($_SERVER['HTTP_HOST'] ?? '', ':'), ['localhost', '127.0.0.1'], true),
]);
}
function requireAdmin($config) {
$email = currentAdminEmail($config['admin_session_secret'] ?? '');
if (!$email || !in_array($email, $config['admin_emails'] ?? [], true)) {
http_response_code(401);
header('Content-Type: application/json');
echo json_encode(['error' => 'Admin auth required']);
exit;
}
return $email;
}Google id_tokens are verified serverβside via Google's tokeninfo endpoint, with explicit checks on aud, iss, email_verified, and an email allowlist β no clientβtrusted claims. (api/admin_auth.php)
The SSMSβstyled app lets visitors read a few demo tables. The first version naively eval'd the SELECT β a textbook injection footgun. The hardened version cookieβgates the endpoint, extracts the requested table name from the inbound query, allowlists it, and only ever runs a parameterβfree SELECT * FROM \
requireAdmin($config);
$allowedTables = ['projects', 'experience', 'certifications', 'email_logs', 'event_logs'];
if (preg_match('/\[([a-zA-Z0-9_]+)\](?:\s*$|\s*;?\s*$)/', $query, $m)) {
$table = strtolower($m[1]);
} elseif (preg_match('/from\s+`?([a-zA-Z0-9_]+)`?/i', $query, $m)) {
$table = strtolower($m[1]);
}
if (!$table || !in_array($table, $allowedTables, true)) {
http_response_code(400);
echo json_encode(['error' => 'Only SELECT queries against an allowlisted table are permitted.']);
exit;
}
$stmt = $pdo->query("SELECT * FROM `{$table}` ORDER BY 1 DESC LIMIT 500");Error responses are intentionally generic; details only land in error_log when APP_DEBUG=true. The same pattern is applied to api/send_email.php.
Every windowed app is registered once and lazyβloaded via React.lazy + Suspense, so the initial JS payload is just the shell β apps stream in as visitors open them. (v2/src/apps/registry.ts)
const FLStudio = lazy(() => import('./flstudio/FLStudio'))
const AdminConsole = lazy(() => import('./admin/AdminConsole'))
export const apps: Record<string, AppDef> = {
flstudio: { id: 'flstudio', title: 'FL Studio 24', icon: `${IMG}fl%20studio.webp`,
defaultSize: { w: 1200, h: 720 }, Component: FLStudio },
admin: { id: 'admin', title: 'Admin Console', icon: `${IMG}mssql.webp`,
defaultSize: { w: 1180, h: 760 }, Component: AdminConsole },
// β¦23 more
}A production build yields one small index-*.js for the shell plus one chunk per app β the biggest (Admin Console with Chart.js) only loads when an authorised admin opens it.
A single typed store drives both the shell and every app. Settings, windowing state, the recycle bin, telemetry session id, mobile/tablet breakpoint, and the context menu all live in the same predictable place; persist mirrors the slices that should survive a refresh into localStorage. (v2/src/store/osStore.ts)
npm run dev boots an Apacheβshaped PHP server and Vite in parallel, with a tiny PHP front controller that routes /v2/* traffic to Vite. That way the SPA hotβreloads against the real PHP API on localhost:8765 with no proxy gymnastics. (dev-server.php)
In production, Apache reverses the role β .htaccess rewrites /v2/* straight onto v2/dist/, and falls back to v2/dist/index.html for clientβside routes:
RewriteCond %{DOCUMENT_ROOT}/v2/dist/$1 -f [OR]
RewriteCond %{DOCUMENT_ROOT}/v2/dist/$1 -d
RewriteRule ^v2/(.+)$ v2/dist/$1 [L]
RewriteRule ^v2(/.*)?$ v2/dist/index.html [L]A single GitHub Actions workflow handles both tracks (.github/workflows/deploy.yml):
Push to main
β
βΌ
ββββββββββββββββββββββββββββββββββββββββββββββββββββ
β 1. Read deploy-config.json β
β 2. Autoβdetect project type (node-build / php) β
β 3. Setup Node (only if v2/package.json exists) β
β 4. v2: npm ci β npm run build β v2/dist β
β 5. SSH into Hostinger, snapshot current release β
β 6. rsync repo β release dir (excludes secrets β
β and devβonly files via deploy-config.json) β
β 7. Healthβcheck the live URL β
β 8. Keep last N releases for rollback β
ββββββββββββββββββββββββββββββββββββββββββββββββββββ
Secrets live in GitHub Actions Secrets only β no .env is ever rsync'd. A separate rollback workflow flips the live directory back to a previous snapshot in seconds.
npm run testβ 118 Vitest specs across 10 files cover the store, the windowing hooks (drag/resize), the calculator engine, the paint floodβfill, the terminal commands, the filesystem model, the clock hook, and the app registry contract.npm run e2eβ Playwright drives the shell endβtoβend (boot β login β open apps β minimise/restore).actionlintβ every CI workflow is linted on push.
Test Files 10 passed (10)
Tests 118 passed (118)
# 1. PHP + MySQL prerequisites (or XAMPP/Laragon/MAMP)
cp .env.example .env # fill in any keys you have
# 2. v1 (PHP/Alpine) only
php -S localhost:8000
# 3. v2 (React) β boots both tiers
cd v2
npm install
npm run dev # PHP at :8765, Vite at :5173, v2 mounted under /v2/The site degrades gracefully if optional services (Google APIs, OAuth, SMTP) aren't configured β features that need them simply hide themselves.
.
βββ api/ # JSON endpoints (admin auth, stats, logs, contact)
βββ assets/ # Images, CSS, vanilla JS modules used by v1
βββ partials/ # v1 PHP partials (shell, taskbar, start-menu, apps/*)
βββ data/portfolio.json # Single source of truth for CV-derived settings
βββ v2/ # Parallel React build (Vite-based)
β βββ src/apps/ # 25 windowed apps, each its own module + tests
β βββ src/shell/ # Taskbar, StartMenu, ContextMenu, etc.
β βββ src/store/osStore.ts # Zustand store
β βββ src/windowing/ # Drag + resize hooks (tested)
β βββ e2e/ # Playwright specs
βββ .github/workflows/ # deploy.yml + rollback.yml
βββ .htaccess # SPA + PHP rewrites, deny rules for sensitive files
βββ bootstrap.php # Helpers: session, paths, admin guard
βββ config.php # .env loader + structured config
βββ dev-server.php # Front controller used by `php -S` in dev
βββ index.php # v1 entry point
- Dev URL decoding β
dev-server.phprawurldecodes the request path before existence checks; without itfl%20studio.webpproduced a phantom 404 on the bundled icon. - Snap targets vs Win11 chrome β window resize uses an 8βdirectional invisible border (~6β8 px) and adaptive cursor capture, integrated with the snapβpreview overlay. This is the single subtlest piece of the UI and lives in
v2/src/windowing/useResize.tswith its own test suite. - Recycle bin restore β items seeded into the static filesystem can be sent to the bin and restored back to their original path. The store keeps a
restoredSeedsset so seeded items survive a refresh. - CV stays private β the printable CV is hosted only on the production server. It is gitignored and is not part of the public repo.
MIT. Use as much or as little of this as you like β it's here to be read.
DeVantΓ© Johnson-Rose Β· Applications Support Engineer & Full-Stack Developer https://devante.johnson-rose.co.uk