Refactor navigation and add authentication features

- Updated navigation links across guess.html, index.html, lists.html, players.html, roulette.html, rules.html, and run.html for consistency and clarity.
- Enhanced index.html with a sign-up button for user registration.
- Modified lists.html to improve user guidance on video watching and added a "My %" column for tracking progress.
- Introduced login.html and signup.html for user authentication, including form validation and status messages.
- Implemented server-side user authentication in server.js, including user registration, login, and session management.
- Added user data handling for roulette progress and saved runs.
- Created a .gitignore file to exclude sensitive user data files from version control.
- Updated styles.css to accommodate new UI elements for authentication and improved layout.
This commit is contained in:
2026-04-03 00:22:25 -05:00
parent aac09d2c8b
commit f012aa3dc4
13 changed files with 1550 additions and 27 deletions
+4
View File
@@ -0,0 +1,4 @@
# Server-side auth and user data (local only)
server/users.json
server/sessions.json
server/userdata.json
+979 -8
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -11,12 +11,12 @@
<h1>Guess The Rank</h1>
<nav>
<a href="index.html">Home</a>
<a href="roulette.html">Roulette</a>
<a href="guess.html">Guess Game</a>
<a href="players.html">Players</a>
<a href="lists.html">Lists</a>
<a href="run.html">Submit Run</a>
<a href="players.html">Players</a>
<a href="rules.html">Rules</a>
<a href="admelist.html">Admin</a>
<a href="roulette.html">Roulette</a>
</nav>
</header>
<main>
+2 -1
View File
@@ -12,13 +12,13 @@
<header>
<h1>FEDL - Ranked Demon List</h1>
<nav>
<a href="index.html">Home</a>
<a href="guess.html">Guess Game</a>
<a href="players.html">Players</a>
<a href="lists.html">Lists</a>
<a href="run.html">Submit Run</a>
<a href="rules.html">Rules</a>
<a href="roulette.html">Roulette</a>
<a href="admelist.html">Admin</a>
</nav>
</header>
<main>
@@ -30,6 +30,7 @@
<div class="hero-actions">
<a class="btn" href="lists.html">Open The List</a>
<a class="btn" href="signup.html">Sign up</a>
<a class="btn ghost-btn" href="guess.html">Play Guess Game</a>
<a class="btn ghost-btn" href="run.html">Submit A Run</a>
<a class="btn ghost-btn" href="rules.html">Submission Rules</a>
+3 -3
View File
@@ -15,10 +15,10 @@
<a href="index.html">Home</a>
<a href="guess.html">Guess Game</a>
<a href="players.html">Players</a>
<a href="lists.html">Lists</a>
<a href="run.html">Submit Run</a>
<a href="rules.html">Rules</a>
<a href="roulette.html">Roulette</a>
<a href="admelist.html">Admin</a>
</nav>
</header>
<main class="layout">
@@ -63,13 +63,13 @@
<p class="status-label">List Table</p>
<h3>Current placements</h3>
</div>
<p class="muted table-hint">Click <strong>Open</strong> on any row to watch the linked run.</p>
<p class="muted table-hint">Click <strong>Open</strong> to watch the video. With a profile from Roulette, use <strong>My %</strong> to track progress per level (this device only).</p>
</div>
<div class="table-wrap">
<table class="levels-table">
<thead>
<tr><th style="width:80px">#</th><th>Title</th><th style="width:140px">Action</th></tr>
<tr><th style="width:80px">#</th><th>Title</th><th style="width:100px">My %</th><th style="width:140px">Action</th></tr>
</thead>
<tbody id="list-area"></tbody>
</table>
+43
View File
@@ -0,0 +1,43 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Log in — GD fedl</title>
<link rel="stylesheet" href="styles.css">
</head>
<body data-page="login">
<header>
<h1>Log in</h1>
<nav>
<a href="index.html">Home</a>
<a href="signup.html">Sign up</a>
<a href="lists.html">Lists</a>
<a href="roulette.html">Roulette</a>
</nav>
</header>
<main class="auth-main">
<section class="panel auth-panel">
<p class="hero-kicker">FEDL account</p>
<h2>Welcome back</h2>
<p class="muted">Sign in to load your saved roulette pick and per-level progress from the server.</p>
<form id="login-form" class="auth-form">
<label class="admin-password-field" for="login-username">
<span>Username</span>
<input id="login-username" name="username" type="text" autocomplete="username" required />
</label>
<label class="admin-password-field" for="login-password">
<span>Password</span>
<input id="login-password" name="password" type="password" autocomplete="current-password" required />
</label>
<p id="login-status" class="muted" role="status"></p>
<div class="auth-actions">
<button type="submit" class="btn" id="login-submit">Log in</button>
<a class="text-link" href="signup.html">Need an account? Sign up</a>
</div>
</form>
</section>
</main>
<script src="app.js"></script>
</body>
</html>
+1 -1
View File
@@ -12,11 +12,11 @@
<nav>
<a href="index.html">Home</a>
<a href="guess.html">Guess Game</a>
<a href="players.html">Players</a>
<a href="lists.html">Lists</a>
<a href="run.html">Submit Run</a>
<a href="rules.html">Rules</a>
<a href="roulette.html">Roulette</a>
<a href="admelist.html">Admin</a>
</nav>
</header>
<main class="layout">
+54 -2
View File
@@ -12,15 +12,57 @@
<nav>
<a href="index.html">Home</a>
<a href="guess.html">Guess Game</a>
<a href="players.html">Players</a>
<a href="lists.html">Lists</a>
<a href="run.html">Submit Run</a>
<a href="players.html">Players</a>
<a href="rules.html">Rules</a>
<a href="admelist.html">Admin</a>
<a href="roulette.html">Roulette</a>
</nav>
</header>
<main>
<section class="roulette-shell">
<div class="roulette-main-stack">
<section class="panel roulette-account-panel">
<p class="roulette-kicker">Your profile</p>
<h2 class="roulette-account-heading">Save roulette &amp; progress</h2>
<p class="muted small roulette-account-intro">Profiles are stored only in this browser. Each profile remembers your last roulette demon and a progress % per level (also shown on the list page).</p>
<p class="muted small roulette-login-sync-hint" id="roulette-login-sync-hint" hidden>
<a href="login.html">Log in</a> or <a href="signup.html">sign up</a> to sync roulette, save slots, and % across browsers.
</p>
<div class="roulette-slots-block" id="roulette-slots-block">
<p class="roulette-slots-heading">Roulette save slots</p>
<p class="muted small" id="roulette-slots-hint">Use three slots to park different demons. Requires an active profile or a signed-in account.</p>
<div class="roulette-slot-rows">
<div class="roulette-slot-row">
<span class="roulette-slot-title">Slot 1</span>
<span class="roulette-slot-label muted" id="roulette-slot-label-1">Empty</span>
<button type="button" class="btn ghost-btn small-btn" id="roulette-slot-save-1">Save</button>
<button type="button" class="btn ghost-btn small-btn" id="roulette-slot-load-1">Load</button>
</div>
<div class="roulette-slot-row">
<span class="roulette-slot-title">Slot 2</span>
<span class="roulette-slot-label muted" id="roulette-slot-label-2">Empty</span>
<button type="button" class="btn ghost-btn small-btn" id="roulette-slot-save-2">Save</button>
<button type="button" class="btn ghost-btn small-btn" id="roulette-slot-load-2">Load</button>
</div>
<div class="roulette-slot-row">
<span class="roulette-slot-title">Slot 3</span>
<span class="roulette-slot-label muted" id="roulette-slot-label-3">Empty</span>
<button type="button" class="btn ghost-btn small-btn" id="roulette-slot-save-3">Save</button>
<button type="button" class="btn ghost-btn small-btn" id="roulette-slot-load-3">Load</button>
</div>
</div>
</div>
<div class="roulette-account-controls">
<label class="roulette-account-label" for="roulette-account-select">Active profile</label>
<select id="roulette-account-select" class="roulette-account-select"></select>
<div class="roulette-account-create-row">
<input id="roulette-account-new" type="text" placeholder="New profile name" autocomplete="username" />
<button type="button" class="btn ghost-btn" id="roulette-account-create">Create</button>
</div>
</div>
<button type="button" class="btn ghost-btn roulette-restore-btn" id="roulette-restore" hidden>Restore last spin</button>
</section>
<section class="panel roulette-panel">
<div class="roulette-head">
<div>
@@ -37,9 +79,19 @@
<p class="roulette-rank" id="roulette-rank">Rank: -</p>
<p class="roulette-meta" id="roulette-level-id">Level ID: -</p>
<p class="roulette-note muted" id="roulette-note">Level IDs come from your local file first, then API lookup if needed.</p>
<div class="roulette-progress-block" id="roulette-progress-row" hidden>
<label class="roulette-progress-label" for="roulette-percent">Your % on this level</label>
<div class="roulette-progress-input-row">
<input id="roulette-percent" type="text" inputmode="decimal" placeholder="e.g. 47" autocomplete="off" />
<span class="muted">%</span>
<button type="button" class="btn small-btn" id="roulette-percent-submit">Submit %</button>
</div>
<p id="roulette-percent-hint" class="muted small roulette-percent-hint"></p>
</div>
<a class="btn roulette-open" id="roulette-open" href="#" target="_blank" rel="noopener noreferrer" hidden>View Video</a>
</div>
</section>
</div>
<aside class="panel roulette-rules-card">
<p class="roulette-kicker">How It Works</p>
+2 -2
View File
@@ -12,11 +12,11 @@
<nav>
<a href="index.html">Home</a>
<a href="guess.html">Guess Game</a>
<a href="players.html">Players</a>
<a href="lists.html">Lists</a>
<a href="run.html">Submit Run</a>
<a href="players.html">Players</a>
<a href="rules.html">Rules</a>
<a href="roulette.html">Roulette</a>
<a href="admelist.html">Admin</a>
</nav>
</header>
<main>
+15 -2
View File
@@ -12,10 +12,11 @@
<nav>
<a href="index.html">Home</a>
<a href="guess.html">Guess Game</a>
<a href="players.html">Players</a>
<a href="lists.html">Lists</a>
<a href="run.html">Submit Run</a>
<a href="rules.html">Rules</a>
<a href="roulette.html">Roulette</a>
<a href="admelist.html">Admin</a>
</nav>
</header>
<main>
@@ -25,7 +26,7 @@
<div>
<p class="hero-kicker">FEDL Submission</p>
<h2>Send in a run</h2>
<p class="muted run-intro">Submit your completion video, attach raw footage if you have it hosted, and leave notes for the moderators. Required fields are marked below.</p>
<p class="muted run-intro">Submit your completion video, attach raw footage if you have it hosted, and leave notes for the moderators. If you <a href="login.html">log in</a>, you can save multiple runs to your account (drafts) and queue submissions will show your username to moderators.</p>
</div>
<div class="run-head-badge">
<strong>Live queue</strong>
@@ -67,6 +68,7 @@
<div class="run-actions">
<button id="run-submit" type="submit" class="btn">Submit Run</button>
<button type="button" id="run-save-account" class="btn ghost-btn" hidden>Save to my account</button>
<p id="run-form-status" class="muted">The server needs to be running for live submissions.</p>
</div>
</form>
@@ -92,6 +94,17 @@
</aside>
</section>
<section class="panel run-my-runs-panel" id="run-my-runs-section" hidden>
<div class="submission-header run-my-runs-head">
<div>
<p class="hero-kicker">Your account</p>
<h3>Runs saved to your account</h3>
<p class="muted">Keep multiple drafts here while you work on links or notes. They sync when you are signed in.</p>
</div>
</div>
<div id="run-saved-runs-list" class="submission-list"></div>
</section>
<section class="panel submission-panel">
<div class="table-header submission-header">
<div>
+350 -1
View File
@@ -1,11 +1,15 @@
const http = require('http');
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const { URL } = require('url');
const appRoot = path.resolve(__dirname, '..');
const dataPath = path.join(__dirname, 'data.txt');
const runsPath = path.join(__dirname, 'runs.json');
const usersPath = path.join(__dirname, 'users.json');
const sessionsPath = path.join(__dirname, 'sessions.json');
const userDataPath = path.join(__dirname, 'userdata.json');
const port = Number(process.env.PORT) || 8090;
const host = process.env.HOST || '127.0.0.1';
const BASE = '/fedl';
@@ -107,6 +111,136 @@ function writeRuns(runs) {
fs.writeFileSync(runsPath, `${JSON.stringify(runs, null, 2)}\n`, 'utf8');
}
const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000;
function ensureUsersFile() {
if (!fs.existsSync(usersPath)) {
fs.writeFileSync(usersPath, '[]\n', 'utf8');
}
}
function ensureSessionsFile() {
if (!fs.existsSync(sessionsPath)) {
fs.writeFileSync(sessionsPath, '{}\n', 'utf8');
}
}
function ensureUserDataFile() {
if (!fs.existsSync(userDataPath)) {
fs.writeFileSync(userDataPath, '{}\n', 'utf8');
}
}
function readUsers() {
ensureUsersFile();
const raw = fs.readFileSync(usersPath, 'utf8');
const parsed = JSON.parse(raw || '[]');
return Array.isArray(parsed) ? parsed : [];
}
function writeUsers(users) {
fs.writeFileSync(usersPath, `${JSON.stringify(users, null, 2)}\n`, 'utf8');
}
function readSessionsRaw() {
ensureSessionsFile();
const raw = fs.readFileSync(sessionsPath, 'utf8');
const parsed = JSON.parse(raw || '{}');
return parsed && typeof parsed === 'object' ? parsed : {};
}
function writeSessions(sessions) {
fs.writeFileSync(sessionsPath, `${JSON.stringify(sessions, null, 2)}\n`, 'utf8');
}
function cleanSessions(sessions) {
const now = Date.now();
const out = {};
Object.keys(sessions).forEach(token => {
const s = sessions[token];
if (s && s.expiresAt && new Date(s.expiresAt).getTime() > now) {
out[token] = s;
}
});
return out;
}
function readSessions() {
return cleanSessions(readSessionsRaw());
}
function createSession(userId, username) {
const sessions = readSessions();
const token = crypto.randomBytes(32).toString('hex');
const expiresAt = new Date(Date.now() + SESSION_TTL_MS).toISOString();
sessions[token] = { userId, username, expiresAt };
writeSessions(sessions);
return token;
}
function findSession(token) {
if (!token) {
return null;
}
const sessions = readSessions();
const s = sessions[token];
if (!s || new Date(s.expiresAt).getTime() <= Date.now()) {
return null;
}
return s;
}
function deleteSession(token) {
const sessions = readSessionsRaw();
delete sessions[token];
writeSessions(cleanSessions(sessions));
}
function hashPassword(password) {
const salt = crypto.randomBytes(16).toString('hex');
const hash = crypto.scryptSync(password, salt, 64).toString('hex');
return { salt, hash };
}
function verifyPassword(password, salt, hashHex) {
try {
const h = crypto.scryptSync(password, salt, 64).toString('hex');
const a = Buffer.from(h, 'hex');
const b = Buffer.from(hashHex, 'hex');
if (a.length !== b.length) {
return false;
}
return crypto.timingSafeEqual(a, b);
} catch (error) {
return false;
}
}
function normalizeUsername(u) {
return String(u || '').trim().toLowerCase();
}
function usernameOk(u) {
return /^[a-z0-9_]{3,24}$/.test(u);
}
function getBearerToken(req) {
const h = String(req.headers.authorization || '');
const m = h.match(/^Bearer\s+(\S+)/i);
return m ? m[1] : '';
}
function readUserDataMap() {
ensureUserDataFile();
const raw = fs.readFileSync(userDataPath, 'utf8');
const parsed = JSON.parse(raw || '{}');
return parsed && typeof parsed === 'object' ? parsed : {};
}
function writeUserDataMap(map) {
fs.writeFileSync(userDataPath, `${JSON.stringify(map, null, 2)}\n`, 'utf8');
}
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
@@ -473,10 +607,73 @@ function normalizeRun(payload, existingRun) {
reviewedBy: String(payload.reviewedBy || existingRun?.reviewedBy || '').trim(),
reviewNotes: String(payload.reviewNotes || existingRun?.reviewNotes || '').trim(),
submittedAt: existingRun?.submittedAt || new Date().toISOString(),
updatedAt: new Date().toISOString()
updatedAt: new Date().toISOString(),
accountUserId: existingRun?.accountUserId || '',
accountUsername: existingRun?.accountUsername || ''
};
}
const MAX_SAVED_RUNS_PER_USER = 48;
function sanitizeSavedRuns(raw) {
if (!Array.isArray(raw)) {
return [];
}
const out = [];
for (let i = 0; i < raw.length && out.length < MAX_SAVED_RUNS_PER_USER; i += 1) {
const item = raw[i];
if (!item || typeof item !== 'object') continue;
const playerName = String(item.playerName || '').trim().slice(0, 120);
const levelTitle = String(item.levelTitle || '').trim().slice(0, 280);
if (!playerName || !levelTitle) continue;
out.push({
id: String(item.id || `sv_${Date.now().toString(36)}_${i}_${Math.random().toString(36).slice(2, 8)}`).slice(0, 96),
playerName,
levelTitle,
videoUrl: String(item.videoUrl || '').trim().slice(0, 2048),
percent: String(item.percent != null ? item.percent : '100').trim().slice(0, 12) || '100',
rawFootageUrl: String(item.rawFootageUrl || '').trim().slice(0, 2048),
notes: String(item.notes || '').trim().slice(0, 8000),
savedAt: String(item.savedAt || new Date().toISOString()).slice(0, 48)
});
}
return out;
}
const ROULETTE_SLOT_KEYS = ['1', '2', '3'];
function emptyRouletteSlots() {
return { '1': null, '2': null, '3': null };
}
function sanitizeRouletteSlots(raw) {
const out = emptyRouletteSlots();
if (!raw || typeof raw !== 'object') {
return out;
}
for (const k of ROULETTE_SLOT_KEYS) {
const v = raw[k];
if (v == null || typeof v !== 'object') {
continue;
}
const title = String(v.title || '').trim().slice(0, 280);
if (!title) {
continue;
}
out[k] = {
title,
position: String(v.position || '').trim().slice(0, 32),
level: String(v.level || '').trim().slice(0, 120),
url: String(v.url || '').trim().slice(0, 2048),
levelId: String(v.levelId || '').trim().slice(0, 64),
noteSource: v.noteSource === 'api' ? 'api' : 'file',
percent: String(v.percent != null ? v.percent : '').trim().slice(0, 12),
savedAt: String(v.savedAt || new Date().toISOString()).slice(0, 48)
};
}
return out;
}
function serveFile(reqPath, res) {
let filePath = path.join(appRoot, reqPath === '/' ? 'index.html' : reqPath.slice(1));
filePath = path.normalize(filePath);
@@ -521,6 +718,153 @@ const server = http.createServer((req, res) => {
return;
}
if (req.method === 'POST' && pathname === '/api/auth/signup') {
let body = '';
req.on('data', chunk => {
body += chunk;
if (body.length > 65536) req.destroy();
});
req.on('end', () => {
try {
const payload = JSON.parse(body || '{}');
const username = normalizeUsername(payload.username);
const password = String(payload.password || '');
if (!usernameOk(username)) {
sendJson(res, 400, {
error: 'Username must be 3-24 characters: lowercase letters, numbers, or underscore.'
});
return;
}
if (password.length < 8) {
sendJson(res, 400, { error: 'Password must be at least 8 characters.' });
return;
}
const users = readUsers();
if (users.some(u => u.username === username)) {
sendJson(res, 409, { error: 'That username is already taken.' });
return;
}
const { salt, hash } = hashPassword(password);
const id = `usr_${Date.now().toString(36)}${crypto.randomBytes(4).toString('hex')}`;
users.push({
id,
username,
passwordHash: hash,
salt,
createdAt: new Date().toISOString()
});
writeUsers(users);
const token = createSession(id, username);
sendJson(res, 201, { ok: true, token, userId: id, username });
} catch (error) {
sendJson(res, 400, { error: 'Invalid signup request' });
}
});
return;
}
if (req.method === 'POST' && pathname === '/api/auth/login') {
let body = '';
req.on('data', chunk => {
body += chunk;
if (body.length > 65536) req.destroy();
});
req.on('end', () => {
try {
const payload = JSON.parse(body || '{}');
const username = normalizeUsername(payload.username);
const password = String(payload.password || '');
const users = readUsers();
const user = users.find(u => u.username === username);
if (!user || !verifyPassword(password, user.salt, user.passwordHash)) {
sendJson(res, 401, { error: 'Invalid username or password.' });
return;
}
const token = createSession(user.id, user.username);
sendJson(res, 200, { ok: true, token, userId: user.id, username: user.username });
} catch (error) {
sendJson(res, 400, { error: 'Invalid login request' });
}
});
return;
}
if (req.method === 'POST' && pathname === '/api/auth/logout') {
const token = getBearerToken(req);
if (token) {
deleteSession(token);
}
sendJson(res, 200, { ok: true });
return;
}
if (req.method === 'GET' && pathname === '/api/auth/me') {
const token = getBearerToken(req);
const sess = findSession(token);
if (!sess) {
sendJson(res, 401, { error: 'Not signed in' });
return;
}
sendJson(res, 200, { userId: sess.userId, username: sess.username });
return;
}
if (req.method === 'GET' && pathname === '/api/user/state') {
const token = getBearerToken(req);
const sess = findSession(token);
if (!sess) {
sendJson(res, 401, { error: 'Not signed in' });
return;
}
const map = readUserDataMap();
const row = map[sess.userId] || {};
const data = {
roulettePick: row.roulettePick != null ? row.roulettePick : null,
levelPercents: row.levelPercents && typeof row.levelPercents === 'object' ? row.levelPercents : {},
savedRuns: Array.isArray(row.savedRuns) ? row.savedRuns : [],
rouletteSlots: sanitizeRouletteSlots(row.rouletteSlots)
};
sendJson(res, 200, { data });
return;
}
if (req.method === 'PUT' && pathname === '/api/user/state') {
const token = getBearerToken(req);
const sess = findSession(token);
if (!sess) {
sendJson(res, 401, { error: 'Not signed in' });
return;
}
let body = '';
req.on('data', chunk => {
body += chunk;
if (body.length > 2 * 1024 * 1024) req.destroy();
});
req.on('end', () => {
try {
const payload = JSON.parse(body || '{}');
const incoming = payload.data;
if (!incoming || typeof incoming !== 'object') {
sendJson(res, 400, { error: 'A "data" object is required' });
return;
}
const map = readUserDataMap();
map[sess.userId] = {
roulettePick: incoming.roulettePick != null ? incoming.roulettePick : null,
levelPercents:
incoming.levelPercents && typeof incoming.levelPercents === 'object' ? incoming.levelPercents : {},
savedRuns: sanitizeSavedRuns(incoming.savedRuns),
rouletteSlots: sanitizeRouletteSlots(incoming.rouletteSlots)
};
writeUserDataMap(map);
sendJson(res, 200, { ok: true });
} catch (error) {
sendJson(res, 400, { error: 'Invalid payload' });
}
});
return;
}
if (req.method === 'GET' && pathname === '/api/list') {
try {
const text = readDataText();
@@ -586,6 +930,11 @@ const server = http.createServer((req, res) => {
try {
const payload = JSON.parse(body || '{}');
const nextRun = normalizeRun(payload);
const sess = findSession(getBearerToken(req));
if (sess) {
nextRun.accountUserId = sess.userId;
nextRun.accountUsername = sess.username;
}
if (!nextRun.playerName || !nextRun.levelTitle || !nextRun.videoUrl || !nextRun.percent) {
sendJson(res, 400, { error: 'playerName, levelTitle, videoUrl, and percent are required' });
return;
+48
View File
@@ -0,0 +1,48 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Sign up — GD fedl</title>
<link rel="stylesheet" href="styles.css">
</head>
<body data-page="signup">
<header>
<h1>Sign up</h1>
<nav>
<a href="index.html">Home</a>
<a href="login.html">Log in</a>
<a href="lists.html">Lists</a>
<a href="roulette.html">Roulette</a>
</nav>
</header>
<main class="auth-main">
<section class="panel auth-panel">
<p class="hero-kicker">FEDL account</p>
<h2>Create an account</h2>
<p class="muted">Username and password are stored on your FEDL server. Roulette progress and list % sync to the server when you are signed in.</p>
<form id="signup-form" class="auth-form">
<label class="admin-password-field" for="signup-username">
<span>Username</span>
<input id="signup-username" name="username" type="text" autocomplete="username" required minlength="3" maxlength="24" pattern="[a-z0-9_]{3,24}" placeholder="letters, numbers, underscore" />
</label>
<p class="muted small auth-hint">324 characters, lowercase letters, numbers, or underscore only.</p>
<label class="admin-password-field" for="signup-password">
<span>Password</span>
<input id="signup-password" name="password" type="password" autocomplete="new-password" required minlength="8" placeholder="At least 8 characters" />
</label>
<label class="admin-password-field" for="signup-password2">
<span>Confirm password</span>
<input id="signup-password2" name="password2" type="password" autocomplete="new-password" required minlength="8" />
</label>
<p id="signup-status" class="muted" role="status"></p>
<div class="auth-actions">
<button type="submit" class="btn" id="signup-submit">Create account</button>
<a class="text-link" href="login.html">Already have an account? Log in</a>
</div>
</form>
</section>
</main>
<script src="app.js"></script>
</body>
</html>
+43 -1
View File
@@ -31,6 +31,8 @@ table.levels-table tr:hover td{background:rgba(255,255,255,0.01)}
.btn:hover{transform:translateY(-1px);box-shadow:0 10px 24px rgba(92,197,255,0.22)}
.muted{color:var(--muted);font-size:0.9rem}
.error-text{color:#ff9f9f}
.success-text{color:#8cf0ae!important}
header nav a.btn{margin-left:14px;text-decoration:none;vertical-align:middle}
.small-btn{padding:8px 12px}
.danger-btn{background:#ff7b7b;color:#2a0909}
.admin-shell{padding:18px 20px}
@@ -184,6 +186,34 @@ table.levels-table .btn{padding:9px 14px}
/* small helpers reused */
.small{font-size:0.9rem}
.roulette-shell{display:grid;grid-template-columns:minmax(0,1.25fr) minmax(280px,.75fr);gap:18px;align-items:start}
.roulette-main-stack{display:grid;gap:18px;min-width:0}
.roulette-account-panel{padding:22px 24px;border-radius:28px;background:linear-gradient(180deg,rgba(255,255,255,0.06),rgba(255,255,255,0.02));border:1px solid rgba(255,255,255,0.08)}
.roulette-account-heading{margin:0 0 8px 0;font-size:1.35rem}
.roulette-account-intro{margin:0 0 14px;line-height:1.55}
.roulette-account-controls{display:grid;gap:10px}
.roulette-account-label{font-size:0.88rem;font-weight:700;color:var(--muted)}
.roulette-account-select{width:100%;max-width:320px;padding:10px 12px;border-radius:12px;border:1px solid rgba(255,255,255,0.1);background:rgba(255,255,255,0.04);color:var(--text);font:inherit}
.roulette-account-create-row{display:flex;flex-wrap:wrap;gap:10px;align-items:center}
.roulette-account-create-row input{flex:1;min-width:160px;padding:10px 12px;border-radius:12px;border:1px solid rgba(255,255,255,0.1);background:rgba(255,255,255,0.04);color:var(--text);font:inherit}
.roulette-restore-btn{margin-top:12px}
.roulette-login-sync-hint{margin:0 0 14px;line-height:1.55}
.roulette-login-sync-hint a{color:var(--accent)}
.roulette-slots-block{margin:14px 0;padding:14px 0;border-top:1px solid rgba(255,255,255,0.08);border-bottom:1px solid rgba(255,255,255,0.08)}
.roulette-slots-heading{margin:0 0 6px 0;font-size:0.95rem;font-weight:800;color:#eaf2fb}
.roulette-slot-row{display:flex;flex-wrap:wrap;align-items:center;gap:10px;margin-top:10px}
.roulette-slot-title{min-width:52px;font-weight:700;font-size:0.88rem;color:var(--muted)}
.roulette-slot-label{flex:1;min-width:140px;font-size:0.88rem}
.roulette-progress-block{margin-top:16px;padding-top:16px;border-top:1px solid rgba(255,255,255,0.08)}
.roulette-progress-label{display:block;font-size:0.88rem;font-weight:700;margin-bottom:8px;color:#dce8f5}
.roulette-progress-input-row{display:flex;flex-wrap:wrap;gap:10px;align-items:center}
.roulette-progress-input-row input{width:100px;padding:10px 12px;border-radius:12px;border:1px solid rgba(255,255,255,0.1);background:rgba(255,255,255,0.04);color:var(--text);font:inherit}
.roulette-percent-hint{margin:10px 0 0;line-height:1.55;max-width:52ch}
.modal-account-progress{margin:16px 0;padding:14px 16px;border-radius:14px;background:rgba(92,197,255,0.08);border:1px solid rgba(92,197,255,0.15)}
.modal-account-label{margin:0 0 8px 0;font-size:0.9rem;font-weight:700}
.modal-account-row{display:flex;gap:10px;align-items:center}
.modal-account-pct-input{width:100px;padding:8px 10px;border-radius:10px;border:1px solid rgba(255,255,255,0.12);background:rgba(255,255,255,0.05);color:var(--text);font:inherit}
.list-my-progress-cell{vertical-align:middle}
.list-progress-input{width:100%;max-width:88px;padding:8px 10px;border-radius:10px;border:1px solid rgba(255,255,255,0.1);background:rgba(255,255,255,0.04);color:var(--text);font:inherit;text-align:center}
.roulette-main-column{display:grid;gap:18px}
.roulette-rules{margin-bottom:18px}
.roulette-panel{margin-top:0;padding:28px;border-radius:28px;background:linear-gradient(135deg,rgba(92,197,255,0.14),rgba(7,19,38,0.96));border:1px solid rgba(92,197,255,0.18)}
@@ -233,6 +263,9 @@ table.levels-table .btn{padding:9px 14px}
.submission-panel{margin-top:18px;padding:22px 22px 16px;border-radius:26px;background:linear-gradient(180deg,rgba(255,255,255,0.04),rgba(255,255,255,0.02));border:1px solid rgba(255,255,255,0.06)}
.submission-header{margin-bottom:18px}
.submission-list{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:14px}
.run-my-runs-panel{margin-top:8px}
.run-my-runs-head{margin-bottom:14px}
.run-saved-card-actions{display:flex;flex-wrap:wrap;gap:10px;margin-top:12px}
.submission-card{padding:18px;border-radius:18px;background:rgba(255,255,255,0.04);border:1px solid rgba(255,255,255,0.08)}
.submission-card-top{display:flex;align-items:center;justify-content:space-between;gap:12px}
.submission-card strong{font-size:1.05rem}
@@ -284,6 +317,15 @@ linear-gradient(180deg,rgba(8,18,32,0.98),rgba(7,19,38,0.94))}
.error-actions{display:flex;flex-wrap:wrap;gap:12px;margin-top:24px}
.error-actions .btn{font-weight:700;text-decoration:none}
.ghost-btn{background:transparent;color:var(--text);border:1px solid rgba(255,255,255,0.14)}
.auth-main{max-width:520px;margin:0 auto;padding:28px 20px 48px}
.auth-panel{padding:28px 26px}
.auth-panel h2{margin:0 0 8px 0;font-size:1.45rem}
.auth-form{display:grid;gap:16px;margin-top:18px}
.auth-form .auth-hint{margin:-6px 0 0 0;font-size:.82rem}
.auth-actions{display:flex;flex-wrap:wrap;align-items:center;gap:14px;margin-top:8px}
header nav .fedl-auth-nav{margin-left:14px;display:inline-flex;align-items:center;flex-wrap:wrap;gap:8px 10px;vertical-align:middle}
header nav .fedl-auth-nav a{color:var(--muted)}
header nav .fedl-auth-nav a:hover{color:var(--text)}
.error-side{display:flex;flex-direction:column;justify-content:space-between;gap:18px;padding:34px 30px;background:linear-gradient(180deg,rgba(255,255,255,0.05),rgba(255,255,255,0.02))}
.error-side h3{margin:0 0 10px 0;font-size:1rem;letter-spacing:0.04em;text-transform:uppercase;color:#eef6ff}
.error-list{margin:0;padding:0;list-style:none}
@@ -300,4 +342,4 @@ linear-gradient(180deg,rgba(8,18,32,0.98),rgba(7,19,38,0.94))}
@media(max-width:900px){.layout{flex-direction:column}.sidebar{width:100%}.sidebar .panel{height:auto}.video-modal iframe{height:320px}.modal-runs-head,.modal-run-card{grid-template-columns:1fr}.home-hero{grid-template-columns:1fr;min-height:auto}.home-copy,.hero-stage{padding:24px 22px}.hero-stats,.home-section-grid,.spotlight-grid{grid-template-columns:1fr}.spotlight-panel{grid-template-columns:1fr;padding:24px 22px}.list-hero-panel,.table-header,.search-row,.roulette-head,.guess-controls{flex-direction:column;align-items:flex-start}.control-select{max-width:none;width:100%}.roulette-shell{grid-template-columns:1fr}.discord-panel{flex-direction:column;align-items:flex-start;padding:22px 20px}.discord-panel h2{font-size:1.55rem}.discord-actions{width:100%;justify-content:flex-start}.rules-view header{padding:16px 18px}.rules-view header h1{font-size:1.55rem}.rules-page{padding:24px 20px}.rules-page h2{font-size:1.65rem}.guess-controls input,.guess-controls .btn{width:100%}}
@media(max-width:900px){.error-grid{grid-template-columns:1fr}.error-copy,.error-side{padding:24px 20px}.error-copy h2{font-size:2.5rem}}
@media(max-width:900px){.admin-overview-panel,.admin-toolbar{flex-direction:column;align-items:stretch}.admin-actions{flex-direction:column;align-items:stretch}.admin-password-field input,.admin-actions input[type=text],.admin-actions input[type=password]{min-width:0;width:100%}.run-shell{grid-template-columns:1fr}.run-head{flex-direction:column;align-items:flex-start}.run-form{grid-template-columns:1fr}.admin-login-screen{padding:18px}.admin-login-card{padding:26px 22px}}
@media(max-width:640px){header{flex-direction:column;align-items:flex-start;gap:12px}nav{display:flex;flex-wrap:wrap;gap:10px}nav a{margin-left:0}.home-copy h2{max-width:none}.hero-actions{flex-direction:column;align-items:stretch}.hero-actions .btn,.roulette-btn{width:100%;text-align:center}.list-hero-panel,.list-controls-panel,.list-table-panel,.roulette-panel,.roulette-rules-card,.run-panel,.run-side-panel,.submission-panel{padding:22px 18px}.table-hint{text-align:left}.run-admin-actions{justify-content:flex-start}}
@media(max-width:640px){header{flex-direction:column;align-items:flex-start;gap:12px}nav{display:flex;flex-wrap:wrap;gap:10px}nav a{margin-left:0}header nav a.btn{margin-left:0}.home-copy h2{max-width:none}.hero-actions{flex-direction:column;align-items:stretch}.hero-actions .btn,.roulette-btn{width:100%;text-align:center}.list-hero-panel,.list-controls-panel,.list-table-panel,.roulette-panel,.roulette-rules-card,.run-panel,.run-side-panel,.submission-panel{padding:22px 18px}.table-hint{text-align:left}.run-admin-actions{justify-content:flex-start}}