valley-commons/admin.html

948 lines
32 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Admin - Valley of the Commons</title>
<link rel="icon" type="image/svg+xml" href="icon.svg">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet">
<style>
:root {
--forest: #2d5016;
--forest-light: #4a7c23;
--cream: #faf8f5;
--sand: #f5f5f0;
--charcoal: #2c2c2c;
--pending: #f59e0b;
--reviewing: #3b82f6;
--accepted: #10b981;
--declined: #ef4444;
--waitlisted: #8b5cf6;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Inter', -apple-system, sans-serif;
background: var(--sand);
color: var(--charcoal);
line-height: 1.5;
}
/* Auth screen */
.auth-screen {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
}
.auth-box {
background: white;
padding: 2rem;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
width: 100%;
max-width: 400px;
}
.auth-box h1 {
color: var(--forest);
margin-bottom: 1.5rem;
font-size: 1.5rem;
}
.auth-box input {
width: 100%;
padding: 0.75rem;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 1rem;
margin-bottom: 1rem;
}
.auth-box button {
width: 100%;
padding: 0.75rem;
background: var(--forest);
color: white;
border: none;
border-radius: 6px;
font-size: 1rem;
cursor: pointer;
}
.auth-box button:hover {
background: var(--forest-light);
}
.auth-error {
color: var(--declined);
margin-bottom: 1rem;
font-size: 0.875rem;
}
/* Dashboard */
.dashboard {
display: none;
}
.dashboard.visible {
display: block;
}
.header {
background: white;
padding: 1rem 2rem;
border-bottom: 1px solid #ddd;
display: flex;
justify-content: space-between;
align-items: center;
}
.header h1 {
color: var(--forest);
font-size: 1.25rem;
}
.header-actions {
display: flex;
gap: 1rem;
align-items: center;
}
.btn-logout {
background: none;
border: 1px solid #ddd;
padding: 0.5rem 1rem;
border-radius: 6px;
cursor: pointer;
font-size: 0.875rem;
}
.container {
max-width: 1400px;
margin: 0 auto;
padding: 2rem;
}
/* Stats */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 1rem;
margin-bottom: 2rem;
}
.stat-card {
background: white;
padding: 1.5rem;
border-radius: 8px;
text-align: center;
}
.stat-card .number {
font-size: 2rem;
font-weight: 600;
color: var(--forest);
}
.stat-card .label {
font-size: 0.875rem;
color: #666;
}
/* Filters */
.filters {
display: flex;
gap: 1rem;
margin-bottom: 1.5rem;
flex-wrap: wrap;
}
.filter-btn {
padding: 0.5rem 1rem;
border: 1px solid #ddd;
background: white;
border-radius: 20px;
cursor: pointer;
font-size: 0.875rem;
transition: all 0.2s;
}
.filter-btn.active {
background: var(--forest);
color: white;
border-color: var(--forest);
}
.filter-btn .count {
background: var(--sand);
padding: 0 0.5rem;
border-radius: 10px;
margin-left: 0.5rem;
font-size: 0.75rem;
}
.filter-btn.active .count {
background: rgba(255,255,255,0.2);
}
/* Table */
.table-container {
background: white;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
}
table {
width: 100%;
border-collapse: collapse;
}
th, td {
padding: 1rem;
text-align: left;
border-bottom: 1px solid #eee;
}
th {
background: var(--sand);
font-weight: 500;
font-size: 0.875rem;
color: #666;
}
tr:hover {
background: #fafafa;
}
.status-badge {
display: inline-block;
padding: 0.25rem 0.75rem;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 500;
text-transform: uppercase;
}
.status-pending { background: #fef3c7; color: #92400e; }
.status-reviewing { background: #dbeafe; color: #1e40af; }
.status-accepted { background: #d1fae5; color: #065f46; }
.status-declined { background: #fee2e2; color: #991b1b; }
.status-waitlisted { background: #ede9fe; color: #5b21b6; }
.applicant-name {
font-weight: 500;
}
.applicant-email {
font-size: 0.875rem;
color: #666;
}
.view-btn {
background: var(--forest);
color: white;
border: none;
padding: 0.375rem 0.75rem;
border-radius: 4px;
cursor: pointer;
font-size: 0.875rem;
}
.view-btn:hover {
background: var(--forest-light);
}
/* Modal */
.modal-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.5);
z-index: 1000;
align-items: center;
justify-content: center;
padding: 2rem;
}
.modal-overlay.visible {
display: flex;
}
.modal {
background: white;
border-radius: 12px;
width: 100%;
max-width: 800px;
max-height: 90vh;
overflow-y: auto;
}
.modal-header {
padding: 1.5rem;
border-bottom: 1px solid #eee;
display: flex;
justify-content: space-between;
align-items: center;
position: sticky;
top: 0;
background: white;
}
.modal-header h2 {
font-size: 1.25rem;
}
.modal-close {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: #666;
}
.modal-body {
padding: 1.5rem;
}
.detail-section {
margin-bottom: 2rem;
}
.detail-section h3 {
color: var(--forest);
font-size: 1rem;
margin-bottom: 1rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid #eee;
}
.detail-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
}
.detail-item {
padding: 0.5rem 0;
}
.detail-item label {
display: block;
font-size: 0.75rem;
color: #666;
text-transform: uppercase;
margin-bottom: 0.25rem;
}
.detail-item p {
font-size: 0.9rem;
}
.detail-item.full-width {
grid-column: 1 / -1;
}
.long-text {
background: var(--sand);
padding: 1rem;
border-radius: 6px;
white-space: pre-wrap;
}
.chip-list {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.chip {
background: var(--sand);
padding: 0.25rem 0.75rem;
border-radius: 12px;
font-size: 0.875rem;
}
.modal-footer {
padding: 1.5rem;
border-top: 1px solid #eee;
display: flex;
gap: 1rem;
justify-content: flex-end;
position: sticky;
bottom: 0;
background: white;
}
.status-select {
padding: 0.5rem 1rem;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 0.875rem;
}
.btn-save {
background: var(--forest);
color: white;
border: none;
padding: 0.5rem 1.5rem;
border-radius: 6px;
cursor: pointer;
}
/* Loading */
.loading {
text-align: center;
padding: 3rem;
color: #666;
}
/* Empty state */
.empty-state {
text-align: center;
padding: 3rem;
color: #666;
}
@media (max-width: 768px) {
.detail-grid {
grid-template-columns: 1fr;
}
.stats-grid {
grid-template-columns: repeat(2, 1fr);
}
table {
font-size: 0.875rem;
}
th, td {
padding: 0.75rem 0.5rem;
}
}
</style>
</head>
<body>
<!-- Auth Screen -->
<div class="auth-screen" id="auth-screen">
<div class="auth-box">
<h1>Admin Login</h1>
<p id="auth-error" class="auth-error" style="display: none;"></p>
<input type="password" id="api-key" placeholder="Enter admin API key">
<button onclick="login()">Login</button>
</div>
</div>
<!-- Dashboard -->
<div class="dashboard" id="dashboard">
<header class="header">
<h1>Valley of the Commons - Applications</h1>
<div class="header-actions">
<button class="btn-logout" onclick="exportCSV()">Export CSV</button>
<button class="btn-logout" onclick="logout()">Logout</button>
</div>
</header>
<div class="container">
<div class="stats-grid" id="stats">
<div class="stat-card">
<div class="number" id="stat-total">-</div>
<div class="label">Total Applications</div>
</div>
<div class="stat-card">
<div class="number" id="stat-pending">-</div>
<div class="label">Pending</div>
</div>
<div class="stat-card">
<div class="number" id="stat-reviewing">-</div>
<div class="label">Reviewing</div>
</div>
<div class="stat-card">
<div class="number" id="stat-accepted">-</div>
<div class="label">Accepted</div>
</div>
<div class="stat-card">
<div class="number" id="stat-waitlisted">-</div>
<div class="label">Waitlisted</div>
</div>
<div class="stat-card">
<div class="number" id="stat-declined">-</div>
<div class="label">Declined</div>
</div>
</div>
<div class="filters">
<button class="filter-btn active" data-status="all" onclick="filterByStatus('all')">
All <span class="count" id="count-all">0</span>
</button>
<button class="filter-btn" data-status="pending" onclick="filterByStatus('pending')">
Pending <span class="count" id="count-pending">0</span>
</button>
<button class="filter-btn" data-status="reviewing" onclick="filterByStatus('reviewing')">
Reviewing <span class="count" id="count-reviewing">0</span>
</button>
<button class="filter-btn" data-status="accepted" onclick="filterByStatus('accepted')">
Accepted <span class="count" id="count-accepted">0</span>
</button>
<button class="filter-btn" data-status="waitlisted" onclick="filterByStatus('waitlisted')">
Waitlisted <span class="count" id="count-waitlisted">0</span>
</button>
<button class="filter-btn" data-status="declined" onclick="filterByStatus('declined')">
Declined <span class="count" id="count-declined">0</span>
</button>
</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>Applicant</th>
<th>Location</th>
<th>Attendance</th>
<th>Submitted</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody id="applications-table">
<tr>
<td colspan="6" class="loading">Loading applications...</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Application Detail Modal -->
<div class="modal-overlay" id="modal-overlay" onclick="closeModal(event)">
<div class="modal" onclick="event.stopPropagation()">
<div class="modal-header">
<h2 id="modal-title">Application Details</h2>
<button class="modal-close" onclick="closeModal()">&times;</button>
</div>
<div class="modal-body" id="modal-body">
<!-- Populated by JavaScript -->
</div>
<div class="modal-footer">
<select class="status-select" id="status-select">
<option value="pending">Pending</option>
<option value="reviewing">Reviewing</option>
<option value="accepted">Accepted</option>
<option value="waitlisted">Waitlisted</option>
<option value="declined">Declined</option>
</select>
<button class="btn-save" onclick="updateStatus()">Update Status</button>
</div>
</div>
</div>
<script>
let apiKey = localStorage.getItem('votc_admin_key');
let applications = [];
let currentFilter = 'all';
let currentApplicationId = null;
// Check if already logged in
if (apiKey) {
showDashboard();
loadApplications();
}
function login() {
const key = document.getElementById('api-key').value;
if (!key) {
showAuthError('Please enter an API key');
return;
}
apiKey = key;
localStorage.setItem('votc_admin_key', key);
showDashboard();
loadApplications();
}
function logout() {
apiKey = null;
localStorage.removeItem('votc_admin_key');
document.getElementById('auth-screen').style.display = 'flex';
document.getElementById('dashboard').classList.remove('visible');
}
function showAuthError(message) {
const errorEl = document.getElementById('auth-error');
errorEl.textContent = message;
errorEl.style.display = 'block';
}
function showDashboard() {
document.getElementById('auth-screen').style.display = 'none';
document.getElementById('dashboard').classList.add('visible');
}
async function loadApplications() {
try {
const response = await fetch('/api/application?limit=500', {
headers: {
'Authorization': `Bearer ${apiKey}`
}
});
if (response.status === 401) {
logout();
showAuthError('Invalid API key');
return;
}
const data = await response.json();
applications = data.applications || [];
updateStats();
renderTable();
} catch (error) {
console.error('Failed to load applications:', error);
document.getElementById('applications-table').innerHTML =
'<tr><td colspan="6" class="empty-state">Failed to load applications</td></tr>';
}
}
function updateStats() {
const counts = {
total: applications.length,
pending: 0,
reviewing: 0,
accepted: 0,
waitlisted: 0,
declined: 0
};
applications.forEach(app => {
if (counts.hasOwnProperty(app.status)) {
counts[app.status]++;
}
});
document.getElementById('stat-total').textContent = counts.total;
document.getElementById('stat-pending').textContent = counts.pending;
document.getElementById('stat-reviewing').textContent = counts.reviewing;
document.getElementById('stat-accepted').textContent = counts.accepted;
document.getElementById('stat-waitlisted').textContent = counts.waitlisted;
document.getElementById('stat-declined').textContent = counts.declined;
document.getElementById('count-all').textContent = counts.total;
document.getElementById('count-pending').textContent = counts.pending;
document.getElementById('count-reviewing').textContent = counts.reviewing;
document.getElementById('count-accepted').textContent = counts.accepted;
document.getElementById('count-waitlisted').textContent = counts.waitlisted;
document.getElementById('count-declined').textContent = counts.declined;
}
function filterByStatus(status) {
currentFilter = status;
document.querySelectorAll('.filter-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.status === status);
});
renderTable();
}
function renderTable() {
const tbody = document.getElementById('applications-table');
let filtered = applications;
if (currentFilter !== 'all') {
filtered = applications.filter(app => app.status === currentFilter);
}
if (filtered.length === 0) {
tbody.innerHTML = '<tr><td colspan="6" class="empty-state">No applications found</td></tr>';
return;
}
tbody.innerHTML = filtered.map(app => `
<tr>
<td>
<div class="applicant-name">${app.first_name} ${app.last_name}</div>
<div class="applicant-email">${app.email}</div>
</td>
<td>${app.city || ''}, ${app.country || ''}</td>
<td>${app.attendance_type === 'full' ? 'Full' : 'Partial'}</td>
<td>${new Date(app.submitted_at).toLocaleDateString()}</td>
<td><span class="status-badge status-${app.status}">${app.status}</span></td>
<td><button class="view-btn" onclick="viewApplication('${app.id}')">View</button></td>
</tr>
`).join('');
}
function viewApplication(id) {
const app = applications.find(a => a.id === id);
if (!app) return;
currentApplicationId = id;
document.getElementById('modal-title').textContent = `${app.first_name} ${app.last_name}`;
document.getElementById('status-select').value = app.status;
const body = document.getElementById('modal-body');
body.innerHTML = `
<div class="detail-section">
<h3>Personal Information</h3>
<div class="detail-grid">
<div class="detail-item">
<label>Email</label>
<p><a href="mailto:${app.email}">${app.email}</a></p>
</div>
<div class="detail-item">
<label>Phone</label>
<p>${app.phone || '-'}</p>
</div>
<div class="detail-item">
<label>Location</label>
<p>${app.city || ''} ${app.country ? ', ' + app.country : ''}</p>
</div>
<div class="detail-item">
<label>Pronouns</label>
<p>${app.pronouns || '-'}</p>
</div>
</div>
</div>
<div class="detail-section">
<h3>Background</h3>
<div class="detail-grid">
<div class="detail-item">
<label>Occupation</label>
<p>${app.occupation || '-'}</p>
</div>
<div class="detail-item">
<label>Organization</label>
<p>${app.organization || '-'}</p>
</div>
<div class="detail-item full-width">
<label>Skills</label>
<div class="chip-list">
${(app.skills || []).map(s => `<span class="chip">${s}</span>`).join('') || '-'}
</div>
</div>
<div class="detail-item">
<label>Languages</label>
<p>${(app.languages || []).join(', ') || '-'}</p>
</div>
<div class="detail-item">
<label>Website</label>
<p>${app.website ? `<a href="${app.website}" target="_blank">${app.website}</a>` : '-'}</p>
</div>
</div>
</div>
<div class="detail-section">
<h3>Participation</h3>
<div class="detail-grid">
<div class="detail-item">
<label>Attendance</label>
<p>${app.attendance_type === 'full' ? 'Full 4 weeks' : 'Partial'}</p>
</div>
<div class="detail-item">
<label>Dates</label>
<p>${app.arrival_date || 'Aug 24'} to ${app.departure_date || 'Sep 20'}</p>
</div>
<div class="detail-item">
<label>Accommodation</label>
<p>${app.accommodation_preference || '-'}</p>
</div>
<div class="detail-item">
<label>Dietary</label>
<p>${(app.dietary_requirements || []).join(', ') || 'None specified'}</p>
</div>
</div>
</div>
<div class="detail-section">
<h3>Motivation & Contribution</h3>
<div class="detail-grid">
<div class="detail-item full-width">
<label>Why do you want to join?</label>
<div class="long-text">${app.motivation || '-'}</div>
</div>
<div class="detail-item full-width">
<label>What will you contribute?</label>
<div class="long-text">${app.contribution || '-'}</div>
</div>
<div class="detail-item full-width">
<label>Workshops to offer</label>
<div class="long-text">${app.workshops_offer || '-'}</div>
</div>
<div class="detail-item full-width">
<label>Areas of Interest</label>
<div class="chip-list">
${(app.governance_interest || []).map(g => `<span class="chip">${g}</span>`).join('') || '-'}
</div>
</div>
<div class="detail-item full-width">
<label>Commons/Community Experience</label>
<div class="long-text">${app.commons_experience || '-'}</div>
</div>
</div>
</div>
<div class="detail-section">
<h3>Financial & Practical</h3>
<div class="detail-grid">
<div class="detail-item">
<label>Contribution Level</label>
<p>${app.contribution_amount || '-'}</p>
</div>
<div class="detail-item">
<label>Scholarship</label>
<p>${app.scholarship_needed ? 'Yes' : 'No'}</p>
</div>
${app.scholarship_reason ? `
<div class="detail-item full-width">
<label>Scholarship Reason</label>
<div class="long-text">${app.scholarship_reason}</div>
</div>
` : ''}
<div class="detail-item">
<label>How Heard</label>
<p>${app.how_heard || '-'}</p>
</div>
<div class="detail-item">
<label>Referral</label>
<p>${app.referral_name || '-'}</p>
</div>
</div>
</div>
<div class="detail-section">
<h3>Emergency Contact</h3>
<div class="detail-grid">
<div class="detail-item">
<label>Name</label>
<p>${app.emergency_name || '-'}</p>
</div>
<div class="detail-item">
<label>Phone</label>
<p>${app.emergency_phone || '-'}</p>
</div>
<div class="detail-item">
<label>Relationship</label>
<p>${app.emergency_relationship || '-'}</p>
</div>
</div>
</div>
<div class="detail-section">
<h3>Agreements</h3>
<div class="detail-grid">
<div class="detail-item">
<label>Code of Conduct</label>
<p>${app.code_of_conduct_accepted ? '✓ Accepted' : '✗ Not accepted'}</p>
</div>
<div class="detail-item">
<label>Privacy Policy</label>
<p>${app.privacy_policy_accepted ? '✓ Accepted' : '✗ Not accepted'}</p>
</div>
<div class="detail-item">
<label>Photo Consent</label>
<p>${app.photo_consent ? '✓ Yes' : '✗ No'}</p>
</div>
</div>
</div>
<div class="detail-section" style="font-size: 0.8rem; color: #666;">
<p>Submitted: ${new Date(app.submitted_at).toLocaleString()}</p>
<p>Application ID: ${app.id}</p>
</div>
`;
document.getElementById('modal-overlay').classList.add('visible');
}
function closeModal(event) {
if (event && event.target !== event.currentTarget) return;
document.getElementById('modal-overlay').classList.remove('visible');
currentApplicationId = null;
}
async function updateStatus() {
if (!currentApplicationId) return;
const newStatus = document.getElementById('status-select').value;
try {
const response = await fetch(`/api/application/${currentApplicationId}`, {
method: 'PATCH',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ status: newStatus })
});
if (response.ok) {
// Update local data
const app = applications.find(a => a.id === currentApplicationId);
if (app) app.status = newStatus;
updateStats();
renderTable();
closeModal();
} else {
alert('Failed to update status');
}
} catch (error) {
console.error('Failed to update status:', error);
alert('Failed to update status');
}
}
function exportCSV() {
const headers = [
'First Name', 'Last Name', 'Email', 'Phone', 'City', 'Country',
'Occupation', 'Organization', 'Attendance', 'Accommodation',
'Contribution Level', 'Scholarship', 'Status', 'Submitted'
];
const rows = applications.map(app => [
app.first_name,
app.last_name,
app.email,
app.phone || '',
app.city || '',
app.country || '',
app.occupation || '',
app.organization || '',
app.attendance_type,
app.accommodation_preference || '',
app.contribution_amount || '',
app.scholarship_needed ? 'Yes' : 'No',
app.status,
new Date(app.submitted_at).toISOString()
]);
const csv = [headers, ...rows]
.map(row => row.map(cell => `"${String(cell).replace(/"/g, '""')}"`).join(','))
.join('\n');
const blob = new Blob([csv], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `votc-applications-${new Date().toISOString().split('T')[0]}.csv`;
a.click();
URL.revokeObjectURL(url);
}
// Close modal on escape key
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') closeModal();
});
</script>
</body>
</html>