Initial commit

This commit is contained in:
Sistav
2026-03-01 23:10:24 -05:00
commit 4cefc00b64
44 changed files with 1587 additions and 0 deletions
+34
View File
@@ -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"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 482 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 480 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 476 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 444 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 462 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 508 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 530 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 524 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 518 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 524 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 533 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 536 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 531 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 523 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 525 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 527 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 193 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 478 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 462 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 498 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 588 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 689 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 746 KiB

+11
View File
@@ -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>
+134
View File
@@ -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;
}
}
+23
View File
@@ -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;
+60
View File
@@ -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;
+90
View File
@@ -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;
+11
View File
@@ -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}`;
}
+6
View File
@@ -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 />);
+83
View File
@@ -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 &lt;3 by{" "}
<a
href="https://oelbaytam.github.io"
target="_blank"
rel="noreferrer"
style={{ color: "#f0f0f0" }}
>
OmarEpic
</a>{" "}
&amp;{" "}
<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;
+95
View File
@@ -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;
+200
View File
@@ -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;
+367
View File
@@ -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;