Update requirements and minor features

This commit is contained in:
2026-05-23 16:50:01 -04:00
parent 471bd05ae6
commit c95cadffa0
4 changed files with 125 additions and 63 deletions
+91 -31
View File
@@ -5,14 +5,15 @@ import "../App.css";
function Leaderboard() { function Leaderboard() {
const [submissions, setSubmissions] = useState([]); const [submissions, setSubmissions] = useState([]);
const [message, setMessage] = useState(""); const [message, setMessage] = useState("");
const [sort, setSort] = useState("top");
useEffect(() => { useEffect(() => {
loadLeaderboard(); loadLeaderboard(sort);
}, []); }, [sort]);
const loadLeaderboard = async () => { const loadLeaderboard = async (sortMode) => {
try { try {
const response = await fetch("/api/leaderboard"); const response = await fetch(`/api/leaderboard?sort=${sortMode}`);
const data = await response.json(); const data = await response.json();
setSubmissions(data); setSubmissions(data);
} catch (error) { } catch (error) {
@@ -20,7 +21,6 @@ function Leaderboard() {
} }
}; };
const upvote = async (id) => { const upvote = async (id) => {
try { try {
const response = await fetch("/api/upvote", { const response = await fetch("/api/upvote", {
@@ -33,7 +33,7 @@ function Leaderboard() {
if (response.ok) { if (response.ok) {
setMessage("✓ Vote recorded!"); setMessage("✓ Vote recorded!");
loadLeaderboard(); loadLeaderboard(sort);
setTimeout(() => setMessage(""), 3000); setTimeout(() => setMessage(""), 3000);
} else { } else {
setMessage("✗ " + (data.error || "Failed to vote")); setMessage("✗ " + (data.error || "Failed to vote"));
@@ -52,6 +52,10 @@ function Leaderboard() {
}); });
}; };
const toggleSort = () => {
setSort((prev) => (prev === "top" ? "new" : "top"));
};
return ( return (
<div <div
style={{ style={{
@@ -73,22 +77,78 @@ function Leaderboard() {
> >
<div> <div>
<h1 style={{ margin: 0 }}>Leaderboard</h1> <h1 style={{ margin: 0 }}>Leaderboard</h1>
<button <div style={{ display: "flex", gap: "10px", marginTop: "10px" }}>
onClick={loadLeaderboard} <button
style={{ onClick={() => loadLeaderboard(sort)}
marginTop: "10px", style={{
padding: "6px 14px", padding: "6px 14px",
fontSize: "14px", fontSize: "14px",
cursor: "pointer", cursor: "pointer",
backgroundColor: "transparent", backgroundColor: "transparent",
color: "#f0f0f0", color: "#f0f0f0",
border: "2px solid #555", border: "2px solid #555",
borderRadius: "4px", borderRadius: "4px",
display: "block", }}
}} >
> Refresh
Refresh </button>
</button>
{/* Sliding toggle */}
<div
onClick={toggleSort}
style={{
position: "relative",
display: "flex",
alignItems: "center",
width: "120px",
height: "34px",
backgroundColor: "transparent",
border: "2px solid #555",
borderRadius: "4px",
cursor: "pointer",
userSelect: "none",
overflow: "hidden",
fontFamily: "system-ui",
}}
>
{/* Sliding highlight */}
<div
style={{
position: "absolute",
top: 0,
left: sort === "top" ? 0 : "50%",
width: "50%",
height: "100%",
backgroundColor: "#f0f0f0",
transition: "left 0.2s ease",
}}
/>
<span
style={{
position: "relative",
flex: 1,
textAlign: "center",
fontSize: "14px",
color: sort === "top" ? "#111" : "#f0f0f0",
transition: "color 0.2s ease",
}}
>
Top
</span>
<span
style={{
position: "relative",
flex: 1,
textAlign: "center",
fontSize: "14px",
color: sort === "new" ? "#111" : "#f0f0f0",
transition: "color 0.2s ease",
}}
>
New
</span>
</div>
</div>
</div> </div>
<MonkeySprite animation="idle" /> <MonkeySprite animation="idle" />
@@ -111,7 +171,7 @@ function Leaderboard() {
<div> <div>
{submissions.length === 0 ? ( {submissions.length === 0 ? (
<p style={{ textAlign: "center", padding: "40px", color: "#666" }}> <p style={{ textAlign: "center", padding: "40px", color: "#f0f0f0" }}>
No submissions yet. Be the first! No submissions yet. Be the first!
</p> </p>
) : ( ) : (
@@ -123,7 +183,7 @@ function Leaderboard() {
marginBottom: "15px", marginBottom: "15px",
borderRadius: "8px", borderRadius: "8px",
backgroundColor: "#1a1a1a", backgroundColor: "#1a1a1a",
border: `1px solid ${index < 3 ? "#555" : "#333"}`, border: `1px solid ${index < 3 && sort === "top" ? "#555" : "#333"}`,
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
gap: "20px", gap: "20px",
@@ -134,12 +194,12 @@ function Leaderboard() {
fontSize: "24px", fontSize: "24px",
minWidth: "40px", minWidth: "40px",
color: color:
index === 0 sort === "top" && index === 0
? "#fff" ? "#FFD700"
: index === 1 : sort === "top" && index === 1
? "#aaa" ? "#C0C0C0"
: index === 2 : sort === "top" && index === 2
? "#888" ? "#CD7F32"
: "#555", : "#555",
}} }}
> >
@@ -157,7 +217,7 @@ function Leaderboard() {
> >
"{sub.phrase}" "{sub.phrase}"
</div> </div>
<div style={{ fontSize: "12px", color: "#666" }}> <div style={{ fontSize: "12px", color: "#f0f0f0" }}>
{formatDate(sub.created_at)} {formatDate(sub.created_at)}
</div> </div>
</div> </div>
+1 -1
View File
@@ -166,7 +166,7 @@ function Type() {
const response = await fetch("/api/submit", { const response = await fetch("/api/submit", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ phrase, timestamps }), body: JSON.stringify({ timestamps }),
}); });
const data = await response.json(); const data = await response.json();
+4 -3
View File
@@ -7,11 +7,12 @@
"dev": "nodemon server.js" "dev": "nodemon server.js"
}, },
"dependencies": { "dependencies": {
"express": "^4.18.2", "cors": "^2.8.5",
"ws": "^8.16.0",
"dotenv": "^16.3.1", "dotenv": "^16.3.1",
"express": "^4.18.2",
"helmet": "^8.2.0",
"sql.js": "^1.10.3", "sql.js": "^1.10.3",
"cors": "^2.8.5" "ws": "^8.16.0"
}, },
"devDependencies": { "devDependencies": {
"nodemon": "^3.0.2" "nodemon": "^3.0.2"
+28 -27
View File
@@ -9,6 +9,7 @@ import { fileURLToPath } from 'url';
const __dirname = dirname(fileURLToPath(import.meta.url)); const __dirname = dirname(fileURLToPath(import.meta.url));
import cors from 'cors'; import cors from 'cors';
import helmet from 'helmet';
const app = express(); const app = express();
const server = createServer(app); const server = createServer(app);
@@ -98,6 +99,7 @@ function saveDB() {
} }
// Middleware // Middleware
app.use(helmet());
app.use(cors()); app.use(cors());
app.use(express.json()); app.use(express.json());
app.set('trust proxy', 1); app.set('trust proxy', 1);
@@ -178,13 +180,18 @@ wss.on('connection', (ws) => {
// Submit phrase // Submit phrase
app.post('/api/submit', (req, res) => { app.post('/api/submit', (req, res) => {
const { phrase, timestamps } = req.body; const { timestamps } = req.body;
const ip = getClientIP(req); const ip = getClientIP(req);
if (!phrase || !timestamps || !Array.isArray(timestamps)) { if (!timestamps || !Array.isArray(timestamps)) {
return res.status(400).json({ error: 'Invalid request' }); return res.status(400).json({ error: 'Invalid request' });
} }
// Validate timestamp count
if (timestamps.length > 256) {
return res.status(400).json({ error: 'Phrase too long (max 256 characters)' });
}
// Check rate limit // Check rate limit
const now = Math.floor(Date.now() / 1000); const now = Math.floor(Date.now() / 1000);
const oneHourAgo = now - 3600; const oneHourAgo = now - 3600;
@@ -200,22 +207,7 @@ app.post('/api/submit', (req, res) => {
}); });
} }
// Validate phrase // Resolve phrase from server-side letter records
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 placeholders = timestamps.map(() => '?').join(',');
const letterResult = db.exec( const letterResult = db.exec(
`SELECT timestamp, letter FROM letters WHERE timestamp IN (${placeholders})`, `SELECT timestamp, letter FROM letters WHERE timestamp IN (${placeholders})`,
@@ -227,17 +219,25 @@ app.post('/api/submit', (req, res) => {
validLetters.set(ts, lt); validLetters.set(ts, lt);
} }
} }
for (let i = 0; i < timestamps.length; i++) { const chars = timestamps.map(ts => validLetters.get(ts));
if (validLetters.get(timestamps[i]) !== phrase[i]) { if (chars.some(c => c === undefined)) {
return res.status(400).json({ return res.status(400).json({ error: 'Invalid submission - unknown timestamps' });
error: 'Invalid submission - letters do not match server records' }
}); const phrase = chars.join('');
}
// Check for duplicate phrase
const dupeResult = db.exec(
'SELECT COUNT(*) as count FROM submissions WHERE LOWER(TRIM(phrase)) = LOWER(TRIM(?))',
[phrase]
);
const dupeCount = dupeResult[0]?.values[0]?.[0] || 0;
if (dupeCount > 0) {
return res.status(400).json({ error: 'This phrase already exists on the leaderboard' });
} }
// Insert submission // Insert submission
db.run('INSERT INTO submissions (phrase, timestamps) VALUES (?, ?)', db.run('INSERT INTO submissions (phrase, timestamps) VALUES (?, ?)',
[phrase.trim(), JSON.stringify(timestamps)]); [phrase, JSON.stringify(timestamps)]);
const lastIdResult = db.exec('SELECT last_insert_rowid() as id'); const lastIdResult = db.exec('SELECT last_insert_rowid() as id');
const submissionId = lastIdResult[0].values[0][0]; const submissionId = lastIdResult[0].values[0][0];
@@ -257,11 +257,12 @@ app.post('/api/submit', (req, res) => {
// Get leaderboard // Get leaderboard
app.get('/api/leaderboard', (req, res) => { app.get('/api/leaderboard', (req, res) => {
const sort = req.query.sort === 'new' ? 'created_at DESC' : 'votes DESC, created_at DESC';
const result = db.exec(` const result = db.exec(`
SELECT id, phrase, votes, created_at SELECT id, phrase, votes, created_at
FROM submissions FROM submissions
ORDER BY votes DESC, created_at DESC ORDER BY ${sort}
LIMIT 100 LIMIT 100
`); `);
let submissions = []; let submissions = [];