Skip to content

Commit 439fd48

Browse files
committed
feat: live cursors + announcement banner, stricter Leave a Word, faster splash
1 parent fe3fe0f commit 439fd48

10 files changed

Lines changed: 782 additions & 11 deletions

File tree

CLAUDE.md

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
# CLAUDE.md
2+
3+
Guidance for Claude Code when working in this repository. Read this first — it
4+
is meant to save you from re-exploring the codebase on every session.
5+
6+
## What this is
7+
8+
Personal portfolio + playground website for **bbastian.dev** (owner: Bastian,
9+
GitHub `bastian-js`, developer/student, Austria). A React SPA frontend plus a
10+
small Express API. The site hosts the portfolio, some mini-apps (PiggyTrack,
11+
DropNote, ProPerform), landing pages for side projects (Noury), Spotify stats
12+
pages, hidden easter-egg games, and interactive features like **Leave a Word**.
13+
14+
- **Live frontend:** https://bbastian.dev
15+
- **Live API:** https://api.bbastian.dev (referenced in code as `const API = "https://api.bbastian.dev"`)
16+
- Design tone: dark, minimal, "developer aesthetic", emerald accent.
17+
18+
## Tech stack
19+
20+
**Frontend** (repo root)
21+
- React 19 + TypeScript, Vite 7, React Router v7 (`react-router-dom`)
22+
- Tailwind CSS v4 (via `@tailwindcss/vite`) — utility classes **plus** heavy use of inline `style={{}}` objects for colors/borders/animations
23+
- `lucide-react` for icons, `chart.js` + `react-chartjs-2` for charts, `canvas-confetti`, `react-snowfall`
24+
- Dev server runs on Vite (port 5173). Preview build on 3030.
25+
26+
**Backend** (`server/`)
27+
- Node ESM (`"type": "module"`), Express 4, `mysql2/promise` connection pool
28+
- `zod` for input validation, `express-rate-limit` for rate limiting
29+
- `resend` + `nodemailer` for the contact form, Spotify Web API integration
30+
- Runs on `process.env.PORT`. DB config via `DB_HOST/DB_USER/DB_PASS/DB_NAME/DB_PORT` (see `server/.env.example`).
31+
32+
## Commands
33+
34+
Frontend (from repo root):
35+
- `npm run dev` — Vite dev server
36+
- `npm run build``tsc -b && vite build` (typecheck THEN build)
37+
- `npm run lint` — ESLint
38+
- `npx tsc -b` — typecheck only (fast; use this to verify TS changes)
39+
40+
Backend (from `server/`):
41+
- `node index.js` — start the API (needs `server/.env`)
42+
- No test suite exists in either package.
43+
44+
There are **no automated tests**. Verify frontend changes with `npx tsc -b`
45+
(and `npm run lint` for anything non-trivial). Verify server changes by reading
46+
the affected route carefully — there is no test harness.
47+
48+
## Project layout
49+
50+
```
51+
/ Frontend (Vite root)
52+
index.html
53+
src/
54+
App.tsx Router shell + global keyboard shortcuts + easter eggs
55+
routes.tsx Central route table — add new pages here
56+
main.tsx Entry
57+
globals.css / index.css
58+
components/ Shared UI (NavBar, Footer, FadeIn, games, overlays, AnnouncementBanner, SplashScreen…)
59+
pages/ One file per route (Home, Projects, About, Contact, Now, LeaveAWord, PiggyTrack…)
60+
noury/ Noury side-project landing + legal pages
61+
spotify/ Spotify stats / hall-of-fame / OAuth callback pages
62+
public/
63+
dist/ Build output (committed)
64+
server/
65+
index.js ALL API routes live here (one big file, ~1050 lines)
66+
db.js mysql2 pool export (`db`)
67+
bad-words.json Banned-words list ({ "words": [...] }) for content filtering
68+
get-spotify-token.mjs One-off script to mint a Spotify refresh token
69+
.env / .env.example
70+
```
71+
72+
## Conventions & patterns (match these)
73+
74+
- **Styling:** Tailwind utilities for layout; inline `style={{}}` for colors,
75+
borders, and hover transitions. Hover effects are done with
76+
`onMouseEnter`/`onMouseLeave` mutating `e.currentTarget.style`, not CSS
77+
`:hover`. Follow the existing component you're editing.
78+
- **Color palette:** background `#0a0a0a` / cards `#111`; emerald accent
79+
`#34d399` (tints used a lot: `#34d39918`, `#34d39930`); text white with
80+
`rgba(255,255,255,α)` for muted greys; hairline borders
81+
`1px solid rgba(255,255,255,0.05–0.09)`; cards are `rounded-xl`.
82+
- **Fonts:** Inter for UI, monospace (`Courier New`) for accents/labels/counters.
83+
- **Animations:** `FadeIn` component wraps content with a `delay` prop
84+
(`delay={index * 40}`) for staggered entrance. Keyframes are injected via
85+
inline `<style>` tags inside components (see Footer, SplashScreen).
86+
- **Adding a page:** create `src/pages/Foo.tsx`, import + register it in
87+
`src/routes.tsx`. If it should appear in nav, add to `NAV_LINKS` in
88+
`src/components/Footer.tsx` (and NavBar if relevant).
89+
- **Keyboard shortcuts & easter eggs** live in `App.tsx`: single-key nav
90+
(h/p/s/a/c/n/l/g), Ctrl/Cmd+K & `/` search, `?` shortcuts overlay, Konami
91+
code → hacker terminal, typed `mine`/`snake`/`matrix` → games.
92+
- **localStorage/sessionStorage:** always wrap access in a try/catch helper
93+
(private-mode / quota safety). See `safeLocalStorage` in `LeaveAWord.tsx` and
94+
`AnnouncementBanner.tsx`, and `SESSION_KEY` guard in `SplashScreen.tsx`.
95+
- **Windows environment:** default shell is PowerShell; a Bash tool is also
96+
available. Prefer forward-slash paths.
97+
98+
## Server / API notes
99+
100+
Base URL `https://api.bbastian.dev`. All routes are defined in `server/index.js`.
101+
CORS allows `bbastian.dev` + any `*.bbastian.dev` subdomain + localhost. Key
102+
endpoints:
103+
104+
- `GET /github-stats` — cached GitHub profile/repo/language stats
105+
- Spotify: `GET /spotify/{auth,exchange,now-playing,top-tracks,hall-of-fame,artist-hall-of-fame}`
106+
- `POST /contact` (rate-limited) — sends email via Resend/nodemailer, zod-validated
107+
- `POST /noury/waitlist`, `GET /noury/waitlist/count`
108+
- Leaderboards: `GET|POST /leaderboard/{minesweeper,snake}` (POST rate-limited)
109+
- **Leave a Word:** `POST /leave-a-word` (rate-limited, cookie `visitor_id`),
110+
`GET /leave-a-word/all?page=&sort=newest|oldest`
111+
- Visitors: `POST /visitors/ping`, `GET /visitors/count`
112+
- **Live cursors (WebSocket):** `wss://api.bbastian.dev/cursors`. Client sends
113+
`{x,y}` (normalized 0–1); server assigns a random color per connection
114+
and broadcasts `{type:"state",cursors:[{id,x,y,color}]}` at ~20fps
115+
(each client gets everyone *except* itself) plus `{type:"leave",id}`. The
116+
Express `app` is wrapped in an `http.createServer` so `ws` can share the port
117+
(`server.listen`, upgrade handler filters path `/cursors` + origin).
118+
119+
**Content filtering:** `normalizeWord()` folds leetspeak, German umlauts,
120+
spacing and accents down to `[a-z0-9]` before comparing against
121+
`bad-words.json`. `isBannedWord()` uses it for both submitted words and names.
122+
123+
**Leave a Word validation rules** (`validateSubmittedWord` + `leaveAWordNameSchema`):
124+
- Word: 2–51 chars, **exactly one word** (no whitespace), not banned.
125+
- Name: 2–30 chars, no leading/trailing space, no line breaks/tabs, **≤ 3
126+
words** (a name, not a sentence), not banned.
127+
- The frontend (`src/pages/LeaveAWord.tsx`) mirrors these: name input
128+
`maxLength=30`, word input strips whitespace on change (`maxLength=51`).
129+
130+
## Features added / gotchas
131+
132+
- **AnnouncementBanner** (`src/components/AnnouncementBanner.tsx`, rendered in
133+
`App.tsx`): bottom-**right** "cookie-style" nudge for Leave a Word. Stores no
134+
cookie — dismissal is persisted in `localStorage["law_banner_dismissed"]="1"`
135+
(permanent). Hidden on the `/leave-a-word` page itself. Slide-in/out via
136+
transform+opacity.
137+
- **SplashScreen** (`src/components/SplashScreen.tsx`): split-panel intro shown
138+
once per session (`sessionStorage["bbastian_splash_shown"]`). A module-level
139+
`_started` guard prevents React StrictMode double-init. Timers intentionally
140+
have no cleanup so they survive StrictMode remount. Total run ≈ 1.65s.
141+
- **LiveCursors** (`src/components/LiveCursors.tsx`, mounted in `App.tsx`):
142+
Figma-style multiplayer cursors on a click-through `<canvas>`. Two config
143+
consts at the top of the file: `TEST` (bool) and `TEST_CURSOR_COUNT` (0–1000).
144+
`TEST=true` simulates wandering fake cursors (no backend). `TEST=false`
145+
connects to the `/cursors` WebSocket — colors come from the server, the
146+
client only broadcasts its own `{x,y}`. Colored arrow only, no name labels.
147+
Canvas-rendered so it scales to 1000.
148+
- `dist/` is committed — rebuild it if the frontend needs to be deployed from
149+
the repo.
150+
151+
## Working agreements
152+
153+
- Commit or push only when explicitly asked. Default branch is `main`.
154+
- The owner communicates in German; code, comments, and identifiers stay in
155+
English (existing inline comments are a mix of German/English — match the file
156+
you're editing).
157+
- Keep this file up to date when you add features, routes, or conventions so
158+
future sessions stay cheap.

server/index.js

Lines changed: 149 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import rateLimit from "express-rate-limit";
66
import nodemailer from "nodemailer";
77
import { z } from "zod";
88
import { randomUUID } from "crypto";
9+
import http from "http";
10+
import { WebSocketServer } from "ws";
911
import { db } from "./db.js";
1012

1113
import bannedWords from "./bad-words.json" with { type: "json" };
@@ -851,6 +853,14 @@ function validateSubmittedWord(input) {
851853
};
852854
}
853855

856+
// Enforce a *single* word — no spaces, tabs or line breaks allowed.
857+
if (/\s/.test(originalWord)) {
858+
return {
859+
valid: false,
860+
reason: "Please enter only a single word.",
861+
};
862+
}
863+
854864
if (isBannedWord(originalWord)) {
855865
return {
856866
valid: false,
@@ -879,8 +889,16 @@ const leaveAWordLimiter = rateLimit({
879889
const leaveAWordNameSchema = z
880890
.string()
881891
.min(2, "Name must be at least 2 characters.")
882-
.max(50, "Name is too long.")
883-
.regex(/^\S.*\S$|^\S$/, "Name can't start or end with a space.");
892+
.max(30, "Name is too long.")
893+
.regex(/^\S.*\S$|^\S$/, "Name can't start or end with a space.")
894+
// No line breaks or tabs — keeps it to a name, not a pasted block of text.
895+
.refine((v) => !/[\n\r\t]/.test(v), {
896+
message: "Name contains invalid characters.",
897+
})
898+
// A name, not a sentence: at most 3 whitespace-separated parts.
899+
.refine((v) => v.split(/\s+/).filter(Boolean).length <= 3, {
900+
message: "Please enter a name, not a sentence.",
901+
});
884902

885903
app.post("/leave-a-word", leaveAWordLimiter, async (req, res) => {
886904
const cookies = parseCookies(req);
@@ -1027,6 +1045,134 @@ app.get("/visitors/count", async (req, res) => {
10271045
}
10281046
});
10291047

1030-
app.listen(process.env.PORT, () => {
1048+
/* ─────────────────────────────────────────────────────────────────────
1049+
* Live cursors — WebSocket hub at /cursors
1050+
*
1051+
* Protocol:
1052+
* Client → Server: { x, y } (normalized 0–1)
1053+
* Server → Client: { type: "state", cursors: [{ id, x, y, color }] }
1054+
* { type: "leave", id }
1055+
*
1056+
* The server assigns each connection a random color, so clients only ever
1057+
* send their position — the identity comes from here.
1058+
* ───────────────────────────────────────────────────────────────────── */
1059+
const CURSOR_COLORS = [
1060+
"#34d399", "#60a5fa", "#f472b6", "#a78bfa", "#fb923c",
1061+
"#facc15", "#38bdf8", "#4ade80", "#f87171", "#c084fc",
1062+
];
1063+
const CURSOR_MAX_CLIENTS = 200; // abuse guard
1064+
const CURSOR_BROADCAST_MS = 50; // ~20 fps state broadcast
1065+
const CURSOR_HEARTBEAT_MS = 30000;
1066+
1067+
const cursorOriginAllowed = (origin) => {
1068+
if (!origin) return false;
1069+
if (allowedOrigins.includes(origin)) return true;
1070+
return /^https:\/\/([\w-]+\.)*bbastian\.dev$/.test(origin);
1071+
};
1072+
1073+
const server = http.createServer(app);
1074+
const cursorWss = new WebSocketServer({ noServer: true });
1075+
1076+
// State per connection: id → { x, y, name, color }
1077+
const cursors = new Map();
1078+
const cursorSockets = new Map(); // id → ws
1079+
1080+
server.on("upgrade", (req, socket, head) => {
1081+
let pathname;
1082+
try {
1083+
pathname = new URL(req.url, "http://localhost").pathname;
1084+
} catch {
1085+
socket.destroy();
1086+
return;
1087+
}
1088+
if (pathname !== "/cursors") {
1089+
socket.destroy();
1090+
return;
1091+
}
1092+
if (!cursorOriginAllowed(req.headers.origin)) {
1093+
socket.destroy();
1094+
return;
1095+
}
1096+
cursorWss.handleUpgrade(req, socket, head, (ws) => {
1097+
cursorWss.emit("connection", ws, req);
1098+
});
1099+
});
1100+
1101+
cursorWss.on("connection", (ws) => {
1102+
if (cursorSockets.size >= CURSOR_MAX_CLIENTS) {
1103+
ws.close();
1104+
return;
1105+
}
1106+
1107+
const id = randomUUID();
1108+
ws.isAlive = true;
1109+
cursors.set(id, {
1110+
x: 0.5,
1111+
y: 0.5,
1112+
color: CURSOR_COLORS[(Math.random() * CURSOR_COLORS.length) | 0],
1113+
});
1114+
cursorSockets.set(id, ws);
1115+
1116+
ws.on("pong", () => {
1117+
ws.isAlive = true;
1118+
});
1119+
1120+
ws.on("message", (buf) => {
1121+
let msg;
1122+
try {
1123+
msg = JSON.parse(buf.toString());
1124+
} catch {
1125+
return;
1126+
}
1127+
const c = cursors.get(id);
1128+
if (!c) return;
1129+
if (typeof msg.x === "number" && typeof msg.y === "number") {
1130+
c.x = Math.min(1, Math.max(0, msg.x));
1131+
c.y = Math.min(1, Math.max(0, msg.y));
1132+
}
1133+
});
1134+
1135+
const cleanup = () => {
1136+
cursors.delete(id);
1137+
cursorSockets.delete(id);
1138+
const payload = JSON.stringify({ type: "leave", id });
1139+
for (const s of cursorSockets.values())
1140+
if (s.readyState === 1) s.send(payload);
1141+
};
1142+
ws.on("close", cleanup);
1143+
ws.on("error", cleanup);
1144+
});
1145+
1146+
// Broadcast everyone's positions — each client gets everyone *except* itself.
1147+
setInterval(() => {
1148+
if (cursorSockets.size === 0) return;
1149+
for (const [id, ws] of cursorSockets) {
1150+
if (ws.readyState !== 1) continue;
1151+
const others = [];
1152+
for (const [oid, c] of cursors) {
1153+
if (oid === id) continue;
1154+
others.push({ id: oid, x: c.x, y: c.y, color: c.color });
1155+
}
1156+
ws.send(JSON.stringify({ type: "state", cursors: others }));
1157+
}
1158+
}, CURSOR_BROADCAST_MS);
1159+
1160+
// Drop dead connections
1161+
setInterval(() => {
1162+
for (const ws of cursorSockets.values()) {
1163+
if (ws.isAlive === false) {
1164+
ws.terminate();
1165+
continue;
1166+
}
1167+
ws.isAlive = false;
1168+
try {
1169+
ws.ping();
1170+
} catch {
1171+
/* ignore */
1172+
}
1173+
}
1174+
}, CURSOR_HEARTBEAT_MS);
1175+
1176+
server.listen(process.env.PORT, () => {
10311177
console.log(`Server läuft auf Port ${process.env.PORT}`);
10321178
});

server/package-lock.json

Lines changed: 22 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

server/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
"nodemailer": "^7.0.13",
1212
"open": "^11.0.0",
1313
"resend": "^6.6.0",
14+
"ws": "^8.21.0",
1415
"zod": "^4.2.1"
1516
}
1617
}

src/App.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import SpotlightSearch from "./components/SpotlightSearch";
1010
import MatrixRain from "./components/MatrixRain";
1111
import Snake from "./components/Snake";
1212
import SplashScreen from "./components/SplashScreen";
13+
import AnnouncementBanner from "./components/AnnouncementBanner";
14+
import LiveCursors from "./components/LiveCursors";
1315
import NotFound from "./pages/NotFound";
1416

1517
const KONAMI = [
@@ -166,6 +168,8 @@ function App() {
166168
</div>
167169

168170
<SplashScreen />
171+
<LiveCursors />
172+
<AnnouncementBanner />
169173
{minesweeper && <Minesweeper onClose={() => setMinesweeper(false)} />}
170174
{hacker && <HackerTerminal onClose={() => setHacker(false)} />}
171175
{shortcuts && <ShortcutsOverlay onClose={() => setShortcuts(false)} />}

0 commit comments

Comments
 (0)