Update requirements and minor features
This commit is contained in:
@@ -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,10 +77,10 @@ function Leaderboard() {
|
|||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<h1 style={{ margin: 0 }}>Leaderboard</h1>
|
<h1 style={{ margin: 0 }}>Leaderboard</h1>
|
||||||
|
<div style={{ display: "flex", gap: "10px", marginTop: "10px" }}>
|
||||||
<button
|
<button
|
||||||
onClick={loadLeaderboard}
|
onClick={() => loadLeaderboard(sort)}
|
||||||
style={{
|
style={{
|
||||||
marginTop: "10px",
|
|
||||||
padding: "6px 14px",
|
padding: "6px 14px",
|
||||||
fontSize: "14px",
|
fontSize: "14px",
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
@@ -84,11 +88,67 @@ function Leaderboard() {
|
|||||||
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>
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
|
|||||||
+26
-25
@@ -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,10 +257,11 @@ 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
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user