worker.js:
- Store join submissions in new 'members' D1 table (in addition to email)
- Add GET /api/admin/members — full member list, password gated
- Add GET /api/admin/stats — member counts, by-state breakdown, recent
signups, top petitions, site stats; all password gated ('dictator')
admin.html:
- Password gate → full dashboard
- AEC progress bar (X/2000 with % and members-to-go)
- Stat cards: total members, this week, site users, proposals, votes
- State breakdown with mini bars
- Recent signups (last 10, NEW badge for this week)
- Top 10 petitions by signature count
- Full searchable/filterable/sortable member table with NEW badges
- CSV export (respects active search + state filter)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
399 lines
18 KiB
HTML
399 lines
18 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
<title>RADICAL — Admin</title>
|
|
<meta name="robots" content="noindex,nofollow">
|
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
<link href="https://fonts.googleapis.com/css2?family=Roboto+Mono:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
|
<style>
|
|
*{box-sizing:border-box;margin:0;padding:0}
|
|
:root{--pk:#ff0099;--bg:#000;--card:#0a0a0a;--border:#222;--text:#ccc;--muted:#555}
|
|
body{font-family:'Roboto Mono',monospace;background:var(--bg);color:var(--text);min-height:100vh;padding:0}
|
|
|
|
/* ── Auth gate ── */
|
|
#auth-gate{display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:100vh;gap:20px;padding:20px}
|
|
#auth-gate h1{color:var(--pk);font-size:2.5rem;letter-spacing:4px}
|
|
#auth-gate p{color:var(--muted);font-size:13px}
|
|
#auth-form{display:flex;flex-direction:column;gap:10px;width:100%;max-width:320px}
|
|
#pw-input{background:#0a0a0a;border:1px solid var(--border);color:#fff;padding:12px 14px;font-family:'Roboto Mono',monospace;font-size:14px;letter-spacing:2px}
|
|
#pw-input:focus{outline:none;border-color:var(--pk)}
|
|
#auth-btn{background:var(--pk);color:#000;border:none;padding:12px;font-family:'Roboto Mono',monospace;font-size:14px;font-weight:700;cursor:pointer;letter-spacing:1px}
|
|
#auth-btn:hover{background:#ff33aa}
|
|
#auth-error{color:var(--pk);font-size:12px;min-height:16px;text-align:center}
|
|
|
|
/* ── Dashboard ── */
|
|
#dashboard{display:none;padding:0 0 60px}
|
|
.top-bar{background:#0a0a0a;border-bottom:2px solid var(--pk);padding:14px 24px;display:flex;align-items:center;justify-content:space-between;position:sticky;top:0;z-index:100}
|
|
.top-bar-title{color:var(--pk);font-size:18px;font-weight:700;letter-spacing:2px}
|
|
.top-bar-right{display:flex;gap:12px;align-items:center}
|
|
.logout-btn{background:none;border:1px solid var(--muted);color:var(--muted);padding:5px 12px;font-family:'Roboto Mono',monospace;font-size:11px;cursor:pointer}
|
|
.logout-btn:hover{border-color:var(--pk);color:var(--pk)}
|
|
.refresh-btn{background:none;border:1px solid var(--pk);color:var(--pk);padding:5px 12px;font-family:'Roboto Mono',monospace;font-size:11px;cursor:pointer}
|
|
.refresh-btn:hover{background:var(--pk);color:#000}
|
|
|
|
.dash-inner{max-width:1200px;margin:0 auto;padding:24px}
|
|
|
|
/* ── Stat cards ── */
|
|
.stat-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px;margin-bottom:28px}
|
|
.stat-card{background:var(--card);border:1px solid var(--border);padding:16px 18px}
|
|
.stat-label{font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:1px;margin-bottom:6px}
|
|
.stat-value{font-size:32px;font-weight:700;color:#fff}
|
|
.stat-value.pink{color:var(--pk)}
|
|
.stat-sub{font-size:11px;color:var(--muted);margin-top:4px}
|
|
|
|
/* ── AEC progress ── */
|
|
.aec-bar-section{background:var(--card);border:1px solid var(--pk);padding:20px;margin-bottom:28px}
|
|
.aec-bar-header{display:flex;justify-content:space-between;align-items:baseline;margin-bottom:10px}
|
|
.aec-bar-title{font-size:13px;font-weight:700;color:#fff;text-transform:uppercase;letter-spacing:1px}
|
|
.aec-bar-count{font-size:22px;font-weight:700;color:var(--pk)}
|
|
.aec-track{background:#111;height:20px;width:100%;position:relative;overflow:hidden}
|
|
.aec-fill{background:var(--pk);height:100%;transition:width .6s ease;position:relative}
|
|
.aec-fill::after{content:'';position:absolute;top:0;right:0;bottom:0;width:2px;background:#fff;opacity:.5}
|
|
.aec-labels{display:flex;justify-content:space-between;font-size:10px;color:var(--muted);margin-top:5px}
|
|
.aec-pct{font-size:11px;color:var(--pk);margin-top:6px;text-align:right}
|
|
|
|
/* ── Section heads ── */
|
|
.section-head{font-size:11px;text-transform:uppercase;letter-spacing:2px;color:var(--muted);border-bottom:1px solid var(--border);padding-bottom:8px;margin-bottom:14px}
|
|
|
|
/* ── State breakdown ── */
|
|
.state-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:8px;margin-bottom:28px}
|
|
.state-bar-card{background:var(--card);border:1px solid var(--border);padding:10px 12px}
|
|
.state-name{font-size:12px;color:#fff;font-weight:700;margin-bottom:5px}
|
|
.state-track{background:#111;height:6px;border-radius:0}
|
|
.state-fill{background:var(--pk);height:6px}
|
|
.state-count{font-size:11px;color:var(--muted);margin-top:4px}
|
|
|
|
/* ── Two-col layout ── */
|
|
.two-col{display:grid;grid-template-columns:1fr 1fr;gap:20px;margin-bottom:28px}
|
|
@media(max-width:768px){.two-col{grid-template-columns:1fr}}
|
|
|
|
/* ── Tables ── */
|
|
.table-section{margin-bottom:28px}
|
|
.table-toolbar{display:flex;gap:10px;margin-bottom:10px;flex-wrap:wrap;align-items:center}
|
|
.search-box{background:#0a0a0a;border:1px solid var(--border);color:#fff;padding:7px 10px;font-family:'Roboto Mono',monospace;font-size:12px;flex:1;min-width:160px}
|
|
.search-box:focus{outline:none;border-color:var(--pk)}
|
|
.csv-btn{background:none;border:1px solid var(--pk);color:var(--pk);padding:7px 14px;font-family:'Roboto Mono',monospace;font-size:11px;cursor:pointer;white-space:nowrap}
|
|
.csv-btn:hover{background:var(--pk);color:#000}
|
|
.state-filter{background:#0a0a0a;border:1px solid var(--border);color:#fff;padding:7px 10px;font-family:'Roboto Mono',monospace;font-size:12px}
|
|
.state-filter:focus{outline:none;border-color:var(--pk)}
|
|
|
|
.data-table{width:100%;border-collapse:collapse;font-size:11px}
|
|
.data-table th{background:#111;color:var(--muted);text-transform:uppercase;letter-spacing:.5px;padding:8px 10px;text-align:left;border-bottom:1px solid var(--border);white-space:nowrap;cursor:pointer;user-select:none}
|
|
.data-table th:hover{color:var(--pk)}
|
|
.data-table td{padding:8px 10px;border-bottom:1px solid #111;vertical-align:top;color:var(--text)}
|
|
.data-table tr:hover td{background:#0d0d0d}
|
|
.data-table .name-cell{color:#fff;font-weight:500}
|
|
.data-table .email-cell{color:var(--pk)}
|
|
.data-table .new-badge{background:var(--pk);color:#000;font-size:9px;padding:1px 5px;font-weight:700;margin-left:6px;vertical-align:middle}
|
|
.table-empty{text-align:center;padding:30px;color:var(--muted)}
|
|
.table-count{font-size:11px;color:var(--muted);margin-left:auto}
|
|
|
|
/* ── Petition list ── */
|
|
.petition-row{display:flex;gap:10px;align-items:center;padding:8px 10px;border-bottom:1px solid #111}
|
|
.petition-row:hover{background:#0d0d0d}
|
|
.petition-rank{color:var(--pk);font-weight:700;font-size:13px;min-width:24px}
|
|
.petition-text{flex:1;font-size:12px;color:var(--text);line-height:1.4}
|
|
.petition-sigs{font-size:13px;font-weight:700;color:var(--pk);white-space:nowrap}
|
|
|
|
/* ── Recent signups ── */
|
|
.recent-row{display:flex;justify-content:space-between;align-items:center;padding:7px 10px;border-bottom:1px solid #111}
|
|
.recent-row:hover{background:#0d0d0d}
|
|
.recent-name{font-size:12px;color:#fff;font-weight:500}
|
|
.recent-loc{font-size:11px;color:var(--muted)}
|
|
.recent-time{font-size:11px;color:var(--muted);white-space:nowrap}
|
|
|
|
.loading{text-align:center;padding:40px;color:var(--muted)}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<!-- Auth gate -->
|
|
<div id="auth-gate">
|
|
<h1>RADICAL</h1>
|
|
<p>Admin access only</p>
|
|
<form id="auth-form" onsubmit="login(event)">
|
|
<input type="password" id="pw-input" placeholder="Password" autocomplete="current-password">
|
|
<button type="submit" id="auth-btn">ENTER →</button>
|
|
<div id="auth-error"></div>
|
|
</form>
|
|
</div>
|
|
|
|
<!-- Dashboard -->
|
|
<div id="dashboard">
|
|
<div class="top-bar">
|
|
<div class="top-bar-title">RADICAL · ADMIN</div>
|
|
<div class="top-bar-right">
|
|
<span id="last-updated" style="font-size:11px;color:var(--muted)"></span>
|
|
<button class="refresh-btn" onclick="loadAll()">↻ Refresh</button>
|
|
<button class="logout-btn" onclick="logout()">Logout</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="dash-inner">
|
|
|
|
<!-- AEC progress bar -->
|
|
<div class="aec-bar-section">
|
|
<div class="aec-bar-header">
|
|
<span class="aec-bar-title">AEC Registration Progress</span>
|
|
<span class="aec-bar-count"><span id="aec-count">—</span> / 2,000</span>
|
|
</div>
|
|
<div class="aec-track"><div class="aec-fill" id="aec-fill" style="width:0%"></div></div>
|
|
<div class="aec-labels"><span>0</span><span>500</span><span>1,000</span><span>1,500</span><span>2,000</span></div>
|
|
<div class="aec-pct" id="aec-pct"></div>
|
|
</div>
|
|
|
|
<!-- Stat cards -->
|
|
<div class="stat-grid">
|
|
<div class="stat-card">
|
|
<div class="stat-label">Total Members</div>
|
|
<div class="stat-value pink" id="s-members">—</div>
|
|
<div class="stat-sub">of 2,000 AEC goal</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-label">This Week</div>
|
|
<div class="stat-value" id="s-week">—</div>
|
|
<div class="stat-sub">new sign-ups</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-label">Site Users</div>
|
|
<div class="stat-value" id="s-users">—</div>
|
|
<div class="stat-sub">anonymous + logged in</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-label">Proposals</div>
|
|
<div class="stat-value" id="s-proposals">—</div>
|
|
<div class="stat-sub">active petitions</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-label">Total Votes</div>
|
|
<div class="stat-value" id="s-votes">—</div>
|
|
<div class="stat-sub">all time</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- State breakdown -->
|
|
<div class="section-head">Members by State</div>
|
|
<div class="state-grid" id="state-grid"><div class="loading">Loading…</div></div>
|
|
|
|
<!-- Two-col: recent signups + top petitions -->
|
|
<div class="two-col">
|
|
<div>
|
|
<div class="section-head">Recent Sign-ups</div>
|
|
<div id="recent-list"><div class="loading">Loading…</div></div>
|
|
</div>
|
|
<div>
|
|
<div class="section-head">Top Petitions by Signatures</div>
|
|
<div id="petition-list"><div class="loading">Loading…</div></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Member table -->
|
|
<div class="table-section">
|
|
<div class="section-head">All Members</div>
|
|
<div class="table-toolbar">
|
|
<input class="search-box" id="member-search" placeholder="Search name, email, suburb…" oninput="filterMembers()">
|
|
<select class="state-filter" id="state-filter" onchange="filterMembers()">
|
|
<option value="">All states</option>
|
|
<option>NSW</option><option>VIC</option><option>QLD</option>
|
|
<option>WA</option><option>SA</option><option>TAS</option>
|
|
<option>ACT</option><option>NT</option>
|
|
</select>
|
|
<button class="csv-btn" onclick="exportCSV()">↓ Export CSV</button>
|
|
<span class="table-count" id="table-count"></span>
|
|
</div>
|
|
<div style="overflow-x:auto">
|
|
<table class="data-table" id="member-table">
|
|
<thead>
|
|
<tr>
|
|
<th onclick="sortBy('full_name')"># Name ↕</th>
|
|
<th onclick="sortBy('email')">Email ↕</th>
|
|
<th onclick="sortBy('dob')">DOB ↕</th>
|
|
<th onclick="sortBy('state')">State ↕</th>
|
|
<th onclick="sortBy('suburb')">Suburb ↕</th>
|
|
<th onclick="sortBy('postcode')">PC ↕</th>
|
|
<th>Phone</th>
|
|
<th onclick="sortBy('joined_at')">Joined ↕</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="member-tbody"><tr><td colspan="8" class="table-empty">Loading…</td></tr></tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
const API = 'https://theradicalparty.com/api';
|
|
let pw = '';
|
|
let allMembers = [];
|
|
let sortKey = 'joined_at';
|
|
let sortDir = -1;
|
|
const weekAgo = Date.now() - 7 * 24 * 60 * 60 * 1000;
|
|
|
|
function login(e) {
|
|
e.preventDefault();
|
|
const input = document.getElementById('pw-input').value.trim();
|
|
document.getElementById('auth-error').textContent = 'Checking…';
|
|
fetch(`${API}/admin/stats?password=${encodeURIComponent(input)}`)
|
|
.then(r => {
|
|
if (r.ok) {
|
|
pw = input;
|
|
document.getElementById('auth-gate').style.display = 'none';
|
|
document.getElementById('dashboard').style.display = 'block';
|
|
loadAll();
|
|
} else {
|
|
document.getElementById('auth-error').textContent = 'Wrong password.';
|
|
}
|
|
})
|
|
.catch(() => { document.getElementById('auth-error').textContent = 'Network error.'; });
|
|
}
|
|
|
|
function logout() {
|
|
pw = '';
|
|
allMembers = [];
|
|
document.getElementById('auth-gate').style.display = 'flex';
|
|
document.getElementById('dashboard').style.display = 'none';
|
|
document.getElementById('pw-input').value = '';
|
|
document.getElementById('auth-error').textContent = '';
|
|
}
|
|
|
|
async function loadAll() {
|
|
await Promise.all([loadStats(), loadMembers()]);
|
|
document.getElementById('last-updated').textContent = 'Updated ' + new Date().toLocaleTimeString('en-AU');
|
|
}
|
|
|
|
async function loadStats() {
|
|
try {
|
|
const res = await fetch(`${API}/admin/stats?password=${encodeURIComponent(pw)}`);
|
|
const d = await res.json();
|
|
|
|
// AEC bar
|
|
const pct = Math.min((d.members.total / 2000) * 100, 100);
|
|
document.getElementById('aec-count').textContent = d.members.total.toLocaleString();
|
|
document.getElementById('aec-fill').style.width = pct + '%';
|
|
document.getElementById('aec-pct').textContent = pct.toFixed(1) + '% of AEC registration target' +
|
|
(d.members.total >= 2000 ? ' — ✓ ELIGIBLE TO REGISTER' : ` — ${(2000 - d.members.total).toLocaleString()} to go`);
|
|
|
|
// Stat cards
|
|
document.getElementById('s-members').textContent = d.members.total.toLocaleString();
|
|
document.getElementById('s-week').textContent = d.members.thisWeek.toLocaleString();
|
|
document.getElementById('s-users').textContent = d.site.users.toLocaleString();
|
|
document.getElementById('s-proposals').textContent = d.site.proposals.toLocaleString();
|
|
document.getElementById('s-votes').textContent = d.site.votes.toLocaleString();
|
|
|
|
// State bars
|
|
const maxState = Math.max(...d.members.byState.map(s => s.c), 1);
|
|
document.getElementById('state-grid').innerHTML = d.members.byState.length
|
|
? d.members.byState.map(s => `
|
|
<div class="state-bar-card">
|
|
<div class="state-name">${s.state}</div>
|
|
<div class="state-track"><div class="state-fill" style="width:${(s.c/maxState*100).toFixed(1)}%"></div></div>
|
|
<div class="state-count">${s.c} member${s.c !== 1 ? 's' : ''}</div>
|
|
</div>`).join('')
|
|
: '<div style="color:var(--muted);font-size:12px">No state data yet</div>';
|
|
|
|
// Recent
|
|
document.getElementById('recent-list').innerHTML = d.members.recent.length
|
|
? d.members.recent.map(m => `
|
|
<div class="recent-row">
|
|
<div>
|
|
<div class="recent-name">${esc(m.full_name)}${m.joined_at > weekAgo ? '<span class="new-badge">NEW</span>' : ''}</div>
|
|
<div class="recent-loc">${[m.suburb, m.state].filter(Boolean).join(', ') || '—'}</div>
|
|
</div>
|
|
<div class="recent-time">${fmtDate(m.joined_at)}</div>
|
|
</div>`).join('')
|
|
: '<div class="loading">No members yet</div>';
|
|
|
|
// Top petitions
|
|
document.getElementById('petition-list').innerHTML = d.topPetitions.length
|
|
? d.topPetitions.map((p, i) => `
|
|
<div class="petition-row">
|
|
<div class="petition-rank">${i + 1}</div>
|
|
<div class="petition-text">${esc(p.text.substring(0, 100))}${p.text.length > 100 ? '…' : ''}</div>
|
|
<div class="petition-sigs">▲ ${p.signatures}</div>
|
|
</div>`).join('')
|
|
: '<div class="loading">No petitions yet</div>';
|
|
|
|
} catch(err) { console.error('Stats error:', err); }
|
|
}
|
|
|
|
async function loadMembers() {
|
|
try {
|
|
const res = await fetch(`${API}/admin/members?password=${encodeURIComponent(pw)}`);
|
|
const d = await res.json();
|
|
allMembers = d.members || [];
|
|
filterMembers();
|
|
} catch(err) { console.error('Members error:', err); }
|
|
}
|
|
|
|
function filterMembers() {
|
|
const q = document.getElementById('member-search').value.toLowerCase();
|
|
const st = document.getElementById('state-filter').value;
|
|
let rows = allMembers.filter(m => {
|
|
const match = !q || [m.full_name, m.email, m.suburb, m.postcode].some(v => v?.toLowerCase().includes(q));
|
|
const stateMatch = !st || m.state === st;
|
|
return match && stateMatch;
|
|
});
|
|
rows = [...rows].sort((a, b) => {
|
|
const av = a[sortKey] ?? '', bv = b[sortKey] ?? '';
|
|
return sortDir * (av < bv ? -1 : av > bv ? 1 : 0);
|
|
});
|
|
document.getElementById('table-count').textContent = `${rows.length} of ${allMembers.length}`;
|
|
document.getElementById('member-tbody').innerHTML = rows.length
|
|
? rows.map((m, i) => `<tr>
|
|
<td class="name-cell">${i + 1}. ${esc(m.full_name)}${m.joined_at > weekAgo ? '<span class="new-badge">NEW</span>' : ''}</td>
|
|
<td class="email-cell">${esc(m.email || '—')}</td>
|
|
<td>${m.dob || '—'}</td>
|
|
<td>${m.state || '—'}</td>
|
|
<td>${esc(m.suburb || '—')}</td>
|
|
<td>${m.postcode || '—'}</td>
|
|
<td>${esc(m.phone || '—')}</td>
|
|
<td>${fmtDate(m.joined_at)}</td>
|
|
</tr>`).join('')
|
|
: '<tr><td colspan="8" class="table-empty">No members match</td></tr>';
|
|
}
|
|
|
|
function sortBy(key) {
|
|
if (sortKey === key) sortDir *= -1;
|
|
else { sortKey = key; sortDir = 1; }
|
|
filterMembers();
|
|
}
|
|
|
|
function exportCSV() {
|
|
const q = document.getElementById('member-search').value.toLowerCase();
|
|
const st = document.getElementById('state-filter').value;
|
|
const rows = allMembers.filter(m => {
|
|
const match = !q || [m.full_name, m.email, m.suburb, m.postcode].some(v => v?.toLowerCase().includes(q));
|
|
return match && (!st || m.state === st);
|
|
});
|
|
const cols = ['full_name','email','dob','address','suburb','state','postcode','phone','joined_at'];
|
|
const header = 'Full Name,Email,DOB,Address,Suburb,State,Postcode,Phone,Joined';
|
|
const csv = [header, ...rows.map(m =>
|
|
cols.map(c => c === 'joined_at' ? new Date(m[c]).toLocaleString('en-AU') : `"${(m[c] || '').replace(/"/g,'""')}"`).join(',')
|
|
)].join('\n');
|
|
const a = document.createElement('a');
|
|
a.href = 'data:text/csv;charset=utf-8,' + encodeURIComponent(csv);
|
|
a.download = `radical-members-${new Date().toISOString().split('T')[0]}.csv`;
|
|
a.click();
|
|
}
|
|
|
|
function fmtDate(ts) {
|
|
if (!ts) return '—';
|
|
const d = new Date(ts);
|
|
const diff = Date.now() - ts;
|
|
if (diff < 60000) return 'just now';
|
|
if (diff < 3600000) return Math.floor(diff/60000) + 'm ago';
|
|
if (diff < 86400000) return Math.floor(diff/3600000) + 'h ago';
|
|
if (diff < 7*86400000) return Math.floor(diff/86400000) + 'd ago';
|
|
return d.toLocaleDateString('en-AU');
|
|
}
|
|
|
|
function esc(s) {
|
|
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>
|