Initial commit
@@ -0,0 +1,8 @@
|
||||
node_modules/
|
||||
client/node_modules/
|
||||
client/build/
|
||||
server/monkey.db
|
||||
server/.env
|
||||
.git/
|
||||
.DS_Store
|
||||
*.log
|
||||
@@ -0,0 +1,2 @@
|
||||
# Auto detect text files and perform LF normalization
|
||||
* text=auto
|
||||
@@ -0,0 +1,31 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
client/node_modules/
|
||||
|
||||
# Database
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
|
||||
# Build outputs
|
||||
client/build/
|
||||
dist/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
package-lock.json
|
||||
/client/node_modules
|
||||
/client/node_modules/.cache
|
||||
@@ -0,0 +1,17 @@
|
||||
# Stage 1: Build the React client
|
||||
FROM node:20-alpine AS builder
|
||||
WORKDIR /app/client
|
||||
COPY client/package*.json ./
|
||||
RUN npm install
|
||||
COPY client/ ./
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2: Run the server
|
||||
FROM node:20-alpine
|
||||
WORKDIR /app/server
|
||||
COPY server/package*.json ./
|
||||
RUN npm install
|
||||
COPY server/ ./
|
||||
COPY --from=builder /app/client/build ./public
|
||||
EXPOSE 3000
|
||||
CMD ["node", "server.js"]
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "monkey-typewriter-client",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.20.0",
|
||||
"react-scripts": "5.0.1",
|
||||
"cross-env": "^7.0.3"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "cross-env PORT=3001 react-scripts start",
|
||||
"build": "react-scripts build"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
"react-app"
|
||||
]
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
},
|
||||
"proxy": "http://localhost:3000"
|
||||
}
|
||||
|
After Width: | Height: | Size: 221 KiB |
|
After Width: | Height: | Size: 482 KiB |
|
After Width: | Height: | Size: 480 KiB |
|
After Width: | Height: | Size: 476 KiB |
|
After Width: | Height: | Size: 444 KiB |
|
After Width: | Height: | Size: 462 KiB |
|
After Width: | Height: | Size: 508 KiB |
|
After Width: | Height: | Size: 530 KiB |
|
After Width: | Height: | Size: 524 KiB |
|
After Width: | Height: | Size: 518 KiB |
|
After Width: | Height: | Size: 524 KiB |
|
After Width: | Height: | Size: 533 KiB |
|
After Width: | Height: | Size: 536 KiB |
|
After Width: | Height: | Size: 531 KiB |
|
After Width: | Height: | Size: 523 KiB |
|
After Width: | Height: | Size: 525 KiB |
|
After Width: | Height: | Size: 527 KiB |
|
After Width: | Height: | Size: 193 KiB |
|
After Width: | Height: | Size: 478 KiB |
|
After Width: | Height: | Size: 462 KiB |
|
After Width: | Height: | Size: 498 KiB |
|
After Width: | Height: | Size: 588 KiB |
|
After Width: | Height: | Size: 689 KiB |
|
After Width: | Height: | Size: 746 KiB |
@@ -0,0 +1,11 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Monkey Typewriter</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,134 @@
|
||||
@import url("https://fonts.googleapis.com/css2?family=Roboto:ital,wght@0,100..900;1,100..900&family=Special+Elite&display=swap");
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: "Special Elite", system-ui;
|
||||
background-color: #111;
|
||||
color: #f0f0f0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Monkey + typewriter — sits inline in the header, not fixed */
|
||||
.monkey-widget {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Wrapper handles the positional offset so the shake animation is clean */
|
||||
.monkey-widget .typewriter-wrapper {
|
||||
position: relative;
|
||||
transform: translate(18px, -8px);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.monkey-widget .typewriter-img {
|
||||
width: 150px;
|
||||
filter: grayscale(1);
|
||||
display: block;
|
||||
}
|
||||
|
||||
@keyframes typewriter-shake {
|
||||
0%, 100% { transform: rotate(0deg) translate(0, 0); }
|
||||
20% { transform: rotate(-4deg) translate(-3px, -1px); }
|
||||
50% { transform: rotate(4deg) translate(3px, 2px); }
|
||||
80% { transform: rotate(-2deg) translate(-2px, 0); }
|
||||
}
|
||||
|
||||
.monkey-widget .typewriter-img.shaking {
|
||||
animation: typewriter-shake 0.3s ease;
|
||||
}
|
||||
|
||||
.home-typewriter-shake {
|
||||
animation: typewriter-shake 0.5s ease;
|
||||
}
|
||||
|
||||
/* Flying letter spat out of the typewriter */
|
||||
@keyframes fly-out {
|
||||
0% { opacity: 1; transform: translate(-50%, -50%) translate(0px, 0px); }
|
||||
100% { opacity: 0; transform: translate(-50%, -50%) translate(var(--dx), var(--dy)); }
|
||||
}
|
||||
|
||||
.flying-letter {
|
||||
position: absolute;
|
||||
top: 35%;
|
||||
left: 50%;
|
||||
pointer-events: none;
|
||||
animation: fly-out 0.8s ease-out forwards;
|
||||
font-family: "Special Elite", system-ui;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
color: #f0f0f0;
|
||||
white-space: nowrap;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.monkey-widget .monkey-img {
|
||||
margin-left: -15px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Navbar desktop links */
|
||||
.navbar-links {
|
||||
display: flex;
|
||||
gap: 28px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Hamburger button — hidden on desktop */
|
||||
.navbar-hamburger {
|
||||
display: none;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #f0f0f0;
|
||||
font-size: 1.4em;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* Mobile dropdown menu */
|
||||
.navbar-mobile-menu {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
border-top: 1px solid #222;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.navbar-links {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.navbar-hamburger {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.navbar-mobile-menu {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.monkey-widget .typewriter-wrapper {
|
||||
transform: translate(10px, -5px);
|
||||
}
|
||||
|
||||
.monkey-widget .typewriter-img {
|
||||
width: 60px;
|
||||
}
|
||||
|
||||
.monkey-widget .monkey-img {
|
||||
width: 90px !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import React from 'react';
|
||||
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
|
||||
import Navbar from './components/Navbar';
|
||||
import Home from './pages/Home';
|
||||
import Type from './pages/Type';
|
||||
import Leaderboard from './pages/Leaderboard';
|
||||
import Credits from './pages/Credits';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<Router>
|
||||
<Navbar />
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/type" element={<Type />} />
|
||||
<Route path="/leaderboard" element={<Leaderboard />} />
|
||||
<Route path="/credits" element={<Credits />} />
|
||||
</Routes>
|
||||
</Router>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,60 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
|
||||
const ANIMATIONS = {
|
||||
idle: { frames: 10, speed: 80, loop: true, path: '/assets/Monkey/Idle', name: 'idle' },
|
||||
air: { frames: 5, speed: 120, loop: false, path: '/assets/Monkey/Air', name: 'air' },
|
||||
punch: { frames: 4, speed: 80, loop: false, path: '/assets/Monkey/Punch', name: 'punch'},
|
||||
};
|
||||
|
||||
// Preload all frames once so src swaps are instant (no flicker)
|
||||
Object.values(ANIMATIONS).forEach(({ path, name, frames }) => {
|
||||
for (let i = 0; i < frames; i++) {
|
||||
const img = new Image();
|
||||
img.src = `${path}/${name}${i}.png`;
|
||||
}
|
||||
});
|
||||
|
||||
const MonkeySprite = ({ animation = 'idle', onAnimationEnd, className, style: styleProp }) => {
|
||||
const [frame, setFrame] = useState(0);
|
||||
const currentAnim = ANIMATIONS[animation];
|
||||
|
||||
useEffect(() => {
|
||||
setFrame(0);
|
||||
let currentFrame = 0;
|
||||
const interval = setInterval(() => {
|
||||
currentFrame++;
|
||||
if (currentFrame >= currentAnim.frames) {
|
||||
if (currentAnim.loop) {
|
||||
currentFrame = 0;
|
||||
setFrame(0);
|
||||
} else {
|
||||
clearInterval(interval);
|
||||
if (onAnimationEnd) onAnimationEnd();
|
||||
}
|
||||
return;
|
||||
}
|
||||
setFrame(currentFrame);
|
||||
}, currentAnim.speed);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [animation, onAnimationEnd, currentAnim]);
|
||||
|
||||
const src = `${currentAnim.path}/${currentAnim.name}${frame}.png`;
|
||||
|
||||
return (
|
||||
<img
|
||||
src={src}
|
||||
alt=""
|
||||
className={className}
|
||||
style={{
|
||||
imageRendering: 'pixelated',
|
||||
width: '200px',
|
||||
height: 'auto',
|
||||
filter: 'grayscale(1)',
|
||||
...styleProp,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default MonkeySprite;
|
||||
@@ -0,0 +1,90 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
|
||||
function Navbar() {
|
||||
const { pathname } = useLocation();
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
|
||||
const linkStyle = (path) => ({
|
||||
color: pathname === path ? '#fff' : '#888',
|
||||
textDecoration: 'none',
|
||||
fontSize: '1em',
|
||||
letterSpacing: '0.05em',
|
||||
borderBottom: pathname === path ? '1px solid #fff' : '1px solid transparent',
|
||||
padding: '2px 0',
|
||||
lineHeight: '1.2',
|
||||
transition: 'color 0.15s',
|
||||
});
|
||||
|
||||
const mobileLinkStyle = (path) => ({
|
||||
color: pathname === path ? '#fff' : '#888',
|
||||
textDecoration: 'none',
|
||||
fontSize: '1.1em',
|
||||
letterSpacing: '0.05em',
|
||||
padding: '12px 24px',
|
||||
borderBottom: '1px solid #222',
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
boxSizing: 'border-box',
|
||||
transition: 'color 0.15s',
|
||||
});
|
||||
|
||||
const closeMenu = () => setMenuOpen(false);
|
||||
|
||||
return (
|
||||
<nav style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
backgroundColor: '#1a1a1a',
|
||||
borderBottom: '1px solid #333',
|
||||
zIndex: 1000,
|
||||
}}>
|
||||
{/* Main row */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '14px 24px',
|
||||
}}>
|
||||
<Link to="/" style={{ textDecoration: 'none', color: '#f0f0f0', fontSize: '1.2em', lineHeight: '1.2' }} onClick={closeMenu}>
|
||||
MonKEYS
|
||||
</Link>
|
||||
|
||||
{/* Desktop links */}
|
||||
<div className="navbar-links">
|
||||
<Link to="/" style={linkStyle('/')}>Home</Link>
|
||||
<Link to="/type" style={linkStyle('/type')}>Type</Link>
|
||||
<Link to="/leaderboard" style={linkStyle('/leaderboard')}>Leaderboard</Link>
|
||||
<Link to="/credits" style={linkStyle('/credits')}>Credits</Link>
|
||||
<a href="https://sistav.com" style={linkStyle('')}>← sistav.com</a>
|
||||
</div>
|
||||
|
||||
{/* Hamburger button (mobile only) */}
|
||||
<button
|
||||
className="navbar-hamburger"
|
||||
onClick={() => setMenuOpen((o) => !o)}
|
||||
aria-label="Toggle menu"
|
||||
>
|
||||
{menuOpen ? '✕' : '☰'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Mobile dropdown — inside <nav> so no top-offset needed */}
|
||||
{menuOpen && (
|
||||
<div className="navbar-mobile-menu">
|
||||
<Link to="/" style={mobileLinkStyle('/')} onClick={closeMenu}>Home</Link>
|
||||
<Link to="/type" style={mobileLinkStyle('/type')} onClick={closeMenu}>Type</Link>
|
||||
<Link to="/leaderboard" style={mobileLinkStyle('/leaderboard')} onClick={closeMenu}>Leaderboard</Link>
|
||||
<Link to="/credits" style={mobileLinkStyle('/credits')} onClick={closeMenu}>Credits</Link>
|
||||
<a href="https://sistav.com" style={{ ...mobileLinkStyle(''), color: '#888', borderBottom: 'none' }}>
|
||||
← sistav.com
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
export default Navbar;
|
||||
@@ -0,0 +1,11 @@
|
||||
// Client-side configuration
|
||||
|
||||
// In dev, the React app runs on 3001 and the server on 3000 — use explicit port.
|
||||
// In production (Docker), everything is served from the same origin — use window.location.host.
|
||||
export function getWsUrl() {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
return `${protocol}//${window.location.hostname}:3000`;
|
||||
}
|
||||
return `${protocol}//${window.location.host}`;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
|
||||
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||
root.render(<App />);
|
||||
@@ -0,0 +1,83 @@
|
||||
import React from "react";
|
||||
import MonkeySprite from "../components/MonkeySprite.js";
|
||||
import "../App.css";
|
||||
|
||||
function Credits() {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
minHeight: "100vh",
|
||||
paddingTop: "53px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
overflow: "hidden",
|
||||
gap: "0",
|
||||
}}
|
||||
>
|
||||
<div style={{ marginTop: "-20px" }}>
|
||||
<MonkeySprite animation="idle" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
textAlign: "center",
|
||||
padding: "0 20px",
|
||||
marginTop: "-70px",
|
||||
}}
|
||||
>
|
||||
<h1 style={{ fontSize: "3em", marginBottom: "40px" }}>Credits</h1>
|
||||
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "24px", alignItems: "center" }}>
|
||||
<p style={{ margin: 0, fontSize: "1.1em", color: "#ccc", lineHeight: "1.8" }}>
|
||||
Made with love <3 by{" "}
|
||||
<a
|
||||
href="https://oelbaytam.github.io"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
style={{ color: "#f0f0f0" }}
|
||||
>
|
||||
OmarEpic
|
||||
</a>{" "}
|
||||
&{" "}
|
||||
<a
|
||||
href="https://sistav.com"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
style={{ color: "#f0f0f0" }}
|
||||
>
|
||||
Sistav
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p style={{ margin: 0, fontSize: "1.1em", color: "#ccc", lineHeight: "1.8" }}>
|
||||
Amazing monkey sprites made by{" "}
|
||||
<a
|
||||
href="https://gtibo.itch.io/monkey-sprite"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
style={{ color: "#f0f0f0" }}
|
||||
>
|
||||
Tibo
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p style={{ margin: 0, fontSize: "1.1em", color: "#ccc", lineHeight: "1.8" }}>
|
||||
Typewriter image by{" "}
|
||||
<a
|
||||
href="https://commons.wikimedia.org/wiki/File:Olympia_typewriter_model_8_mechanism.jpg"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
style={{ color: "#f0f0f0" }}
|
||||
>
|
||||
Miloš Jurišić
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Credits;
|
||||
@@ -0,0 +1,95 @@
|
||||
import React, { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import MonkeySprite from "../components/MonkeySprite.js";
|
||||
import "../App.css";
|
||||
|
||||
function Home() {
|
||||
const [isFalling, setIsFalling] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleStartTyping = () => {
|
||||
setIsFalling(true);
|
||||
setTimeout(() => {
|
||||
navigate("/type");
|
||||
}, 1500);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
minHeight: "100vh",
|
||||
paddingTop: "53px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
overflow: "hidden",
|
||||
gap: "0",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
transform: isFalling ? "translateY(200vh)" : "translateY(0)",
|
||||
transition: "transform 3s ease-in",
|
||||
}}
|
||||
>
|
||||
<MonkeySprite animation={isFalling ? "air" : "idle"} />
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
textAlign: "center",
|
||||
padding: "0 20px",
|
||||
marginTop: "-70px",
|
||||
opacity: isFalling ? 0 : 1,
|
||||
pointerEvents: isFalling ? "none" : "auto",
|
||||
transition: "opacity 0.2s",
|
||||
}}
|
||||
>
|
||||
<h1 style={{ fontSize: "3em", marginBottom: "20px" }}>MonKEYS</h1>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: "20px",
|
||||
justifyContent: "center",
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={handleStartTyping}
|
||||
style={{
|
||||
padding: "15px 40px",
|
||||
fontSize: "1.2em",
|
||||
cursor: "pointer",
|
||||
backgroundColor: "#f0f0f0",
|
||||
color: "#111",
|
||||
border: "2px solid #f0f0f0",
|
||||
borderRadius: "8px",
|
||||
}}
|
||||
>
|
||||
Start Typing
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsFalling(true);
|
||||
setTimeout(() => navigate("/leaderboard"), 1500);
|
||||
}}
|
||||
style={{
|
||||
padding: "15px 40px",
|
||||
fontSize: "1.2em",
|
||||
cursor: "pointer",
|
||||
backgroundColor: "transparent",
|
||||
color: "#f0f0f0",
|
||||
border: "2px solid #f0f0f0",
|
||||
borderRadius: "8px",
|
||||
}}
|
||||
>
|
||||
Leaderboard
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Home;
|
||||
@@ -0,0 +1,200 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import MonkeySprite from "../components/MonkeySprite";
|
||||
import "../App.css";
|
||||
|
||||
function Leaderboard() {
|
||||
const [submissions, setSubmissions] = useState([]);
|
||||
const [message, setMessage] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
loadLeaderboard();
|
||||
}, []);
|
||||
|
||||
const loadLeaderboard = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/leaderboard");
|
||||
const data = await response.json();
|
||||
setSubmissions(data);
|
||||
} catch (error) {
|
||||
console.error("Error loading leaderboard:", error);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const upvote = async (id) => {
|
||||
try {
|
||||
const response = await fetch("/api/upvote", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ id }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
setMessage("✓ Vote recorded!");
|
||||
loadLeaderboard();
|
||||
setTimeout(() => setMessage(""), 3000);
|
||||
} else {
|
||||
setMessage("✗ " + (data.error || "Failed to vote"));
|
||||
}
|
||||
} catch (error) {
|
||||
setMessage("✗ Network error");
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (timestamp) => {
|
||||
const date = new Date(timestamp * 1000);
|
||||
return date.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
padding: "20px",
|
||||
paddingTop: "73px",
|
||||
maxWidth: "700px",
|
||||
margin: "0 auto",
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
marginBottom: "20px",
|
||||
gap: "16px",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<h1 style={{ margin: 0 }}>Leaderboard</h1>
|
||||
<button
|
||||
onClick={loadLeaderboard}
|
||||
style={{
|
||||
marginTop: "10px",
|
||||
padding: "6px 14px",
|
||||
fontSize: "14px",
|
||||
cursor: "pointer",
|
||||
backgroundColor: "transparent",
|
||||
color: "#f0f0f0",
|
||||
border: "2px solid #555",
|
||||
borderRadius: "4px",
|
||||
display: "block",
|
||||
}}
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<MonkeySprite animation="idle" />
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<div
|
||||
style={{
|
||||
marginBottom: "20px",
|
||||
padding: "10px",
|
||||
backgroundColor: "#1a1a1a",
|
||||
border: "1px solid #444",
|
||||
borderRadius: "4px",
|
||||
color: message.startsWith("✓") ? "#ccc" : "#888",
|
||||
}}
|
||||
>
|
||||
{message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
{submissions.length === 0 ? (
|
||||
<p style={{ textAlign: "center", padding: "40px", color: "#666" }}>
|
||||
No submissions yet. Be the first!
|
||||
</p>
|
||||
) : (
|
||||
submissions.map((sub, index) => (
|
||||
<div
|
||||
key={sub.id}
|
||||
style={{
|
||||
padding: "20px",
|
||||
marginBottom: "15px",
|
||||
borderRadius: "8px",
|
||||
backgroundColor: "#1a1a1a",
|
||||
border: `1px solid ${index < 3 ? "#555" : "#333"}`,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "20px",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "24px",
|
||||
minWidth: "40px",
|
||||
color:
|
||||
index === 0
|
||||
? "#fff"
|
||||
: index === 1
|
||||
? "#aaa"
|
||||
: index === 2
|
||||
? "#888"
|
||||
: "#555",
|
||||
}}
|
||||
>
|
||||
#{index + 1}
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1 }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "18px",
|
||||
marginBottom: "8px",
|
||||
wordBreak: "break-word",
|
||||
color: "#f0f0f0",
|
||||
}}
|
||||
>
|
||||
"{sub.phrase}"
|
||||
</div>
|
||||
<div style={{ fontSize: "12px", color: "#666" }}>
|
||||
{formatDate(sub.created_at)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
gap: "8px",
|
||||
minWidth: "80px",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={() => upvote(sub.id)}
|
||||
style={{
|
||||
padding: "8px 16px",
|
||||
fontSize: "16px",
|
||||
cursor: "pointer",
|
||||
backgroundColor: "#f0f0f0",
|
||||
color: "#111",
|
||||
border: "none",
|
||||
borderRadius: "4px",
|
||||
}}
|
||||
>
|
||||
+1
|
||||
</button>
|
||||
<div style={{ fontSize: "18px", color: "#ccc" }}>
|
||||
{sub.votes}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Leaderboard;
|
||||
@@ -0,0 +1,367 @@
|
||||
import React, { useState, useEffect, useRef, useCallback, useMemo } from "react";
|
||||
import MonkeySprite from "../components/MonkeySprite";
|
||||
import { getWsUrl } from "../config";
|
||||
import "../App.css";
|
||||
|
||||
function Type() {
|
||||
const [letters, setLetters] = useState([]);
|
||||
const [selectedIndices, setSelectedIndices] = useState([]);
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [message, setMessage] = useState("");
|
||||
const [monkeyAnimation, setMonkeyAnimation] = useState("idle");
|
||||
const [flyingLetters, setFlyingLetters] = useState([]);
|
||||
const selectedSet = useMemo(() => new Set(selectedIndices), [selectedIndices]);
|
||||
const isDraggingRef = useRef(false);
|
||||
const selectionStartRef = useRef(null);
|
||||
const letterBoxRef = useRef(null);
|
||||
const userScrolledRef = useRef(false);
|
||||
const typewriterRef = useRef(null);
|
||||
const flyingLetterIdRef = useRef(0);
|
||||
const pendingTimeoutsRef = useRef([]);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
let ws = null;
|
||||
|
||||
const connect = () => {
|
||||
if (!mounted) return;
|
||||
|
||||
ws = new WebSocket(getWsUrl());
|
||||
|
||||
ws.onopen = () => {
|
||||
if (!mounted) return;
|
||||
setConnected(true);
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
if (!mounted) return;
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
if (data.type === "init") {
|
||||
setLetters(data.letters);
|
||||
} else if (data.type === "letter") {
|
||||
setMonkeyAnimation("punch");
|
||||
|
||||
// Delay everything to when the fist lands (~frame 2-3)
|
||||
const letterAtImpact = data.letter;
|
||||
const timestampAtImpact = data.timestamp;
|
||||
const impactTimeout = setTimeout(() => {
|
||||
setLetters((prev) => [
|
||||
...prev,
|
||||
{ letter: letterAtImpact, timestamp: timestampAtImpact },
|
||||
]);
|
||||
|
||||
if (typewriterRef.current) {
|
||||
typewriterRef.current.classList.remove("shaking");
|
||||
void typewriterRef.current.offsetWidth;
|
||||
typewriterRef.current.classList.add("shaking");
|
||||
}
|
||||
|
||||
const id = flyingLetterIdRef.current++;
|
||||
const angle = Math.random() * Math.PI * 2;
|
||||
const distance = 50 + Math.random() * 60;
|
||||
const dx = Math.cos(angle) * distance;
|
||||
const dy = Math.sin(angle) * distance;
|
||||
setFlyingLetters((prev) => [
|
||||
...prev,
|
||||
{ id, letter: letterAtImpact, dx, dy },
|
||||
]);
|
||||
const cleanupTimeout = setTimeout(() => {
|
||||
setFlyingLetters((prev) => prev.filter((l) => l.id !== id));
|
||||
}, 900);
|
||||
pendingTimeoutsRef.current.push(cleanupTimeout);
|
||||
}, 180);
|
||||
pendingTimeoutsRef.current.push(impactTimeout);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
if (!mounted) return;
|
||||
setConnected(false);
|
||||
setTimeout(connect, 3000);
|
||||
};
|
||||
};
|
||||
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
if (ws) ws.close();
|
||||
pendingTimeoutsRef.current.forEach(clearTimeout);
|
||||
pendingTimeoutsRef.current = [];
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Auto-scroll to bottom when new letters arrive, unless user has scrolled up
|
||||
useEffect(() => {
|
||||
if (!userScrolledRef.current && letterBoxRef.current) {
|
||||
letterBoxRef.current.scrollTop = letterBoxRef.current.scrollHeight;
|
||||
}
|
||||
}, [letters.length]);
|
||||
|
||||
const handleLetterBoxScroll = useCallback(() => {
|
||||
const el = letterBoxRef.current;
|
||||
if (!el) return;
|
||||
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 30;
|
||||
userScrolledRef.current = !atBottom;
|
||||
}, []);
|
||||
|
||||
const handlePunchEnd = useCallback(() => {
|
||||
setMonkeyAnimation("idle");
|
||||
}, []);
|
||||
|
||||
const handleMouseDown = useCallback((index, e) => {
|
||||
e.preventDefault();
|
||||
isDraggingRef.current = true;
|
||||
selectionStartRef.current = index;
|
||||
setSelectedIndices([index]);
|
||||
}, []);
|
||||
|
||||
const handleMouseEnter = useCallback((index) => {
|
||||
if (!isDraggingRef.current || selectionStartRef.current === null) return;
|
||||
|
||||
const start = selectionStartRef.current;
|
||||
|
||||
if (index >= start) {
|
||||
const newSelection = [];
|
||||
for (let i = start; i <= index; i++) {
|
||||
newSelection.push(i);
|
||||
}
|
||||
setSelectedIndices(newSelection);
|
||||
} else {
|
||||
const newSelection = [];
|
||||
for (let i = index; i <= start; i++) {
|
||||
newSelection.push(i);
|
||||
}
|
||||
setSelectedIndices(newSelection);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleGlobalMouseUp = () => {
|
||||
isDraggingRef.current = false;
|
||||
};
|
||||
|
||||
document.addEventListener("mouseup", handleGlobalMouseUp);
|
||||
return () => {
|
||||
document.removeEventListener("mouseup", handleGlobalMouseUp);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const clearSelection = () => {
|
||||
setSelectedIndices([]);
|
||||
selectionStartRef.current = null;
|
||||
setMessage("");
|
||||
};
|
||||
|
||||
const submitPhrase = async () => {
|
||||
if (selectedIndices.length === 0) return;
|
||||
|
||||
const sortedIndices = [...selectedIndices].sort((a, b) => a - b);
|
||||
const selectedLetters = sortedIndices.map((i) => letters[i]);
|
||||
const phrase = selectedLetters.map((l) => l.letter).join("");
|
||||
const timestamps = selectedLetters.map((l) => l.timestamp);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/submit", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ phrase, timestamps }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
setMessage("✓ Submitted successfully!");
|
||||
clearSelection();
|
||||
setTimeout(() => setMessage(""), 3000);
|
||||
} else {
|
||||
setMessage("✗ " + (data.error || "Failed to submit"));
|
||||
}
|
||||
} catch (error) {
|
||||
setMessage("✗ Network error");
|
||||
}
|
||||
};
|
||||
|
||||
const selectedPhrase = useMemo(() =>
|
||||
selectedIndices.length > 0
|
||||
? [...selectedIndices]
|
||||
.sort((a, b) => a - b)
|
||||
.map((i) => letters[i]?.letter || "")
|
||||
.join("")
|
||||
: "",
|
||||
[selectedIndices, letters]);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
minHeight: "100vh",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
padding: "20px",
|
||||
paddingTop: "73px",
|
||||
width: "100%",
|
||||
maxWidth: "700px",
|
||||
margin: "0 auto",
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
{/* Header row: title + status + monkey all on one line */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
marginBottom: "20px",
|
||||
gap: "16px",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<h1 style={{ margin: 0 }}>Type</h1>
|
||||
<span style={{ color: connected ? "#4caf50" : "#666", fontSize: "0.9em", marginTop: "10px", display: "block" }}>
|
||||
{connected ? "● Connected" : "○ Disconnected"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="monkey-widget">
|
||||
<div className="typewriter-wrapper">
|
||||
<img
|
||||
ref={typewriterRef}
|
||||
src="/assets/Typewriter/typewriter.png"
|
||||
alt=""
|
||||
className="typewriter-img"
|
||||
/>
|
||||
{flyingLetters.map(({ id, letter, dx, dy }) => (
|
||||
<span
|
||||
key={id}
|
||||
className="flying-letter"
|
||||
style={{ "--dx": `${dx}px`, "--dy": `${dy}px` }}
|
||||
>
|
||||
{letter}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<MonkeySprite
|
||||
animation={monkeyAnimation}
|
||||
onAnimationEnd={handlePunchEnd}
|
||||
className="monkey-img"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Letter display area */}
|
||||
<div
|
||||
ref={letterBoxRef}
|
||||
onScroll={handleLetterBoxScroll}
|
||||
style={{
|
||||
border: "2px solid #444",
|
||||
padding: "20px",
|
||||
height: "300px",
|
||||
overflowY: "auto",
|
||||
overflowX: "hidden",
|
||||
userSelect: "none",
|
||||
backgroundColor: "#1a1a1a",
|
||||
color: "#f0f0f0",
|
||||
fontSize: "18px",
|
||||
lineHeight: "1.8",
|
||||
wordBreak: "break-all",
|
||||
}}
|
||||
>
|
||||
{letters.map((letter, index) => (
|
||||
<span
|
||||
key={`${index}-${letter.timestamp}`}
|
||||
onMouseDown={(e) => handleMouseDown(index, e)}
|
||||
onMouseEnter={() => handleMouseEnter(index)}
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
backgroundColor: selectedSet.has(index)
|
||||
? "#fff"
|
||||
: "transparent",
|
||||
color: selectedSet.has(index) ? "#111" : "#f0f0f0",
|
||||
padding: "2px 1px",
|
||||
display: "inline",
|
||||
transition: "background-color 0.1s",
|
||||
}}
|
||||
>
|
||||
{letter.letter}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Control Panel */}
|
||||
<div style={{ marginTop: "20px" }}>
|
||||
<div
|
||||
style={{
|
||||
padding: "15px",
|
||||
backgroundColor: "#1a1a1a",
|
||||
border: "1px solid #444",
|
||||
minHeight: "50px",
|
||||
marginBottom: "15px",
|
||||
fontSize: "16px",
|
||||
color: "#ccc",
|
||||
}}
|
||||
>
|
||||
<strong style={{ color: "#f0f0f0" }}>Selected:</strong>{" "}
|
||||
{selectedPhrase || "(drag to select letters)"}
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", gap: "10px", flexWrap: "wrap" }}>
|
||||
<button
|
||||
onClick={clearSelection}
|
||||
style={{
|
||||
padding: "10px 20px",
|
||||
fontSize: "16px",
|
||||
cursor: "pointer",
|
||||
backgroundColor: "transparent",
|
||||
color: "#f0f0f0",
|
||||
border: "2px solid #555",
|
||||
borderRadius: "4px",
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
<button
|
||||
onClick={submitPhrase}
|
||||
disabled={selectedIndices.length === 0}
|
||||
style={{
|
||||
padding: "10px 20px",
|
||||
fontSize: "16px",
|
||||
cursor:
|
||||
selectedIndices.length === 0 ? "not-allowed" : "pointer",
|
||||
backgroundColor:
|
||||
selectedIndices.length === 0 ? "#333" : "#f0f0f0",
|
||||
color: selectedIndices.length === 0 ? "#666" : "#111",
|
||||
border: "2px solid #555",
|
||||
borderRadius: "4px",
|
||||
opacity: selectedIndices.length === 0 ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
Submit to Leaderboard
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: "15px",
|
||||
padding: "10px",
|
||||
backgroundColor: "#1a1a1a",
|
||||
border: `1px solid ${message.startsWith("✓") ? "#555" : "#444"}`,
|
||||
borderRadius: "4px",
|
||||
color: message.startsWith("✓") ? "#ccc" : "#888",
|
||||
}}
|
||||
>
|
||||
{message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Type;
|
||||
@@ -0,0 +1,17 @@
|
||||
services:
|
||||
monkeys:
|
||||
build: .
|
||||
container_name: monkeys-app
|
||||
ports:
|
||||
- "3000:3000"
|
||||
volumes:
|
||||
- ./data:/app/server/data
|
||||
environment:
|
||||
- PORT=3000
|
||||
- DB_PATH=/app/server/data/monkey.db
|
||||
- LETTERS_CACHE_SIZE=1000
|
||||
- LETTER_GENERATION_INTERVAL=800
|
||||
- MAX_SUBMISSIONS_PER_HOUR=1
|
||||
- MAX_UPVOTES_PER_DAY=10
|
||||
- LOG_LETTERS=false
|
||||
restart: unless-stopped
|
||||
@@ -0,0 +1,20 @@
|
||||
# Server port
|
||||
PORT=3000
|
||||
|
||||
# How many letters to keep in the DB (older ones are pruned)
|
||||
LETTERS_CACHE_SIZE=1000
|
||||
|
||||
# How often a new letter is generated (milliseconds)
|
||||
LETTER_GENERATION_INTERVAL=800
|
||||
|
||||
# Max phrase submissions per IP per hour
|
||||
MAX_SUBMISSIONS_PER_HOUR=1
|
||||
|
||||
# Max upvotes per IP per day
|
||||
MAX_UPVOTES_PER_DAY=10
|
||||
|
||||
# Print each generated letter to the console (true/false)
|
||||
LOG_LETTERS=false
|
||||
|
||||
# Path to the SQLite database file (defaults to monkey.db next to server.js)
|
||||
# DB_PATH=/data/monkey.db
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "monkey-typewriter-server",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"dev": "nodemon server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.18.2",
|
||||
"ws": "^8.16.0",
|
||||
"dotenv": "^16.3.1",
|
||||
"sql.js": "^1.10.3",
|
||||
"cors": "^2.8.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.0.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
import 'dotenv/config';
|
||||
import express from 'express';
|
||||
import { createServer } from 'http';
|
||||
import { WebSocketServer } from 'ws';
|
||||
import initSqlJs from 'sql.js';
|
||||
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
||||
import { join, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
import cors from 'cors';
|
||||
|
||||
const app = express();
|
||||
const server = createServer(app);
|
||||
const wss = new WebSocketServer({ server });
|
||||
|
||||
// Configuration
|
||||
const PORT = process.env.PORT || 3000;
|
||||
const LETTERS_CACHE_SIZE = parseInt(process.env.LETTERS_CACHE_SIZE) || 1000;
|
||||
const LETTER_GENERATION_INTERVAL = parseInt(process.env.LETTER_GENERATION_INTERVAL) || 800;
|
||||
const MAX_SUBMISSIONS_PER_HOUR = parseInt(process.env.MAX_SUBMISSIONS_PER_HOUR) || 1;
|
||||
const MAX_UPVOTES_PER_DAY = parseInt(process.env.MAX_UPVOTES_PER_DAY) || 10;
|
||||
const LOG_LETTERS = process.env.LOG_LETTERS === 'true';
|
||||
const DB_PATH = process.env.DB_PATH || join(__dirname, 'monkey.db');
|
||||
|
||||
let db;
|
||||
|
||||
// Initialize SQLite database
|
||||
async function initDB() {
|
||||
const SQL = await initSqlJs();
|
||||
|
||||
if (existsSync(DB_PATH)) {
|
||||
const buffer = readFileSync(DB_PATH);
|
||||
db = new SQL.Database(buffer);
|
||||
} else {
|
||||
db = new SQL.Database();
|
||||
}
|
||||
|
||||
// Create tables
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS letters (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
letter TEXT NOT NULL,
|
||||
timestamp INTEGER NOT NULL,
|
||||
created_at INTEGER DEFAULT (strftime('%s', 'now'))
|
||||
);
|
||||
`);
|
||||
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS submissions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
phrase TEXT NOT NULL,
|
||||
timestamps TEXT NOT NULL,
|
||||
votes INTEGER DEFAULT 0,
|
||||
created_at INTEGER DEFAULT (strftime('%s', 'now'))
|
||||
);
|
||||
`);
|
||||
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS submission_votes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
submission_id INTEGER NOT NULL,
|
||||
ip_address TEXT NOT NULL,
|
||||
created_at INTEGER DEFAULT (strftime('%s', 'now')),
|
||||
UNIQUE(submission_id, ip_address)
|
||||
);
|
||||
`);
|
||||
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS submission_attempts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ip_address TEXT NOT NULL,
|
||||
created_at INTEGER DEFAULT (strftime('%s', 'now'))
|
||||
);
|
||||
`);
|
||||
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS upvote_attempts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ip_address TEXT NOT NULL,
|
||||
created_at INTEGER DEFAULT (strftime('%s', 'now'))
|
||||
);
|
||||
`);
|
||||
|
||||
// Indexes for rate-limit queries (ip_address + created_at lookups)
|
||||
db.run('CREATE INDEX IF NOT EXISTS idx_sub_attempts_ip ON submission_attempts (ip_address, created_at)');
|
||||
db.run('CREATE INDEX IF NOT EXISTS idx_upvote_attempts_ip ON upvote_attempts (ip_address, created_at)');
|
||||
db.run('CREATE INDEX IF NOT EXISTS idx_sub_votes_lookup ON submission_votes (submission_id, ip_address)');
|
||||
db.run('CREATE INDEX IF NOT EXISTS idx_letters_timestamp ON letters (timestamp)');
|
||||
|
||||
saveDB();
|
||||
}
|
||||
|
||||
function saveDB() {
|
||||
const data = db.export();
|
||||
const buffer = Buffer.from(data);
|
||||
writeFileSync(DB_PATH, buffer);
|
||||
}
|
||||
|
||||
// Middleware
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
app.set('trust proxy', 1);
|
||||
|
||||
function getClientIP(req) {
|
||||
return req.ip || req.socket.remoteAddress;
|
||||
}
|
||||
|
||||
// Generate random letter
|
||||
function generateRandomLetter() {
|
||||
const chars = 'abcdefghijklmnopqrstuvwxyz ';
|
||||
return chars[Math.floor(Math.random() * chars.length)];
|
||||
}
|
||||
|
||||
let letterInterval = null;
|
||||
let letterCount = 0;
|
||||
|
||||
// Purge stale rate-limit records — these are only needed within their time window
|
||||
function purgeOldRateLimitRecords() {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
db.run('DELETE FROM submission_attempts WHERE created_at < ?', [now - 3600]);
|
||||
db.run('DELETE FROM upvote_attempts WHERE created_at < ?', [now - 86400]);
|
||||
}
|
||||
|
||||
// Generate and broadcast letters
|
||||
function startLetterGeneration() {
|
||||
if (letterInterval) {
|
||||
clearInterval(letterInterval);
|
||||
}
|
||||
|
||||
letterInterval = setInterval(() => {
|
||||
const letter = generateRandomLetter();
|
||||
const timestamp = Date.now();
|
||||
|
||||
db.run('INSERT INTO letters (letter, timestamp) VALUES (?, ?)', [letter, timestamp]);
|
||||
|
||||
if (LOG_LETTERS) console.log(`Letter: "${letter}"`);
|
||||
|
||||
const message = JSON.stringify({ type: 'letter', letter, timestamp });
|
||||
wss.clients.forEach(client => {
|
||||
if (client.readyState === 1) {
|
||||
client.send(message);
|
||||
}
|
||||
});
|
||||
|
||||
// Clean old letters and save every 100 ticks (~80 seconds at default interval)
|
||||
letterCount++;
|
||||
if (letterCount % 100 === 0) {
|
||||
db.run(`
|
||||
DELETE FROM letters
|
||||
WHERE id NOT IN (
|
||||
SELECT id FROM letters ORDER BY id DESC LIMIT ?
|
||||
)
|
||||
`, [LETTERS_CACHE_SIZE]);
|
||||
purgeOldRateLimitRecords();
|
||||
saveDB();
|
||||
}
|
||||
}, LETTER_GENERATION_INTERVAL);
|
||||
}
|
||||
|
||||
// WebSocket connection
|
||||
wss.on('connection', (ws) => {
|
||||
ws.send(JSON.stringify({ type: 'init', letters: [] }));
|
||||
|
||||
if (wss.clients.size === 1) {
|
||||
startLetterGeneration();
|
||||
}
|
||||
|
||||
ws.on('close', () => {
|
||||
if (wss.clients.size === 0) {
|
||||
clearInterval(letterInterval);
|
||||
letterInterval = null;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// API Routes
|
||||
|
||||
// Submit phrase
|
||||
app.post('/api/submit', (req, res) => {
|
||||
const { phrase, timestamps } = req.body;
|
||||
const ip = getClientIP(req);
|
||||
|
||||
if (!phrase || !timestamps || !Array.isArray(timestamps)) {
|
||||
return res.status(400).json({ error: 'Invalid request' });
|
||||
}
|
||||
|
||||
// Check rate limit
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const oneHourAgo = now - 3600;
|
||||
const countResult = db.exec(`
|
||||
SELECT COUNT(*) as count FROM submission_attempts
|
||||
WHERE ip_address = ? AND created_at > ?
|
||||
`, [ip, oneHourAgo]);
|
||||
|
||||
const count = countResult[0]?.values[0]?.[0] || 0;
|
||||
if (count >= MAX_SUBMISSIONS_PER_HOUR) {
|
||||
return res.status(429).json({
|
||||
error: `Maximum ${MAX_SUBMISSIONS_PER_HOUR} submission(s) per hour reached`
|
||||
});
|
||||
}
|
||||
|
||||
// Validate phrase
|
||||
if (phrase.length !== timestamps.length || phrase.length === 0 || phrase.length > 200) {
|
||||
return res.status(400).json({ error: 'Invalid phrase length' });
|
||||
}
|
||||
|
||||
// Check for duplicate phrase
|
||||
const dupeResult = db.exec(
|
||||
'SELECT COUNT(*) as count FROM submissions WHERE LOWER(TRIM(phrase)) = LOWER(TRIM(?))',
|
||||
[phrase.trim()]
|
||||
);
|
||||
const dupeCount = dupeResult[0]?.values[0]?.[0] || 0;
|
||||
if (dupeCount > 0) {
|
||||
return res.status(400).json({ error: 'This phrase already exists on the leaderboard' });
|
||||
}
|
||||
|
||||
// Validate all letters in one query
|
||||
const placeholders = timestamps.map(() => '?').join(',');
|
||||
const letterResult = db.exec(
|
||||
`SELECT timestamp, letter FROM letters WHERE timestamp IN (${placeholders})`,
|
||||
timestamps
|
||||
);
|
||||
const validLetters = new Map();
|
||||
if (letterResult.length && letterResult[0].values.length) {
|
||||
for (const [ts, lt] of letterResult[0].values) {
|
||||
validLetters.set(ts, lt);
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < timestamps.length; i++) {
|
||||
if (validLetters.get(timestamps[i]) !== phrase[i]) {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid submission - letters do not match server records'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Insert submission
|
||||
db.run('INSERT INTO submissions (phrase, timestamps) VALUES (?, ?)',
|
||||
[phrase.trim(), JSON.stringify(timestamps)]);
|
||||
|
||||
const lastIdResult = db.exec('SELECT last_insert_rowid() as id');
|
||||
const submissionId = lastIdResult[0].values[0][0];
|
||||
|
||||
db.run('INSERT INTO submission_attempts (ip_address) VALUES (?)', [ip]);
|
||||
saveDB();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
submission: {
|
||||
id: submissionId,
|
||||
phrase: phrase.trim(),
|
||||
votes: 0
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Get leaderboard
|
||||
app.get('/api/leaderboard', (req, res) => {
|
||||
const result = db.exec(`
|
||||
SELECT id, phrase, votes, created_at
|
||||
FROM submissions
|
||||
ORDER BY votes DESC, created_at DESC
|
||||
LIMIT 100
|
||||
`);
|
||||
|
||||
let submissions = [];
|
||||
if (result.length > 0 && result[0].values.length > 0) {
|
||||
submissions = result[0].values.map(row => ({
|
||||
id: row[0],
|
||||
phrase: row[1],
|
||||
votes: row[2],
|
||||
created_at: row[3]
|
||||
}));
|
||||
}
|
||||
|
||||
res.json(submissions);
|
||||
});
|
||||
|
||||
// Upvote
|
||||
app.post('/api/upvote', (req, res) => {
|
||||
const { id } = req.body;
|
||||
const ip = getClientIP(req);
|
||||
|
||||
if (!id) {
|
||||
return res.status(400).json({ error: 'Invalid request' });
|
||||
}
|
||||
|
||||
// Check rate limit
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const oneDayAgo = now - 86400;
|
||||
const countResult = db.exec(`
|
||||
SELECT COUNT(*) as count FROM upvote_attempts
|
||||
WHERE ip_address = ? AND created_at > ?
|
||||
`, [ip, oneDayAgo]);
|
||||
|
||||
const count = countResult[0]?.values[0]?.[0] || 0;
|
||||
if (count >= MAX_UPVOTES_PER_DAY) {
|
||||
return res.status(429).json({
|
||||
error: `Maximum ${MAX_UPVOTES_PER_DAY} upvotes per day reached`
|
||||
});
|
||||
}
|
||||
|
||||
// Check if submission exists
|
||||
const subResult = db.exec('SELECT 1 FROM submissions WHERE id = ?', [id]);
|
||||
if (!subResult.length || !subResult[0].values.length) {
|
||||
return res.status(404).json({ error: 'Submission not found' });
|
||||
}
|
||||
|
||||
// Check if already voted
|
||||
const voteResult = db.exec(
|
||||
'SELECT 1 FROM submission_votes WHERE submission_id = ? AND ip_address = ?',
|
||||
[id, ip]
|
||||
);
|
||||
|
||||
if (voteResult.length && voteResult[0].values.length) {
|
||||
return res.status(400).json({ error: 'Already voted for this submission' });
|
||||
}
|
||||
|
||||
// Add vote
|
||||
try {
|
||||
db.run('INSERT INTO submission_votes (submission_id, ip_address) VALUES (?, ?)', [id, ip]);
|
||||
db.run('UPDATE submissions SET votes = votes + 1 WHERE id = ?', [id]);
|
||||
db.run('INSERT INTO upvote_attempts (ip_address) VALUES (?)', [ip]);
|
||||
saveDB();
|
||||
|
||||
const updated = db.exec('SELECT votes FROM submissions WHERE id = ?', [id]);
|
||||
const votes = updated[0].values[0][0];
|
||||
|
||||
res.json({ success: true, votes });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to record vote' });
|
||||
}
|
||||
});
|
||||
|
||||
// Serve React build in production
|
||||
const clientBuild = join(__dirname, 'public');
|
||||
if (existsSync(clientBuild)) {
|
||||
app.use(express.static(clientBuild));
|
||||
app.get('*', (req, res) => {
|
||||
res.sendFile(join(clientBuild, 'index.html'));
|
||||
});
|
||||
}
|
||||
|
||||
// Start server
|
||||
initDB().then(() => {
|
||||
// Run cleanup once an hour regardless of letter generation activity
|
||||
setInterval(() => {
|
||||
purgeOldRateLimitRecords();
|
||||
saveDB();
|
||||
}, 60 * 60 * 1000);
|
||||
|
||||
server.listen(PORT, () => {
|
||||
console.log(`Server running on port ${PORT}`);
|
||||
});
|
||||
}).catch(err => {
|
||||
console.error('Failed to initialize database:', err);
|
||||
process.exit(1);
|
||||
});
|
||||