added admin page
235
admin.html
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Admin - Wesley High School Fundraiser</title>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/images/favicon.svg" />
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- Header -->
|
||||||
|
<header class="admin-header">
|
||||||
|
<div class="admin-header-inner">
|
||||||
|
<div class="admin-header-left">
|
||||||
|
<img src="/images/whs-logo.svg" alt="Wesley High School" class="admin-logo" />
|
||||||
|
<span class="admin-badge">Admin</span>
|
||||||
|
</div>
|
||||||
|
<div class="admin-header-right">
|
||||||
|
<a href="/" class="admin-back-link">Back to Site</a>
|
||||||
|
<button class="btn btn-sm" id="logoutBtn" style="display:none">Sign Out</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Auth Gate -->
|
||||||
|
<div class="admin-auth" id="authSection">
|
||||||
|
<div class="admin-auth-card">
|
||||||
|
<h1>Admin Login</h1>
|
||||||
|
<p>Sign in to manage donors and import data.</p>
|
||||||
|
<div class="admin-auth-buttons">
|
||||||
|
<button class="btn btn-google" id="loginGoogle">
|
||||||
|
<svg viewBox="0 0 24 24" width="18" height="18"><path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 01-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z"/><path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/><path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/><path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/></svg>
|
||||||
|
Sign in with Google
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Unauthorized -->
|
||||||
|
<div class="admin-auth hidden" id="unauthorizedSection">
|
||||||
|
<div class="admin-auth-card">
|
||||||
|
<h1>Access Denied</h1>
|
||||||
|
<p>Your account is not authorized to access the admin panel.</p>
|
||||||
|
<button class="btn btn-primary" id="unauthorizedLogout">Sign Out</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Admin Content -->
|
||||||
|
<main class="admin-main hidden" id="adminContent">
|
||||||
|
<!-- Stats -->
|
||||||
|
<div class="admin-stats">
|
||||||
|
<div class="stat-card">
|
||||||
|
<span class="stat-value" id="statTotal">$0</span>
|
||||||
|
<span class="stat-label">Total Raised</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<span class="stat-value" id="statCount">0</span>
|
||||||
|
<span class="stat-label">Donors</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tabs -->
|
||||||
|
<div class="admin-tabs">
|
||||||
|
<button class="admin-tab active" data-tab="add">Add Donor</button>
|
||||||
|
<button class="admin-tab" data-tab="import">CSV Import</button>
|
||||||
|
<button class="admin-tab" data-tab="list">Donor List</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add Donor Panel -->
|
||||||
|
<div class="admin-panel active" id="panel-add">
|
||||||
|
<h2>Add a Donor</h2>
|
||||||
|
<form class="admin-form" id="addDonorForm">
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="donorName">Name</label>
|
||||||
|
<input type="text" id="donorName" placeholder="Donor name" required />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="donorAmount">Amount ($)</label>
|
||||||
|
<input type="number" id="donorAmount" placeholder="0.00" min="0" step="0.01" required />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="donorClassYear">Class Year</label>
|
||||||
|
<input type="text" id="donorClassYear" placeholder="e.g. 1985" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="donorDate">Date</label>
|
||||||
|
<input type="date" id="donorDate" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="donorMessage">Message</label>
|
||||||
|
<textarea id="donorMessage" placeholder="Optional message..." rows="2"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="form-check">
|
||||||
|
<input type="checkbox" id="donorAnonymous" />
|
||||||
|
<label for="donorAnonymous">Anonymous donation</label>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary" id="addDonorBtn">Add Donor</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- CSV Import Panel -->
|
||||||
|
<div class="admin-panel" id="panel-import">
|
||||||
|
<h2>Import Donors from CSV</h2>
|
||||||
|
<p class="panel-desc">Upload a CSV file with donor data. Map the columns to the correct fields, preview, then import.</p>
|
||||||
|
|
||||||
|
<div class="csv-upload">
|
||||||
|
<label class="csv-upload-label" for="csvFile">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="32" height="32"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
|
||||||
|
<span>Choose CSV file or drag & drop</span>
|
||||||
|
<input type="file" id="csvFile" accept=".csv" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Column Mapping (hidden until file loaded) -->
|
||||||
|
<div class="csv-mapping hidden" id="csvMapping">
|
||||||
|
<h3>Map Columns</h3>
|
||||||
|
<div class="mapping-grid">
|
||||||
|
<div class="mapping-row">
|
||||||
|
<label>Name</label>
|
||||||
|
<select id="mapName"></select>
|
||||||
|
</div>
|
||||||
|
<div class="mapping-row">
|
||||||
|
<label>Amount</label>
|
||||||
|
<select id="mapAmount"></select>
|
||||||
|
</div>
|
||||||
|
<div class="mapping-row">
|
||||||
|
<label>Class Year</label>
|
||||||
|
<select id="mapClassYear"></select>
|
||||||
|
</div>
|
||||||
|
<div class="mapping-row">
|
||||||
|
<label>Message</label>
|
||||||
|
<select id="mapMessage"></select>
|
||||||
|
</div>
|
||||||
|
<div class="mapping-row">
|
||||||
|
<label>Date</label>
|
||||||
|
<select id="mapDate"></select>
|
||||||
|
</div>
|
||||||
|
<div class="mapping-row">
|
||||||
|
<label>Anonymous</label>
|
||||||
|
<select id="mapAnonymous"></select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Preview -->
|
||||||
|
<h3>Preview <span class="preview-count" id="previewCount"></span></h3>
|
||||||
|
<div class="csv-preview-wrap">
|
||||||
|
<table class="csv-preview" id="csvPreview">
|
||||||
|
<thead id="csvPreviewHead"></thead>
|
||||||
|
<tbody id="csvPreviewBody"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="csv-actions">
|
||||||
|
<button class="btn btn-primary" id="importBtn">Import All</button>
|
||||||
|
<button class="btn btn-outline" id="csvCancelBtn">Cancel</button>
|
||||||
|
</div>
|
||||||
|
<div class="import-progress hidden" id="importProgress">
|
||||||
|
<div class="progress-bar"><div class="progress-fill" id="progressFill"></div></div>
|
||||||
|
<span id="progressText">Importing...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Donor List Panel -->
|
||||||
|
<div class="admin-panel" id="panel-list">
|
||||||
|
<h2>All Donors</h2>
|
||||||
|
<div class="admin-search">
|
||||||
|
<input type="text" id="donorSearch" placeholder="Search donors..." />
|
||||||
|
</div>
|
||||||
|
<div class="admin-table-wrap">
|
||||||
|
<table class="admin-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Amount</th>
|
||||||
|
<th>Class Year</th>
|
||||||
|
<th>Date</th>
|
||||||
|
<th>Message</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="donorTableBody"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="empty-state hidden" id="donorListEmpty">
|
||||||
|
<p>No donors found.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- Edit Modal -->
|
||||||
|
<div class="modal-overlay hidden" id="editModal">
|
||||||
|
<div class="modal">
|
||||||
|
<button class="modal-close" id="editModalClose">×</button>
|
||||||
|
<h2>Edit Donor</h2>
|
||||||
|
<form class="admin-form" id="editDonorForm">
|
||||||
|
<input type="hidden" id="editDonorId" />
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="editName">Name</label>
|
||||||
|
<input type="text" id="editName" required />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="editAmount">Amount ($)</label>
|
||||||
|
<input type="number" id="editAmount" min="0" step="0.01" required />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="editClassYear">Class Year</label>
|
||||||
|
<input type="text" id="editClassYear" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="editMessage">Message</label>
|
||||||
|
<textarea id="editMessage" rows="2"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="form-check">
|
||||||
|
<input type="checkbox" id="editAnonymous" />
|
||||||
|
<label for="editAnonymous">Anonymous donation</label>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary">Save Changes</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script type="module" src="/src/admin.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
237
dist/admin.html
vendored
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Admin - Wesley High School Fundraiser</title>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/images/favicon.svg" />
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||||
|
<script type="module" crossorigin src="/assets/admin-BaZpN3Uw.js"></script>
|
||||||
|
<link rel="modulepreload" crossorigin href="/assets/donors-DDFnEjnq.js">
|
||||||
|
<link rel="stylesheet" crossorigin href="/assets/admin-r6G2dek0.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- Header -->
|
||||||
|
<header class="admin-header">
|
||||||
|
<div class="admin-header-inner">
|
||||||
|
<div class="admin-header-left">
|
||||||
|
<img src="/images/whs-logo.svg" alt="Wesley High School" class="admin-logo" />
|
||||||
|
<span class="admin-badge">Admin</span>
|
||||||
|
</div>
|
||||||
|
<div class="admin-header-right">
|
||||||
|
<a href="/" class="admin-back-link">Back to Site</a>
|
||||||
|
<button class="btn btn-sm" id="logoutBtn" style="display:none">Sign Out</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Auth Gate -->
|
||||||
|
<div class="admin-auth" id="authSection">
|
||||||
|
<div class="admin-auth-card">
|
||||||
|
<h1>Admin Login</h1>
|
||||||
|
<p>Sign in to manage donors and import data.</p>
|
||||||
|
<div class="admin-auth-buttons">
|
||||||
|
<button class="btn btn-google" id="loginGoogle">
|
||||||
|
<svg viewBox="0 0 24 24" width="18" height="18"><path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 01-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z"/><path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/><path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/><path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/></svg>
|
||||||
|
Sign in with Google
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Unauthorized -->
|
||||||
|
<div class="admin-auth hidden" id="unauthorizedSection">
|
||||||
|
<div class="admin-auth-card">
|
||||||
|
<h1>Access Denied</h1>
|
||||||
|
<p>Your account is not authorized to access the admin panel.</p>
|
||||||
|
<button class="btn btn-primary" id="unauthorizedLogout">Sign Out</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Admin Content -->
|
||||||
|
<main class="admin-main hidden" id="adminContent">
|
||||||
|
<!-- Stats -->
|
||||||
|
<div class="admin-stats">
|
||||||
|
<div class="stat-card">
|
||||||
|
<span class="stat-value" id="statTotal">$0</span>
|
||||||
|
<span class="stat-label">Total Raised</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<span class="stat-value" id="statCount">0</span>
|
||||||
|
<span class="stat-label">Donors</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tabs -->
|
||||||
|
<div class="admin-tabs">
|
||||||
|
<button class="admin-tab active" data-tab="add">Add Donor</button>
|
||||||
|
<button class="admin-tab" data-tab="import">CSV Import</button>
|
||||||
|
<button class="admin-tab" data-tab="list">Donor List</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add Donor Panel -->
|
||||||
|
<div class="admin-panel active" id="panel-add">
|
||||||
|
<h2>Add a Donor</h2>
|
||||||
|
<form class="admin-form" id="addDonorForm">
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="donorName">Name</label>
|
||||||
|
<input type="text" id="donorName" placeholder="Donor name" required />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="donorAmount">Amount ($)</label>
|
||||||
|
<input type="number" id="donorAmount" placeholder="0.00" min="0" step="0.01" required />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="donorClassYear">Class Year</label>
|
||||||
|
<input type="text" id="donorClassYear" placeholder="e.g. 1985" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="donorDate">Date</label>
|
||||||
|
<input type="date" id="donorDate" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="donorMessage">Message</label>
|
||||||
|
<textarea id="donorMessage" placeholder="Optional message..." rows="2"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="form-check">
|
||||||
|
<input type="checkbox" id="donorAnonymous" />
|
||||||
|
<label for="donorAnonymous">Anonymous donation</label>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary" id="addDonorBtn">Add Donor</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- CSV Import Panel -->
|
||||||
|
<div class="admin-panel" id="panel-import">
|
||||||
|
<h2>Import Donors from CSV</h2>
|
||||||
|
<p class="panel-desc">Upload a CSV file with donor data. Map the columns to the correct fields, preview, then import.</p>
|
||||||
|
|
||||||
|
<div class="csv-upload">
|
||||||
|
<label class="csv-upload-label" for="csvFile">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="32" height="32"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
|
||||||
|
<span>Choose CSV file or drag & drop</span>
|
||||||
|
<input type="file" id="csvFile" accept=".csv" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Column Mapping (hidden until file loaded) -->
|
||||||
|
<div class="csv-mapping hidden" id="csvMapping">
|
||||||
|
<h3>Map Columns</h3>
|
||||||
|
<div class="mapping-grid">
|
||||||
|
<div class="mapping-row">
|
||||||
|
<label>Name</label>
|
||||||
|
<select id="mapName"></select>
|
||||||
|
</div>
|
||||||
|
<div class="mapping-row">
|
||||||
|
<label>Amount</label>
|
||||||
|
<select id="mapAmount"></select>
|
||||||
|
</div>
|
||||||
|
<div class="mapping-row">
|
||||||
|
<label>Class Year</label>
|
||||||
|
<select id="mapClassYear"></select>
|
||||||
|
</div>
|
||||||
|
<div class="mapping-row">
|
||||||
|
<label>Message</label>
|
||||||
|
<select id="mapMessage"></select>
|
||||||
|
</div>
|
||||||
|
<div class="mapping-row">
|
||||||
|
<label>Date</label>
|
||||||
|
<select id="mapDate"></select>
|
||||||
|
</div>
|
||||||
|
<div class="mapping-row">
|
||||||
|
<label>Anonymous</label>
|
||||||
|
<select id="mapAnonymous"></select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Preview -->
|
||||||
|
<h3>Preview <span class="preview-count" id="previewCount"></span></h3>
|
||||||
|
<div class="csv-preview-wrap">
|
||||||
|
<table class="csv-preview" id="csvPreview">
|
||||||
|
<thead id="csvPreviewHead"></thead>
|
||||||
|
<tbody id="csvPreviewBody"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="csv-actions">
|
||||||
|
<button class="btn btn-primary" id="importBtn">Import All</button>
|
||||||
|
<button class="btn btn-outline" id="csvCancelBtn">Cancel</button>
|
||||||
|
</div>
|
||||||
|
<div class="import-progress hidden" id="importProgress">
|
||||||
|
<div class="progress-bar"><div class="progress-fill" id="progressFill"></div></div>
|
||||||
|
<span id="progressText">Importing...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Donor List Panel -->
|
||||||
|
<div class="admin-panel" id="panel-list">
|
||||||
|
<h2>All Donors</h2>
|
||||||
|
<div class="admin-search">
|
||||||
|
<input type="text" id="donorSearch" placeholder="Search donors..." />
|
||||||
|
</div>
|
||||||
|
<div class="admin-table-wrap">
|
||||||
|
<table class="admin-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Amount</th>
|
||||||
|
<th>Class Year</th>
|
||||||
|
<th>Date</th>
|
||||||
|
<th>Message</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="donorTableBody"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="empty-state hidden" id="donorListEmpty">
|
||||||
|
<p>No donors found.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- Edit Modal -->
|
||||||
|
<div class="modal-overlay hidden" id="editModal">
|
||||||
|
<div class="modal">
|
||||||
|
<button class="modal-close" id="editModalClose">×</button>
|
||||||
|
<h2>Edit Donor</h2>
|
||||||
|
<form class="admin-form" id="editDonorForm">
|
||||||
|
<input type="hidden" id="editDonorId" />
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="editName">Name</label>
|
||||||
|
<input type="text" id="editName" required />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="editAmount">Amount ($)</label>
|
||||||
|
<input type="number" id="editAmount" min="0" step="0.01" required />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="editClassYear">Class Year</label>
|
||||||
|
<input type="text" id="editClassYear" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="editMessage">Message</label>
|
||||||
|
<textarea id="editMessage" rows="2"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="form-check">
|
||||||
|
<input type="checkbox" id="editAnonymous" />
|
||||||
|
<label for="editAnonymous">Anonymous donation</label>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary">Save Changes</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
24
dist/assets/admin-BaZpN3Uw.js
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import{i as C,j as A,n as p,p as D,f as b,d as S,v as k,x as M,y as $,t as x,z as T,A as F,B as Y,C as N}from"./donors-DDFnEjnq.js";const f=["pcgurudm@gmail.com"];let m=[];document.addEventListener("DOMContentLoaded",()=>{C(),H(),V(),P(),j(),_()});function H(){document.getElementById("loginGoogle").addEventListener("click",A),document.getElementById("logoutBtn").addEventListener("click",p),document.getElementById("unauthorizedLogout").addEventListener("click",p),D(q)}async function q(t){const e=document.getElementById("authSection"),n=document.getElementById("unauthorizedSection"),o=document.getElementById("adminContent"),d=document.getElementById("logoutBtn");if(!t){e.classList.remove("hidden"),n.classList.add("hidden"),o.classList.add("hidden"),d.style.display="none";return}if(d.style.display="",!await z(t.email)){e.classList.add("hidden"),n.classList.remove("hidden"),o.classList.add("hidden");return}e.classList.add("hidden"),n.classList.add("hidden"),o.classList.remove("hidden"),g()}async function z(t){try{const e=b(S,"config","admins"),n=await k(e);return n.exists()?(n.data()?.emails||[]).includes(t):f.includes(t)?(await M(e,{emails:f}),console.log("Admin allowlist created with seed admins."),!0):!1}catch(e){return console.error("Admin check failed:",e),!1}}function V(){document.querySelectorAll(".admin-tab").forEach(t=>{t.addEventListener("click",()=>{document.querySelectorAll(".admin-tab").forEach(e=>e.classList.remove("active")),document.querySelectorAll(".admin-panel").forEach(e=>e.classList.remove("active")),t.classList.add("active"),document.getElementById(`panel-${t.dataset.tab}`).classList.add("active")})})}async function g(){try{m=await $(),R(),L()}catch(t){console.error("Failed to load donors:",t),r("Failed to load donors.","error")}}function R(){const t=x(m);document.getElementById("statTotal").textContent=`$${t.toLocaleString()}`,document.getElementById("statCount").textContent=m.length}function P(){const t=document.getElementById("addDonorForm");t.addEventListener("submit",async e=>{e.preventDefault();const n=document.getElementById("addDonorBtn");n.disabled=!0,n.textContent="Adding...";try{await T({name:document.getElementById("donorName").value.trim(),amount:document.getElementById("donorAmount").value,classYear:document.getElementById("donorClassYear").value.trim(),message:document.getElementById("donorMessage").value.trim(),anonymous:document.getElementById("donorAnonymous").checked,date:document.getElementById("donorDate").value||null}),r("Donor added!","success"),t.reset(),await g()}catch(o){console.error("Failed to add donor:",o),r("Failed to add donor.","error")}finally{n.disabled=!1,n.textContent="Add Donor"}})}let u=[],h=[];function j(){document.getElementById("csvFile").addEventListener("change",G),document.getElementById("importBtn").addEventListener("click",O),document.getElementById("csvCancelBtn").addEventListener("click",I),document.querySelectorAll(".mapping-grid select").forEach(e=>{e.addEventListener("change",B)})}function G(t){const e=t.target.files[0];if(!e)return;const n=new FileReader;n.onload=o=>{const d=o.target.result,a=U(d);if(a.length<2){r("CSV file appears empty or has no data rows.","error");return}h=a[0],u=a.slice(1),W(),document.getElementById("csvMapping").classList.remove("hidden")},n.readAsText(e)}function U(t){const e=[];let n=[],o="",d=!1;for(let a=0;a<t.length;a++){const s=t[a],i=t[a+1];d?s==='"'&&i==='"'?(o+='"',a++):s==='"'?d=!1:o+=s:s==='"'?d=!0:s===","?(n.push(o.trim()),o=""):s===`
|
||||||
|
`||s==="\r"&&i===`
|
||||||
|
`?(n.push(o.trim()),n.some(l=>l!=="")&&e.push(n),n=[],o="",s==="\r"&&a++):o+=s}return n.push(o.trim()),n.some(a=>a!=="")&&e.push(n),e}function W(){const t=["mapName","mapAmount","mapClassYear","mapMessage","mapDate","mapAnonymous"],e={mapName:["name","donor","first","full"],mapAmount:["amount","donation","gift","total","sum"],mapClassYear:["class","year","grad"],mapMessage:["message","note","comment"],mapDate:["date","time","when"],mapAnonymous:["anonymous","anon"]};for(const n of t){const o=document.getElementById(n);o.innerHTML='<option value="">-- skip --</option>',h.forEach((s,i)=>{const l=document.createElement("option");l.value=i,l.textContent=s,o.appendChild(l)});const d=e[n],a=h.findIndex(s=>d.some(i=>s.toLowerCase().includes(i)));a!==-1&&(o.value=a)}B()}function E(t){const e=n=>{const o=document.getElementById(n).value;return o!==""&&t[Number(o)]||""};return{name:e("mapName"),amount:e("mapAmount"),classYear:e("mapClassYear"),message:e("mapMessage"),date:e("mapDate"),anonymous:e("mapAnonymous")}}function B(){const t=document.getElementById("csvPreviewHead"),e=document.getElementById("csvPreviewBody");t.innerHTML="<tr><th>Name</th><th>Amount</th><th>Class Year</th><th>Date</th><th>Message</th><th>Anon</th></tr>";const n=u.slice(0,5);e.innerHTML=n.map(o=>{const d=E(o);return`<tr>
|
||||||
|
<td>${c(d.name)}</td>
|
||||||
|
<td>${c(d.amount)}</td>
|
||||||
|
<td>${c(d.classYear)}</td>
|
||||||
|
<td>${c(d.date)}</td>
|
||||||
|
<td>${c(d.message)}</td>
|
||||||
|
<td>${y(d.anonymous)?"Yes":"No"}</td>
|
||||||
|
</tr>`}).join(""),document.getElementById("previewCount").textContent=`(${u.length} rows total)`}function y(t){if(!t)return!1;const e=t.toString().toLowerCase().trim();return e==="true"||e==="yes"||e==="1"}async function O(){const t=document.getElementById("importBtn"),e=document.getElementById("importProgress"),n=document.getElementById("progressFill"),o=document.getElementById("progressText"),d=u.map(a=>{const s=E(a),i=parseFloat(s.amount.replace(/[^0-9.\-]/g,""));return!s.name&&!y(s.anonymous)||isNaN(i)||i<=0?null:{name:s.name||"Anonymous",amount:i,classYear:s.classYear,message:s.message,date:s.date||null,anonymous:y(s.anonymous)}}).filter(Boolean);if(d.length===0){r("No valid rows to import. Check your column mapping.","error");return}if(confirm(`Import ${d.length} donors? (${u.length-d.length} rows will be skipped due to missing name/amount)`)){t.disabled=!0,e.classList.remove("hidden");try{let s=0;for(let i=0;i<d.length;i+=500){const l=d.slice(i,i+500);await F(l),s+=l.length;const w=Math.round(s/d.length*100);n.style.width=w+"%",o.textContent=`Imported ${s} of ${d.length}...`}r(`Successfully imported ${s} donors!`,"success"),I(),await g()}catch(a){console.error("Import failed:",a),r("Import failed. Some rows may have been imported.","error")}finally{t.disabled=!1,e.classList.add("hidden"),n.style.width="0%"}}}function I(){u=[],h=[],document.getElementById("csvFile").value="",document.getElementById("csvMapping").classList.add("hidden")}function L(t=""){const e=document.getElementById("donorTableBody"),n=document.getElementById("donorListEmpty"),o=t?m.filter(a=>a.name.toLowerCase().includes(t.toLowerCase())):m;if(o.length===0){e.innerHTML="",n.classList.remove("hidden");return}n.classList.add("hidden"),e.innerHTML=o.map(a=>{const s=a.date?.toDate?a.date.toDate().toLocaleDateString():"";return`<tr>
|
||||||
|
<td>${c(a.name)}${a.anonymous?' <span class="anon-badge">Anon</span>':""}</td>
|
||||||
|
<td>$${(a.amount||0).toLocaleString()}</td>
|
||||||
|
<td>${c(a.classYear)}</td>
|
||||||
|
<td>${s}</td>
|
||||||
|
<td class="msg-cell">${c(a.message)}</td>
|
||||||
|
<td class="action-cell">
|
||||||
|
<button class="btn-icon btn-edit" data-id="${a.id}" title="Edit">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
|
||||||
|
</button>
|
||||||
|
<button class="btn-icon btn-delete" data-id="${a.id}" title="Delete">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/></svg>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>`}).join(""),e.querySelectorAll(".btn-edit").forEach(a=>{a.addEventListener("click",()=>J(a.dataset.id))}),e.querySelectorAll(".btn-delete").forEach(a=>{a.addEventListener("click",()=>Q(a.dataset.id))});const d=document.getElementById("donorSearch");d.removeEventListener("input",v),d.addEventListener("input",v)}function v(t){L(t.target.value.trim())}async function Q(t){if(confirm("Delete this donor? This cannot be undone."))try{await Y(t),r("Donor deleted.","success"),await g()}catch(e){console.error("Delete failed:",e),r("Failed to delete donor.","error")}}function _(){const t=document.getElementById("editModal");document.getElementById("editModalClose").addEventListener("click",()=>t.classList.add("hidden")),t.addEventListener("click",e=>{e.target===t&&t.classList.add("hidden")}),document.getElementById("editDonorForm").addEventListener("submit",async e=>{e.preventDefault();const n=document.getElementById("editDonorId").value,o=e.target.querySelector('button[type="submit"]');o.disabled=!0,o.textContent="Saving...";try{await N(n,{name:document.getElementById("editAnonymous").checked?"Anonymous":document.getElementById("editName").value.trim(),amount:Number(document.getElementById("editAmount").value),classYear:document.getElementById("editClassYear").value.trim(),message:document.getElementById("editMessage").value.trim(),anonymous:document.getElementById("editAnonymous").checked}),r("Donor updated!","success"),t.classList.add("hidden"),await g()}catch(d){console.error("Update failed:",d),r("Failed to update donor.","error")}finally{o.disabled=!1,o.textContent="Save Changes"}})}function J(t){const e=m.find(n=>n.id===t);e&&(document.getElementById("editDonorId").value=t,document.getElementById("editName").value=e.name||"",document.getElementById("editAmount").value=e.amount||"",document.getElementById("editClassYear").value=e.classYear||"",document.getElementById("editMessage").value=e.message||"",document.getElementById("editAnonymous").checked=!!e.anonymous,document.getElementById("editModal").classList.remove("hidden"))}function c(t){const e=document.createElement("div");return e.textContent=t||"",e.innerHTML}function r(t,e="info"){const n=document.querySelector(".toast");n&&n.remove();const o=document.createElement("div");o.className=`toast ${e}`,o.textContent=t,document.body.appendChild(o),setTimeout(()=>o.remove(),4e3)}
|
||||||
1
dist/assets/admin-r6G2dek0.css
vendored
Normal file
36
dist/assets/donors-DDFnEjnq.js
vendored
Normal file
1
dist/assets/index-Cpged3wR.css
vendored
67
dist/assets/index-DWzoJss_.js
vendored
1
dist/assets/main-BYboBf7A.css
vendored
Normal file
32
dist/assets/main-f9-9VaoM.js
vendored
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import{c as p,d as u,q as v,o as E,a as k,b,s as L,e as T,f as A,l as U,w as g,g as D,i as M,h as N,j as H,k as F,m as R,n as q,p as P,r as j,t as W,u as B}from"./donors-DDFnEjnq.js";const I=p(u,"comments");let y=null;function Y(t){const n=v(I,E("timestamp","desc"));return y=k(n,o=>{const e=[];o.forEach(s=>{e.push({id:s.id,...s.data()})}),t(e)},o=>{console.error("Error fetching comments:",o),t([])}),y}async function _(t,n){return!t||!n.trim()?null:b(I,{userId:t.uid,userName:t.displayName||"Anonymous",userPhoto:t.photoURL||"",text:n.trim(),timestamp:L()})}async function G(t){return T(A(u,"comments",t))}const O=p(u,"students");async function J(t){const n=t.trim().toLowerCase();if(!n)return[];const o=n+"",e=v(O,g("nameLower",">=",n),g("nameLower","<=",o),E("nameLower"),U(50));try{const s=await D(e),a=[];return s.forEach(r=>{a.push({id:r.id,...r.data()})}),a}catch(s){return console.error("Error searching students:",s),[]}}const z=p(u,"subscribers");async function X(t){return b(z,{email:t,subscribedAt:L()})}let C=[],h=[];document.addEventListener("DOMContentLoaded",()=>{M(),K(),Q(),V(),Z(),nt(),st(),ct(),dt(),it(),N(ot),Y(at)});function K(){const t=document.getElementById("tabNav");t.addEventListener("click",n=>{const o=n.target.closest(".tab-btn");if(!o)return;t.querySelectorAll(".tab-btn").forEach(s=>s.classList.remove("active")),o.classList.add("active"),document.querySelectorAll(".tab-panel").forEach(s=>s.classList.remove("active"));const e=document.getElementById(`tab-${o.dataset.tab}`);e&&e.classList.add("active")})}function Q(){const t=document.getElementById("carouselTrack"),n=t.querySelectorAll(".carousel-slide"),o=document.querySelectorAll(".dot"),e=document.getElementById("carouselPrev"),s=document.getElementById("carouselNext");let a=0,r;function c(l){a=(l%n.length+n.length)%n.length,t.style.transform=`translateX(-${a*100}%)`,o.forEach((x,$)=>x.classList.toggle("active",$===a))}function d(){S(),r=setInterval(()=>c(a+1),5e3)}function S(){clearInterval(r)}e.addEventListener("click",()=>{c(a-1),d()}),s.addEventListener("click",()=>{c(a+1),d()}),o.forEach(l=>{l.addEventListener("click",()=>{c(Number(l.dataset.index)),d()})}),d()}function V(){const t=window.location.href,n="Wesley High School - 100th Anniversary Fundraiser",o="Support Wesley High School's 100th Anniversary Celebrations! Help us celebrate a century of excellence.";document.getElementById("shareFacebook").addEventListener("click",()=>{window.open(`https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(t)}`,"_blank","width=600,height=400")}),document.getElementById("shareTwitter").addEventListener("click",()=>{window.open(`https://twitter.com/intent/tweet?text=${encodeURIComponent(o)}&url=${encodeURIComponent(t)}`,"_blank","width=600,height=400")}),document.getElementById("shareEmail").addEventListener("click",()=>{window.location.href=`mailto:?subject=${encodeURIComponent(n)}&body=${encodeURIComponent(o+`
|
||||||
|
|
||||||
|
`+t)}`})}function Z(){document.getElementById("loginGoogle").addEventListener("click",H),document.getElementById("loginFacebook").addEventListener("click",F),document.getElementById("loginTwitter").addEventListener("click",R),document.getElementById("logoutBtn").addEventListener("click",q),P(tt)}function tt(t){const n=document.getElementById("authPrompt"),o=document.getElementById("commentForm"),e=document.getElementById("userAvatar"),s=document.getElementById("userName");t?(n.classList.add("hidden"),o.classList.remove("hidden"),e.src=t.photoURL||et(t.displayName||"U"),e.alt=t.displayName||"User",s.textContent=t.displayName||t.email||"User"):(n.classList.remove("hidden"),o.classList.add("hidden"))}function et(t){const n=t.charAt(0).toUpperCase(),o=document.createElement("canvas");o.width=80,o.height=80;const e=o.getContext("2d");return e.fillStyle="#1a1a6e",e.fillRect(0,0,80,80),e.fillStyle="#ffffff",e.font="bold 36px Inter, sans-serif",e.textAlign="center",e.textBaseline="middle",e.fillText(n,40,40),o.toDataURL()}function nt(){const t=document.getElementById("commentText"),n=document.getElementById("charCount"),o=document.getElementById("postCommentBtn");t.addEventListener("input",()=>{n.textContent=`${t.value.length}/500`}),o.addEventListener("click",async()=>{const e=B(),s=t.value.trim();if(!(!e||!s)){o.disabled=!0,o.textContent="Posting...";try{await _(e,s),t.value="",n.textContent="0/500"}catch(a){console.error("Error posting comment:",a),m("Failed to post comment. Please try again.","error")}finally{o.disabled=!1,o.textContent="Post Comment"}}})}function ot(t){C=t;const n=W(t);document.getElementById("totalRaised").textContent=`$${n.toLocaleString()}`,w()}function st(){document.getElementById("donorSort").addEventListener("change",w)}function w(){const t=document.getElementById("donorsList"),n=document.getElementById("donorsEmpty"),o=document.getElementById("donorSort").value,e=j(C,o);if(e.length===0){t.innerHTML="",t.appendChild(n),n.classList.remove("hidden");return}t.innerHTML=e.map(s=>{const a=(s.name||"A").charAt(0).toUpperCase(),r=s.date?.toDate?s.date.toDate().toLocaleDateString():"",c=s.classYear?` · Class of ${s.classYear}`:"";return`
|
||||||
|
<div class="donor-card">
|
||||||
|
<div class="donor-info">
|
||||||
|
<div class="donor-avatar">${a}</div>
|
||||||
|
<div class="donor-details">
|
||||||
|
<h4>${i(s.name)}</h4>
|
||||||
|
<p>${r}${c}</p>
|
||||||
|
${s.message?`<p style="margin-top:4px;color:#374151;font-size:0.85rem">"${i(s.message)}"</p>`:""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="donor-amount">$${(s.amount||0).toLocaleString()}</div>
|
||||||
|
</div>`}).join("")}function at(t){h=t,rt()}function rt(){const t=document.getElementById("commentsList"),n=document.getElementById("commentsEmpty"),o=B();if(h.length===0){t.innerHTML="",t.appendChild(n),n.classList.remove("hidden");return}t.innerHTML=h.map(e=>{const s=e.timestamp?.toDate?lt(e.timestamp.toDate()):"just now",a=e.userPhoto?`<img class="comment-avatar" src="${i(e.userPhoto)}" alt="${i(e.userName)}" />`:`<div class="comment-avatar-placeholder">${(e.userName||"U").charAt(0).toUpperCase()}</div>`,r=o&&o.uid===e.userId?`<button class="comment-delete" data-id="${e.id}">Delete</button>`:"";return`
|
||||||
|
<div class="comment-card">
|
||||||
|
${a}
|
||||||
|
<div class="comment-body">
|
||||||
|
<div class="comment-header">
|
||||||
|
<span class="comment-author">${i(e.userName)}</span>
|
||||||
|
<span class="comment-time">${s}</span>
|
||||||
|
</div>
|
||||||
|
<p class="comment-text">${i(e.text)}</p>
|
||||||
|
${r}
|
||||||
|
</div>
|
||||||
|
</div>`}).join(""),t.querySelectorAll(".comment-delete").forEach(e=>{e.addEventListener("click",async()=>{if(confirm("Delete this comment?"))try{await G(e.dataset.id)}catch(s){console.error("Error deleting comment:",s),m("Failed to delete comment.","error")}})})}function it(){const t=document.getElementById("studentSearch"),n=document.getElementById("studentResults"),o=document.getElementById("studentSearchEmpty");let e;t.addEventListener("input",()=>{clearTimeout(e);const s=t.value.trim();if(!s){n.innerHTML="",n.appendChild(o),o.querySelector("p").textContent="Enter a name above to search for students.",o.classList.remove("hidden");return}e=setTimeout(async()=>{n.innerHTML='<div class="search-loading"><div class="loading-spinner"></div> Searching...</div>';const a=await J(s);if(a.length===0){n.innerHTML="",n.appendChild(o),o.querySelector("p").textContent=`No students found matching "${i(s)}".`,o.classList.remove("hidden");return}n.innerHTML=a.map(r=>`
|
||||||
|
<div class="student-card">
|
||||||
|
<div class="student-avatar">${(r.name||"?").charAt(0).toUpperCase()}</div>
|
||||||
|
<div class="student-info">
|
||||||
|
<h4>${i(r.name)}</h4>
|
||||||
|
<p>Class of ${i(String(r.classYear||"N/A"))}</p>
|
||||||
|
</div>
|
||||||
|
</div>`).join("")},350)})}function ct(){const t=document.getElementById("newsletterForm"),n=t.querySelector('button[type="submit"]');t.addEventListener("submit",async o=>{o.preventDefault();const e=document.getElementById("newsletterEmail").value.trim();if(e){n.disabled=!0,n.textContent="Joining...";try{await X(e),m("Thank you for subscribing!","success"),t.reset()}catch(s){console.error("Newsletter subscribe error:",s),m("Failed to subscribe. Please try again.","error")}finally{n.disabled=!1,n.textContent="Join"}}})}function dt(){const t=document.getElementById("modalOverlay");document.getElementById("modalClose").addEventListener("click",()=>t.classList.add("hidden")),t.addEventListener("click",e=>{e.target===t&&t.classList.add("hidden")}),document.getElementById("becomeFundraiserBtn").addEventListener("click",()=>{f('<h2>Become a Fundraiser</h2><p style="margin-top:12px">Create your own fundraising page and rally your classmates! Share the donation link with friends and family to help Wesley High School reach its goal.</p><a href="https://pay.shopdm.store/dominica-methodist-circuit" target="_blank" rel="noopener" class="btn btn-primary" style="margin-top:20px;display:inline-flex">Start Fundraising</a>')});const o=document.getElementById("startFundraiserBtn");o&&o.addEventListener("click",()=>{f('<h2>Start Your Fundraiser</h2><p style="margin-top:12px">Share the Wesley High School donation page with your network and help us celebrate 100 years!</p><a href="https://pay.shopdm.store/dominica-methodist-circuit" target="_blank" rel="noopener" class="btn btn-primary" style="margin-top:20px;display:inline-flex">Get Started</a>')})}function f(t){document.getElementById("modalContent").innerHTML=t,document.getElementById("modalOverlay").classList.remove("hidden")}function i(t){const n=document.createElement("div");return n.textContent=t||"",n.innerHTML}function lt(t){const n=Math.floor((Date.now()-t.getTime())/1e3);if(n<60)return"just now";const o=Math.floor(n/60);if(o<60)return`${o}m ago`;const e=Math.floor(o/60);if(e<24)return`${e}h ago`;const s=Math.floor(e/24);if(s<30)return`${s}d ago`;const a=Math.floor(s/30);return a<12?`${a}mo ago`:`${Math.floor(a/12)}y ago`}function m(t,n="info"){const o=document.querySelector(".toast");o&&o.remove();const e=document.createElement("div");e.className=`toast ${n}`,e.textContent=t,document.body.appendChild(e),setTimeout(()=>e.remove(),4e3)}
|
||||||
BIN
dist/images/image-1.jpeg
vendored
Normal file
|
After Width: | Height: | Size: 90 KiB |
BIN
dist/images/image-10.jpeg
vendored
Normal file
|
After Width: | Height: | Size: 5.2 KiB |
BIN
dist/images/image-11.jpeg
vendored
Normal file
|
After Width: | Height: | Size: 280 KiB |
BIN
dist/images/image-2.jpeg
vendored
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
dist/images/image-3.jpeg
vendored
Normal file
|
After Width: | Height: | Size: 6.9 KiB |
BIN
dist/images/image-4.jpeg
vendored
Normal file
|
After Width: | Height: | Size: 8.8 KiB |
BIN
dist/images/image-5.jpeg
vendored
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
BIN
dist/images/image-6.jpeg
vendored
Normal file
|
After Width: | Height: | Size: 91 KiB |
BIN
dist/images/image-7.jpeg
vendored
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
dist/images/image-8.jpeg
vendored
Normal file
|
After Width: | Height: | Size: 9.3 KiB |
BIN
dist/images/image-9.jpeg
vendored
Normal file
|
After Width: | Height: | Size: 72 KiB |
BIN
dist/images/logo-black.jpeg
vendored
|
Before Width: | Height: | Size: 224 KiB |
BIN
dist/images/logo-blue.jpeg
vendored
|
Before Width: | Height: | Size: 253 KiB |
BIN
dist/images/logo-orange.jpeg
vendored
|
Before Width: | Height: | Size: 182 KiB |
BIN
dist/images/logo-white.jpeg
vendored
|
Before Width: | Height: | Size: 56 KiB |
4
dist/images/whs-logo.svg
vendored
@@ -13,8 +13,8 @@
|
|||||||
<text x="100" y="90" font-family="Georgia, serif" font-size="52" font-weight="bold" fill="#ffffff" text-anchor="middle" dominant-baseline="middle">WHS</text>
|
<text x="100" y="90" font-family="Georgia, serif" font-size="52" font-weight="bold" fill="#ffffff" text-anchor="middle" dominant-baseline="middle">WHS</text>
|
||||||
<!-- Divider line -->
|
<!-- Divider line -->
|
||||||
<line x1="55" y1="110" x2="145" y2="110" stroke="#c8a951" stroke-width="2"/>
|
<line x1="55" y1="110" x2="145" y2="110" stroke="#c8a951" stroke-width="2"/>
|
||||||
<!-- Est 1925 -->
|
<!-- Est 1926 -->
|
||||||
<text x="100" y="132" font-family="Georgia, serif" font-size="14" fill="#c8a951" text-anchor="middle">EST. 1925</text>
|
<text x="100" y="132" font-family="Georgia, serif" font-size="14" fill="#c8a951" text-anchor="middle">EST. 1926</text>
|
||||||
<!-- 100 Years -->
|
<!-- 100 Years -->
|
||||||
<text x="100" y="155" font-family="Georgia, serif" font-size="16" font-weight="bold" fill="#ffffff" text-anchor="middle">100 YEARS</text>
|
<text x="100" y="155" font-family="Georgia, serif" font-size="16" font-weight="bold" fill="#ffffff" text-anchor="middle">100 YEARS</text>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
65
dist/index.html
vendored
@@ -9,8 +9,9 @@
|
|||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||||
<script type="module" crossorigin src="/assets/index-DWzoJss_.js"></script>
|
<script type="module" crossorigin src="/assets/main-f9-9VaoM.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-Cpged3wR.css">
|
<link rel="modulepreload" crossorigin href="/assets/donors-DDFnEjnq.js">
|
||||||
|
<link rel="stylesheet" crossorigin href="/assets/main-BYboBf7A.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<!-- Top Header Bar -->
|
<!-- Top Header Bar -->
|
||||||
@@ -45,26 +46,33 @@
|
|||||||
<div class="carousel" id="carousel">
|
<div class="carousel" id="carousel">
|
||||||
<div class="carousel-track" id="carouselTrack">
|
<div class="carousel-track" id="carouselTrack">
|
||||||
<div class="carousel-slide active">
|
<div class="carousel-slide active">
|
||||||
<div class="carousel-placeholder" id="slide1">
|
<div class="carousel-slide-bg">
|
||||||
|
<img src="/images/image-1.jpeg" alt="" />
|
||||||
|
</div>
|
||||||
|
<div class="carousel-overlay"></div>
|
||||||
<div class="placeholder-content">
|
<div class="placeholder-content">
|
||||||
<h2>Wesley High School</h2>
|
<h2>Wesley High School</h2>
|
||||||
<p>100th Anniversary</p>
|
<p>100th Anniversary</p>
|
||||||
<p class="year-range">1925 - 2025</p>
|
<p class="year-range">1926 - 2026</p>
|
||||||
<p>Celebrating a Century of Excellence</p>
|
<p>Celebrating a Century of Excellence</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div class="carousel-slide">
|
<div class="carousel-slide">
|
||||||
<div class="carousel-placeholder" id="slide2">
|
<div class="carousel-slide-bg">
|
||||||
|
<img src="/images/image-9.jpeg" alt="" />
|
||||||
|
</div>
|
||||||
|
<div class="carousel-overlay"></div>
|
||||||
<div class="placeholder-content">
|
<div class="placeholder-content">
|
||||||
<h2>Support Our Legacy</h2>
|
<h2>Support Our Legacy</h2>
|
||||||
<p>Help us continue building</p>
|
<p>Help us continue building</p>
|
||||||
<p>the next 100 years</p>
|
<p>the next 100 years</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div class="carousel-slide">
|
<div class="carousel-slide">
|
||||||
<div class="carousel-placeholder" id="slide3">
|
<div class="carousel-slide-bg">
|
||||||
|
<img src="/images/image-6.jpeg" alt="" />
|
||||||
|
</div>
|
||||||
|
<div class="carousel-overlay"></div>
|
||||||
<div class="placeholder-content">
|
<div class="placeholder-content">
|
||||||
<h2>Class Challenge</h2>
|
<h2>Class Challenge</h2>
|
||||||
<p>Click below to make</p>
|
<p>Click below to make</p>
|
||||||
@@ -72,7 +80,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<button class="carousel-btn carousel-prev" id="carouselPrev" aria-label="Previous slide">❮</button>
|
<button class="carousel-btn carousel-prev" id="carouselPrev" aria-label="Previous slide">❮</button>
|
||||||
<button class="carousel-btn carousel-next" id="carouselNext" aria-label="Next slide">❯</button>
|
<button class="carousel-btn carousel-next" id="carouselNext" aria-label="Next slide">❯</button>
|
||||||
<div class="carousel-dots" id="carouselDots">
|
<div class="carousel-dots" id="carouselDots">
|
||||||
@@ -93,6 +100,40 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- Photo Filmstrip -->
|
||||||
|
<section class="filmstrip-section">
|
||||||
|
<h2 class="filmstrip-title">Through the Years</h2>
|
||||||
|
<div class="filmstrip-track">
|
||||||
|
<div class="filmstrip-scroll">
|
||||||
|
<div class="filmstrip-frame"><img src="/images/image-1.jpeg" alt="Historic school gathering" /></div>
|
||||||
|
<div class="filmstrip-frame"><img src="/images/image-11.jpeg" alt="Class photo — students in uniform" /></div>
|
||||||
|
<div class="filmstrip-frame"><img src="/images/image-4.jpeg" alt="Students outside Wesley High School" /></div>
|
||||||
|
<div class="filmstrip-frame"><img src="/images/image-9.jpeg" alt="The original school building" /></div>
|
||||||
|
<div class="filmstrip-frame"><img src="/images/image-6.jpeg" alt="Students at a church celebration" /></div>
|
||||||
|
<div class="filmstrip-frame"><img src="/images/image-3.jpeg" alt="Early faculty and students" /></div>
|
||||||
|
<div class="filmstrip-frame"><img src="/images/image-8.jpeg" alt="The modern school building" /></div>
|
||||||
|
<div class="filmstrip-frame"><img src="/images/image-7.jpeg" alt="School assembly — panoramic view" /></div>
|
||||||
|
<div class="filmstrip-frame"><img src="/images/image-2.jpeg" alt="Vintage student photograph" /></div>
|
||||||
|
<div class="filmstrip-frame"><img src="/images/image-5.jpeg" alt="Alumni group photo" /></div>
|
||||||
|
<div class="filmstrip-frame"><img src="/images/image-10.jpeg" alt="Early class photograph" /></div>
|
||||||
|
</div>
|
||||||
|
<!-- Duplicate for seamless infinite loop -->
|
||||||
|
<div class="filmstrip-scroll">
|
||||||
|
<div class="filmstrip-frame"><img src="/images/image-1.jpeg" alt="" /></div>
|
||||||
|
<div class="filmstrip-frame"><img src="/images/image-11.jpeg" alt="" /></div>
|
||||||
|
<div class="filmstrip-frame"><img src="/images/image-4.jpeg" alt="" /></div>
|
||||||
|
<div class="filmstrip-frame"><img src="/images/image-9.jpeg" alt="" /></div>
|
||||||
|
<div class="filmstrip-frame"><img src="/images/image-6.jpeg" alt="" /></div>
|
||||||
|
<div class="filmstrip-frame"><img src="/images/image-3.jpeg" alt="" /></div>
|
||||||
|
<div class="filmstrip-frame"><img src="/images/image-8.jpeg" alt="" /></div>
|
||||||
|
<div class="filmstrip-frame"><img src="/images/image-7.jpeg" alt="" /></div>
|
||||||
|
<div class="filmstrip-frame"><img src="/images/image-2.jpeg" alt="" /></div>
|
||||||
|
<div class="filmstrip-frame"><img src="/images/image-5.jpeg" alt="" /></div>
|
||||||
|
<div class="filmstrip-frame"><img src="/images/image-10.jpeg" alt="" /></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- Tab Navigation -->
|
<!-- Tab Navigation -->
|
||||||
<section class="tabs-section">
|
<section class="tabs-section">
|
||||||
<div class="tabs-container">
|
<div class="tabs-container">
|
||||||
@@ -110,8 +151,8 @@
|
|||||||
<div class="tab-panel active" id="tab-about">
|
<div class="tab-panel active" id="tab-about">
|
||||||
<h2>Join us for our 100th Anniversary Celebration!</h2>
|
<h2>Join us for our 100th Anniversary Celebration!</h2>
|
||||||
<p>Please consider a donation in honor of your graduating class and help us raise funds for our Alma Mater, Wesley High School!</p>
|
<p>Please consider a donation in honor of your graduating class and help us raise funds for our Alma Mater, Wesley High School!</p>
|
||||||
<p>Wesley High School has been a cornerstone of education in Dominica since 1925. As we celebrate our centennial, we invite all alumni, students, and supporters to contribute to our legacy.</p>
|
<p>Wesley High School has been a cornerstone of education in Dominica since 1926. As we celebrate our centennial, we invite all alumni, students, and supporters to contribute to our legacy.</p>
|
||||||
<p>Our goal is to raise funds that will support scholarships, campus improvements, and programs that will benefit students for the next century.</p>
|
<p>Our goal is to raise funds that will support scholarships, and a science lab for our students.</p>
|
||||||
<div class="about-cta">
|
<div class="about-cta">
|
||||||
<a href="https://pay.shopdm.store/dominica-methodist-circuit" target="_blank" rel="noopener" class="btn btn-primary">Donate Now</a>
|
<a href="https://pay.shopdm.store/dominica-methodist-circuit" target="_blank" rel="noopener" class="btn btn-primary">Donate Now</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -252,7 +293,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="footer-bottom">
|
<div class="footer-bottom">
|
||||||
<p>Privacy Policy | Terms and Conditions</p>
|
<p>Privacy Policy | Terms and Conditions</p>
|
||||||
<p>Copyright © 2025 Wesley High School. All rights reserved.</p>
|
<p>Copyright © 2026 Wesley High School. All rights reserved.</p>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,21 @@ rules_version = '2';
|
|||||||
service cloud.firestore {
|
service cloud.firestore {
|
||||||
match /databases/{database}/documents {
|
match /databases/{database}/documents {
|
||||||
|
|
||||||
|
// Helper: check if the authenticated user is an admin
|
||||||
|
function isAdmin() {
|
||||||
|
return request.auth != null
|
||||||
|
&& request.auth.token.email in get(/databases/$(database)/documents/config/admins).data.emails;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config collection (admin allowlist etc.)
|
||||||
|
match /config/{docId} {
|
||||||
|
allow read: if request.auth != null;
|
||||||
|
// Allow initial creation (bootstrap) by authenticated user; updates only by admins
|
||||||
|
allow create: if request.auth != null;
|
||||||
|
allow update: if isAdmin();
|
||||||
|
allow delete: if false;
|
||||||
|
}
|
||||||
|
|
||||||
// Students collection (read-only, managed via admin/console)
|
// Students collection (read-only, managed via admin/console)
|
||||||
match /students/{studentId} {
|
match /students/{studentId} {
|
||||||
allow read: if true;
|
allow read: if true;
|
||||||
@@ -13,10 +28,9 @@ service cloud.firestore {
|
|||||||
match /donors/{donorId} {
|
match /donors/{donorId} {
|
||||||
// Anyone can read donors
|
// Anyone can read donors
|
||||||
allow read: if true;
|
allow read: if true;
|
||||||
// Only authenticated users can create donors
|
// Authenticated users can create donors; admins can also update/delete
|
||||||
allow create: if request.auth != null;
|
allow create: if request.auth != null;
|
||||||
// No one can update or delete donors (admin-only via console)
|
allow update, delete: if isAdmin();
|
||||||
allow update, delete: if false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Comments collection
|
// Comments collection
|
||||||
|
|||||||
52
index.html
@@ -44,7 +44,10 @@
|
|||||||
<div class="carousel" id="carousel">
|
<div class="carousel" id="carousel">
|
||||||
<div class="carousel-track" id="carouselTrack">
|
<div class="carousel-track" id="carouselTrack">
|
||||||
<div class="carousel-slide active">
|
<div class="carousel-slide active">
|
||||||
<div class="carousel-placeholder" id="slide1">
|
<div class="carousel-slide-bg">
|
||||||
|
<img src="/images/image-1.jpeg" alt="" />
|
||||||
|
</div>
|
||||||
|
<div class="carousel-overlay"></div>
|
||||||
<div class="placeholder-content">
|
<div class="placeholder-content">
|
||||||
<h2>Wesley High School</h2>
|
<h2>Wesley High School</h2>
|
||||||
<p>100th Anniversary</p>
|
<p>100th Anniversary</p>
|
||||||
@@ -52,18 +55,22 @@
|
|||||||
<p>Celebrating a Century of Excellence</p>
|
<p>Celebrating a Century of Excellence</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div class="carousel-slide">
|
<div class="carousel-slide">
|
||||||
<div class="carousel-placeholder" id="slide2">
|
<div class="carousel-slide-bg">
|
||||||
|
<img src="/images/image-9.jpeg" alt="" />
|
||||||
|
</div>
|
||||||
|
<div class="carousel-overlay"></div>
|
||||||
<div class="placeholder-content">
|
<div class="placeholder-content">
|
||||||
<h2>Support Our Legacy</h2>
|
<h2>Support Our Legacy</h2>
|
||||||
<p>Help us continue building</p>
|
<p>Help us continue building</p>
|
||||||
<p>the next 100 years</p>
|
<p>the next 100 years</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div class="carousel-slide">
|
<div class="carousel-slide">
|
||||||
<div class="carousel-placeholder" id="slide3">
|
<div class="carousel-slide-bg">
|
||||||
|
<img src="/images/image-6.jpeg" alt="" />
|
||||||
|
</div>
|
||||||
|
<div class="carousel-overlay"></div>
|
||||||
<div class="placeholder-content">
|
<div class="placeholder-content">
|
||||||
<h2>Class Challenge</h2>
|
<h2>Class Challenge</h2>
|
||||||
<p>Click below to make</p>
|
<p>Click below to make</p>
|
||||||
@@ -71,7 +78,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<button class="carousel-btn carousel-prev" id="carouselPrev" aria-label="Previous slide">❮</button>
|
<button class="carousel-btn carousel-prev" id="carouselPrev" aria-label="Previous slide">❮</button>
|
||||||
<button class="carousel-btn carousel-next" id="carouselNext" aria-label="Next slide">❯</button>
|
<button class="carousel-btn carousel-next" id="carouselNext" aria-label="Next slide">❯</button>
|
||||||
<div class="carousel-dots" id="carouselDots">
|
<div class="carousel-dots" id="carouselDots">
|
||||||
@@ -92,6 +98,40 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- Photo Filmstrip -->
|
||||||
|
<section class="filmstrip-section">
|
||||||
|
<h2 class="filmstrip-title">Through the Years</h2>
|
||||||
|
<div class="filmstrip-track">
|
||||||
|
<div class="filmstrip-scroll">
|
||||||
|
<div class="filmstrip-frame"><img src="/images/image-1.jpeg" alt="Historic school gathering" /></div>
|
||||||
|
<div class="filmstrip-frame"><img src="/images/image-11.jpeg" alt="Class photo — students in uniform" /></div>
|
||||||
|
<div class="filmstrip-frame"><img src="/images/image-4.jpeg" alt="Students outside Wesley High School" /></div>
|
||||||
|
<div class="filmstrip-frame"><img src="/images/image-9.jpeg" alt="The original school building" /></div>
|
||||||
|
<div class="filmstrip-frame"><img src="/images/image-6.jpeg" alt="Students at a church celebration" /></div>
|
||||||
|
<div class="filmstrip-frame"><img src="/images/image-3.jpeg" alt="Early faculty and students" /></div>
|
||||||
|
<div class="filmstrip-frame"><img src="/images/image-8.jpeg" alt="The modern school building" /></div>
|
||||||
|
<div class="filmstrip-frame"><img src="/images/image-7.jpeg" alt="School assembly — panoramic view" /></div>
|
||||||
|
<div class="filmstrip-frame"><img src="/images/image-2.jpeg" alt="Vintage student photograph" /></div>
|
||||||
|
<div class="filmstrip-frame"><img src="/images/image-5.jpeg" alt="Alumni group photo" /></div>
|
||||||
|
<div class="filmstrip-frame"><img src="/images/image-10.jpeg" alt="Early class photograph" /></div>
|
||||||
|
</div>
|
||||||
|
<!-- Duplicate for seamless infinite loop -->
|
||||||
|
<div class="filmstrip-scroll">
|
||||||
|
<div class="filmstrip-frame"><img src="/images/image-1.jpeg" alt="" /></div>
|
||||||
|
<div class="filmstrip-frame"><img src="/images/image-11.jpeg" alt="" /></div>
|
||||||
|
<div class="filmstrip-frame"><img src="/images/image-4.jpeg" alt="" /></div>
|
||||||
|
<div class="filmstrip-frame"><img src="/images/image-9.jpeg" alt="" /></div>
|
||||||
|
<div class="filmstrip-frame"><img src="/images/image-6.jpeg" alt="" /></div>
|
||||||
|
<div class="filmstrip-frame"><img src="/images/image-3.jpeg" alt="" /></div>
|
||||||
|
<div class="filmstrip-frame"><img src="/images/image-8.jpeg" alt="" /></div>
|
||||||
|
<div class="filmstrip-frame"><img src="/images/image-7.jpeg" alt="" /></div>
|
||||||
|
<div class="filmstrip-frame"><img src="/images/image-2.jpeg" alt="" /></div>
|
||||||
|
<div class="filmstrip-frame"><img src="/images/image-5.jpeg" alt="" /></div>
|
||||||
|
<div class="filmstrip-frame"><img src="/images/image-10.jpeg" alt="" /></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- Tab Navigation -->
|
<!-- Tab Navigation -->
|
||||||
<section class="tabs-section">
|
<section class="tabs-section">
|
||||||
<div class="tabs-container">
|
<div class="tabs-container">
|
||||||
|
|||||||
BIN
public/images/image-1.jpeg
Normal file
|
After Width: | Height: | Size: 90 KiB |
BIN
public/images/image-10.jpeg
Normal file
|
After Width: | Height: | Size: 5.2 KiB |
BIN
public/images/image-11.jpeg
Normal file
|
After Width: | Height: | Size: 280 KiB |
BIN
public/images/image-2.jpeg
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
public/images/image-3.jpeg
Normal file
|
After Width: | Height: | Size: 6.9 KiB |
BIN
public/images/image-4.jpeg
Normal file
|
After Width: | Height: | Size: 8.8 KiB |
BIN
public/images/image-5.jpeg
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
BIN
public/images/image-6.jpeg
Normal file
|
After Width: | Height: | Size: 91 KiB |
BIN
public/images/image-7.jpeg
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
public/images/image-8.jpeg
Normal file
|
After Width: | Height: | Size: 9.3 KiB |
BIN
public/images/image-9.jpeg
Normal file
|
After Width: | Height: | Size: 72 KiB |
671
src/admin.css
Normal file
@@ -0,0 +1,671 @@
|
|||||||
|
/* ===== Admin Styles ===== */
|
||||||
|
/* Imports shared design tokens */
|
||||||
|
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--primary: #1a1a6e;
|
||||||
|
--primary-dark: #0e0e4a;
|
||||||
|
--primary-light: #2d2d9e;
|
||||||
|
--accent: #c8a951;
|
||||||
|
--white: #ffffff;
|
||||||
|
--gray-50: #f9fafb;
|
||||||
|
--gray-100: #f3f4f6;
|
||||||
|
--gray-200: #e5e7eb;
|
||||||
|
--gray-300: #d1d5db;
|
||||||
|
--gray-400: #9ca3af;
|
||||||
|
--gray-500: #6b7280;
|
||||||
|
--gray-600: #4b5563;
|
||||||
|
--gray-700: #374151;
|
||||||
|
--gray-800: #1f2937;
|
||||||
|
--gray-900: #111827;
|
||||||
|
--danger: #dc2626;
|
||||||
|
--success: #16a34a;
|
||||||
|
--google-blue: #4285f4;
|
||||||
|
--font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
--max-width: 960px;
|
||||||
|
--radius: 6px;
|
||||||
|
--shadow: 0 1px 3px rgba(0, 0, 0, 0.12), 0 1px 2px rgba(0, 0, 0, 0.06);
|
||||||
|
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1), 0 2px 4px rgba(0, 0, 0, 0.06);
|
||||||
|
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.1), 0 4px 6px rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
html { scroll-behavior: smooth; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--font-family);
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--gray-800);
|
||||||
|
background: var(--gray-50);
|
||||||
|
line-height: 1.6;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
a { color: var(--primary); text-decoration: none; }
|
||||||
|
img { max-width: 100%; height: auto; display: block; }
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 24px;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
font-family: var(--font-family);
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
text-decoration: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--primary);
|
||||||
|
color: var(--white);
|
||||||
|
border-color: var(--primary);
|
||||||
|
}
|
||||||
|
.btn-primary:hover { background: var(--primary-dark); border-color: var(--primary-dark); }
|
||||||
|
|
||||||
|
.btn-outline {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--primary);
|
||||||
|
border-color: var(--primary);
|
||||||
|
}
|
||||||
|
.btn-outline:hover { background: var(--primary); color: var(--white); }
|
||||||
|
|
||||||
|
.btn-sm {
|
||||||
|
padding: 6px 16px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-google {
|
||||||
|
background: var(--white);
|
||||||
|
color: var(--gray-700);
|
||||||
|
border: 1px solid var(--gray-300);
|
||||||
|
padding: 12px 24px;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.btn-google:hover { border-color: var(--google-blue); background: var(--gray-50); }
|
||||||
|
.btn-google svg { width: 20px; height: 20px; }
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
.admin-header {
|
||||||
|
background: var(--primary);
|
||||||
|
color: var(--white);
|
||||||
|
padding: 0 20px;
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-header-inner {
|
||||||
|
max-width: var(--max-width);
|
||||||
|
margin: 0 auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
height: 64px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-header-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-logo { height: 44px; width: auto; }
|
||||||
|
|
||||||
|
.admin-badge {
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--primary-dark);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 2px 10px;
|
||||||
|
border-radius: 12px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-header-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-back-link {
|
||||||
|
color: var(--gray-300);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
.admin-back-link:hover { color: var(--white); }
|
||||||
|
|
||||||
|
.admin-header-right .btn-sm {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--gray-300);
|
||||||
|
border: 1px solid var(--gray-500);
|
||||||
|
}
|
||||||
|
.admin-header-right .btn-sm:hover {
|
||||||
|
color: var(--white);
|
||||||
|
border-color: var(--white);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Auth */
|
||||||
|
.admin-auth {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: calc(100vh - 64px);
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-auth-card {
|
||||||
|
background: var(--white);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 48px 40px;
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
max-width: 420px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-auth-card h1 {
|
||||||
|
font-size: 1.6rem;
|
||||||
|
color: var(--gray-900);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-auth-card p {
|
||||||
|
color: var(--gray-500);
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-auth-buttons {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Main Content */
|
||||||
|
.admin-main {
|
||||||
|
max-width: var(--max-width);
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 32px 20px 64px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stats */
|
||||||
|
.admin-stats {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
background: var(--white);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 24px;
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
display: block;
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--gray-500);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
margin-top: 4px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tabs */
|
||||||
|
.admin-tabs {
|
||||||
|
display: flex;
|
||||||
|
border-bottom: 2px solid var(--gray-200);
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-tab {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 12px 24px;
|
||||||
|
font-family: var(--font-family);
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--gray-500);
|
||||||
|
cursor: pointer;
|
||||||
|
border-bottom: 3px solid transparent;
|
||||||
|
margin-bottom: -2px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
.admin-tab:hover { color: var(--primary); }
|
||||||
|
.admin-tab.active {
|
||||||
|
color: var(--primary);
|
||||||
|
border-bottom-color: var(--primary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Panels */
|
||||||
|
.admin-panel { display: none; }
|
||||||
|
.admin-panel.active { display: block; }
|
||||||
|
|
||||||
|
.admin-panel h2 {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
color: var(--gray-900);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-desc {
|
||||||
|
color: var(--gray-500);
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Forms */
|
||||||
|
.admin-form {
|
||||||
|
background: var(--white);
|
||||||
|
padding: 24px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--gray-700);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input,
|
||||||
|
.form-group textarea,
|
||||||
|
.form-group select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border: 1px solid var(--gray-300);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
font-family: var(--font-family);
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--gray-800);
|
||||||
|
background: var(--white);
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input:focus,
|
||||||
|
.form-group textarea:focus,
|
||||||
|
.form-group select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--primary);
|
||||||
|
box-shadow: 0 0 0 3px rgba(26, 26, 110, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group textarea { resize: vertical; min-height: 60px; }
|
||||||
|
|
||||||
|
.form-check {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-check input[type="checkbox"] {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
accent-color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-check label {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: var(--gray-700);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* CSV Upload */
|
||||||
|
.csv-upload {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.csv-upload-label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 40px 20px;
|
||||||
|
border: 2px dashed var(--gray-300);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--white);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--gray-500);
|
||||||
|
}
|
||||||
|
|
||||||
|
.csv-upload-label:hover {
|
||||||
|
border-color: var(--primary);
|
||||||
|
color: var(--primary);
|
||||||
|
background: rgba(26, 26, 110, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.csv-upload-label input { display: none; }
|
||||||
|
|
||||||
|
.csv-upload-label span { font-size: 1rem; font-weight: 500; }
|
||||||
|
|
||||||
|
/* Column Mapping */
|
||||||
|
.csv-mapping {
|
||||||
|
background: var(--white);
|
||||||
|
padding: 24px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.csv-mapping h3 {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
color: var(--gray-800);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.csv-mapping h3:first-child { margin-top: 0; }
|
||||||
|
|
||||||
|
.mapping-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapping-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapping-row label {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--gray-700);
|
||||||
|
min-width: 90px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapping-row select {
|
||||||
|
flex: 1;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid var(--gray-300);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
font-family: var(--font-family);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: var(--gray-700);
|
||||||
|
background: var(--white);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* CSV Preview */
|
||||||
|
.preview-count {
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--gray-500);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.csv-preview-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.csv-preview {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.csv-preview th,
|
||||||
|
.csv-preview td {
|
||||||
|
padding: 8px 12px;
|
||||||
|
text-align: left;
|
||||||
|
border: 1px solid var(--gray-200);
|
||||||
|
}
|
||||||
|
|
||||||
|
.csv-preview th {
|
||||||
|
background: var(--gray-100);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--gray-700);
|
||||||
|
}
|
||||||
|
|
||||||
|
.csv-preview td {
|
||||||
|
color: var(--gray-600);
|
||||||
|
}
|
||||||
|
|
||||||
|
.csv-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Progress Bar */
|
||||||
|
.import-progress {
|
||||||
|
margin-top: 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
flex: 1;
|
||||||
|
height: 8px;
|
||||||
|
background: var(--gray-200);
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: var(--primary);
|
||||||
|
border-radius: 4px;
|
||||||
|
width: 0%;
|
||||||
|
transition: width 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
#progressText {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--gray-500);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Donor Table */
|
||||||
|
.admin-search {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-search input {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 360px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border: 1px solid var(--gray-300);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
font-family: var(--font-family);
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--gray-800);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-search input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--primary);
|
||||||
|
box-shadow: 0 0 0 3px rgba(26, 26, 110, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
background: var(--white);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table th,
|
||||||
|
.admin-table td {
|
||||||
|
padding: 12px 16px;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid var(--gray-100);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table th {
|
||||||
|
background: var(--gray-50);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--gray-700);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table tbody tr:hover {
|
||||||
|
background: var(--gray-50);
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg-cell {
|
||||||
|
max-width: 200px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-cell {
|
||||||
|
white-space: nowrap;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--gray-200);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--gray-500);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon:hover { color: var(--primary); border-color: var(--primary); }
|
||||||
|
.btn-delete:hover { color: var(--danger); border-color: var(--danger); }
|
||||||
|
|
||||||
|
.anon-badge {
|
||||||
|
background: var(--gray-200);
|
||||||
|
color: var(--gray-600);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 1px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-left: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Empty State */
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px 20px;
|
||||||
|
color: var(--gray-500);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal */
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 200;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
background: var(--white);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 32px;
|
||||||
|
max-width: 520px;
|
||||||
|
width: 90%;
|
||||||
|
position: relative;
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal h2 {
|
||||||
|
font-size: 1.3rem;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close {
|
||||||
|
position: absolute;
|
||||||
|
top: 12px;
|
||||||
|
right: 16px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 1.8rem;
|
||||||
|
color: var(--gray-400);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.modal-close:hover { color: var(--gray-700); }
|
||||||
|
|
||||||
|
.hidden { display: none !important; }
|
||||||
|
|
||||||
|
/* Toast */
|
||||||
|
.toast {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 24px;
|
||||||
|
right: 24px;
|
||||||
|
padding: 12px 20px;
|
||||||
|
background: var(--gray-900);
|
||||||
|
color: var(--white);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
font-size: 1rem;
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
z-index: 300;
|
||||||
|
animation: slideUp 0.3s ease;
|
||||||
|
}
|
||||||
|
.toast.error { background: var(--danger); }
|
||||||
|
.toast.success { background: var(--success); }
|
||||||
|
|
||||||
|
@keyframes slideUp {
|
||||||
|
from { opacity: 0; transform: translateY(12px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.form-row { grid-template-columns: 1fr; }
|
||||||
|
.mapping-grid { grid-template-columns: 1fr; }
|
||||||
|
.admin-stats { grid-template-columns: 1fr; }
|
||||||
|
.admin-tab { padding: 10px 16px; font-size: 0.9rem; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.admin-header-inner { height: 52px; }
|
||||||
|
.admin-logo { height: 36px; }
|
||||||
|
.admin-auth-card { padding: 32px 20px; }
|
||||||
|
.admin-form { padding: 16px; }
|
||||||
|
.csv-mapping { padding: 16px; }
|
||||||
|
}
|
||||||
512
src/admin.js
Normal file
@@ -0,0 +1,512 @@
|
|||||||
|
import './admin.css';
|
||||||
|
import { initAuth, onAuthChange, loginWithGoogle, logout } from './auth.js';
|
||||||
|
import { db } from './firebase.js';
|
||||||
|
import { doc, getDoc, setDoc } from 'firebase/firestore';
|
||||||
|
|
||||||
|
// Hardcoded admin allowlist — used to bootstrap the Firestore config/admins document
|
||||||
|
// on first login if it doesn't exist yet.
|
||||||
|
const SEED_ADMINS = ['pcgurudm@gmail.com'];
|
||||||
|
import {
|
||||||
|
getDonors,
|
||||||
|
addDonorWithDate,
|
||||||
|
addDonorsBatch,
|
||||||
|
updateDonor,
|
||||||
|
deleteDonor,
|
||||||
|
calculateTotal,
|
||||||
|
} from './donors.js';
|
||||||
|
|
||||||
|
let allDonors = [];
|
||||||
|
|
||||||
|
// ===== Init =====
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
initAuth();
|
||||||
|
initAuthUI();
|
||||||
|
initTabs();
|
||||||
|
initAddForm();
|
||||||
|
initCSVImport();
|
||||||
|
initEditModal();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== Auth =====
|
||||||
|
function initAuthUI() {
|
||||||
|
document.getElementById('loginGoogle').addEventListener('click', loginWithGoogle);
|
||||||
|
document.getElementById('logoutBtn').addEventListener('click', logout);
|
||||||
|
document.getElementById('unauthorizedLogout').addEventListener('click', logout);
|
||||||
|
onAuthChange(handleAuth);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAuth(user) {
|
||||||
|
const authSection = document.getElementById('authSection');
|
||||||
|
const unauthorizedSection = document.getElementById('unauthorizedSection');
|
||||||
|
const adminContent = document.getElementById('adminContent');
|
||||||
|
const logoutBtn = document.getElementById('logoutBtn');
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
authSection.classList.remove('hidden');
|
||||||
|
unauthorizedSection.classList.add('hidden');
|
||||||
|
adminContent.classList.add('hidden');
|
||||||
|
logoutBtn.style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logoutBtn.style.display = '';
|
||||||
|
|
||||||
|
// Check admin allowlist
|
||||||
|
const isAdmin = await checkAdmin(user.email);
|
||||||
|
if (!isAdmin) {
|
||||||
|
authSection.classList.add('hidden');
|
||||||
|
unauthorizedSection.classList.remove('hidden');
|
||||||
|
adminContent.classList.add('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
authSection.classList.add('hidden');
|
||||||
|
unauthorizedSection.classList.add('hidden');
|
||||||
|
adminContent.classList.remove('hidden');
|
||||||
|
loadDonors();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkAdmin(email) {
|
||||||
|
try {
|
||||||
|
const adminDocRef = doc(db, 'config', 'admins');
|
||||||
|
const adminDoc = await getDoc(adminDocRef);
|
||||||
|
|
||||||
|
if (!adminDoc.exists()) {
|
||||||
|
// No allowlist yet — bootstrap it if this user is in the seed list
|
||||||
|
if (SEED_ADMINS.includes(email)) {
|
||||||
|
await setDoc(adminDocRef, { emails: SEED_ADMINS });
|
||||||
|
console.log('Admin allowlist created with seed admins.');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// No doc and not a seed admin — deny
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const emails = adminDoc.data()?.emails || [];
|
||||||
|
return emails.includes(email);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Admin check failed:', err);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Tabs =====
|
||||||
|
function initTabs() {
|
||||||
|
document.querySelectorAll('.admin-tab').forEach((btn) => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
document.querySelectorAll('.admin-tab').forEach((b) => b.classList.remove('active'));
|
||||||
|
document.querySelectorAll('.admin-panel').forEach((p) => p.classList.remove('active'));
|
||||||
|
btn.classList.add('active');
|
||||||
|
document.getElementById(`panel-${btn.dataset.tab}`).classList.add('active');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Load Donors =====
|
||||||
|
async function loadDonors() {
|
||||||
|
try {
|
||||||
|
allDonors = await getDonors();
|
||||||
|
updateStats();
|
||||||
|
renderDonorTable();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load donors:', err);
|
||||||
|
showToast('Failed to load donors.', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateStats() {
|
||||||
|
const total = calculateTotal(allDonors);
|
||||||
|
document.getElementById('statTotal').textContent = `$${total.toLocaleString()}`;
|
||||||
|
document.getElementById('statCount').textContent = allDonors.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Add Donor Form =====
|
||||||
|
function initAddForm() {
|
||||||
|
const form = document.getElementById('addDonorForm');
|
||||||
|
form.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const btn = document.getElementById('addDonorBtn');
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = 'Adding...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
await addDonorWithDate({
|
||||||
|
name: document.getElementById('donorName').value.trim(),
|
||||||
|
amount: document.getElementById('donorAmount').value,
|
||||||
|
classYear: document.getElementById('donorClassYear').value.trim(),
|
||||||
|
message: document.getElementById('donorMessage').value.trim(),
|
||||||
|
anonymous: document.getElementById('donorAnonymous').checked,
|
||||||
|
date: document.getElementById('donorDate').value || null,
|
||||||
|
});
|
||||||
|
showToast('Donor added!', 'success');
|
||||||
|
form.reset();
|
||||||
|
await loadDonors();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to add donor:', err);
|
||||||
|
showToast('Failed to add donor.', 'error');
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'Add Donor';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== CSV Import =====
|
||||||
|
let csvRows = [];
|
||||||
|
let csvHeaders = [];
|
||||||
|
|
||||||
|
function initCSVImport() {
|
||||||
|
const fileInput = document.getElementById('csvFile');
|
||||||
|
fileInput.addEventListener('change', handleFileSelect);
|
||||||
|
|
||||||
|
document.getElementById('importBtn').addEventListener('click', runImport);
|
||||||
|
document.getElementById('csvCancelBtn').addEventListener('click', resetCSV);
|
||||||
|
|
||||||
|
// Update preview when mappings change
|
||||||
|
document.querySelectorAll('.mapping-grid select').forEach((sel) => {
|
||||||
|
sel.addEventListener('change', updatePreview);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFileSelect(e) {
|
||||||
|
const file = e.target.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (evt) => {
|
||||||
|
const text = evt.target.result;
|
||||||
|
const parsed = parseCSV(text);
|
||||||
|
if (parsed.length < 2) {
|
||||||
|
showToast('CSV file appears empty or has no data rows.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
csvHeaders = parsed[0];
|
||||||
|
csvRows = parsed.slice(1);
|
||||||
|
setupMapping();
|
||||||
|
document.getElementById('csvMapping').classList.remove('hidden');
|
||||||
|
};
|
||||||
|
reader.readAsText(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCSV(text) {
|
||||||
|
const rows = [];
|
||||||
|
let current = [];
|
||||||
|
let field = '';
|
||||||
|
let inQuotes = false;
|
||||||
|
|
||||||
|
for (let i = 0; i < text.length; i++) {
|
||||||
|
const ch = text[i];
|
||||||
|
const next = text[i + 1];
|
||||||
|
|
||||||
|
if (inQuotes) {
|
||||||
|
if (ch === '"' && next === '"') {
|
||||||
|
field += '"';
|
||||||
|
i++;
|
||||||
|
} else if (ch === '"') {
|
||||||
|
inQuotes = false;
|
||||||
|
} else {
|
||||||
|
field += ch;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (ch === '"') {
|
||||||
|
inQuotes = true;
|
||||||
|
} else if (ch === ',') {
|
||||||
|
current.push(field.trim());
|
||||||
|
field = '';
|
||||||
|
} else if (ch === '\n' || (ch === '\r' && next === '\n')) {
|
||||||
|
current.push(field.trim());
|
||||||
|
if (current.some((c) => c !== '')) rows.push(current);
|
||||||
|
current = [];
|
||||||
|
field = '';
|
||||||
|
if (ch === '\r') i++;
|
||||||
|
} else {
|
||||||
|
field += ch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Last field/row
|
||||||
|
current.push(field.trim());
|
||||||
|
if (current.some((c) => c !== '')) rows.push(current);
|
||||||
|
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupMapping() {
|
||||||
|
const fields = ['mapName', 'mapAmount', 'mapClassYear', 'mapMessage', 'mapDate', 'mapAnonymous'];
|
||||||
|
const hints = {
|
||||||
|
mapName: ['name', 'donor', 'first', 'full'],
|
||||||
|
mapAmount: ['amount', 'donation', 'gift', 'total', 'sum'],
|
||||||
|
mapClassYear: ['class', 'year', 'grad'],
|
||||||
|
mapMessage: ['message', 'note', 'comment'],
|
||||||
|
mapDate: ['date', 'time', 'when'],
|
||||||
|
mapAnonymous: ['anonymous', 'anon'],
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const fieldId of fields) {
|
||||||
|
const select = document.getElementById(fieldId);
|
||||||
|
select.innerHTML = '<option value="">-- skip --</option>';
|
||||||
|
csvHeaders.forEach((h, i) => {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = i;
|
||||||
|
opt.textContent = h;
|
||||||
|
select.appendChild(opt);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto-map by fuzzy match
|
||||||
|
const keywords = hints[fieldId];
|
||||||
|
const match = csvHeaders.findIndex((h) =>
|
||||||
|
keywords.some((k) => h.toLowerCase().includes(k))
|
||||||
|
);
|
||||||
|
if (match !== -1) select.value = match;
|
||||||
|
}
|
||||||
|
|
||||||
|
updatePreview();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMappedRow(row) {
|
||||||
|
const get = (id) => {
|
||||||
|
const val = document.getElementById(id).value;
|
||||||
|
return val !== '' ? (row[Number(val)] || '') : '';
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
name: get('mapName'),
|
||||||
|
amount: get('mapAmount'),
|
||||||
|
classYear: get('mapClassYear'),
|
||||||
|
message: get('mapMessage'),
|
||||||
|
date: get('mapDate'),
|
||||||
|
anonymous: get('mapAnonymous'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePreview() {
|
||||||
|
const head = document.getElementById('csvPreviewHead');
|
||||||
|
const body = document.getElementById('csvPreviewBody');
|
||||||
|
|
||||||
|
head.innerHTML = '<tr><th>Name</th><th>Amount</th><th>Class Year</th><th>Date</th><th>Message</th><th>Anon</th></tr>';
|
||||||
|
|
||||||
|
const previewRows = csvRows.slice(0, 5);
|
||||||
|
body.innerHTML = previewRows
|
||||||
|
.map((row) => {
|
||||||
|
const m = getMappedRow(row);
|
||||||
|
return `<tr>
|
||||||
|
<td>${esc(m.name)}</td>
|
||||||
|
<td>${esc(m.amount)}</td>
|
||||||
|
<td>${esc(m.classYear)}</td>
|
||||||
|
<td>${esc(m.date)}</td>
|
||||||
|
<td>${esc(m.message)}</td>
|
||||||
|
<td>${normBool(m.anonymous) ? 'Yes' : 'No'}</td>
|
||||||
|
</tr>`;
|
||||||
|
})
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
document.getElementById('previewCount').textContent = `(${csvRows.length} rows total)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normBool(val) {
|
||||||
|
if (!val) return false;
|
||||||
|
const v = val.toString().toLowerCase().trim();
|
||||||
|
return v === 'true' || v === 'yes' || v === '1';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runImport() {
|
||||||
|
const importBtn = document.getElementById('importBtn');
|
||||||
|
const progressEl = document.getElementById('importProgress');
|
||||||
|
const progressFill = document.getElementById('progressFill');
|
||||||
|
const progressText = document.getElementById('progressText');
|
||||||
|
|
||||||
|
const donors = csvRows
|
||||||
|
.map((row) => {
|
||||||
|
const m = getMappedRow(row);
|
||||||
|
const amount = parseFloat(m.amount.replace(/[^0-9.\-]/g, ''));
|
||||||
|
if (!m.name && !normBool(m.anonymous)) return null;
|
||||||
|
if (isNaN(amount) || amount <= 0) return null;
|
||||||
|
return {
|
||||||
|
name: m.name || 'Anonymous',
|
||||||
|
amount,
|
||||||
|
classYear: m.classYear,
|
||||||
|
message: m.message,
|
||||||
|
date: m.date || null,
|
||||||
|
anonymous: normBool(m.anonymous),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
if (donors.length === 0) {
|
||||||
|
showToast('No valid rows to import. Check your column mapping.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!confirm(`Import ${donors.length} donors? (${csvRows.length - donors.length} rows will be skipped due to missing name/amount)`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
importBtn.disabled = true;
|
||||||
|
progressEl.classList.remove('hidden');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Process in chunks of 500 with progress
|
||||||
|
const chunkSize = 500;
|
||||||
|
let imported = 0;
|
||||||
|
for (let i = 0; i < donors.length; i += chunkSize) {
|
||||||
|
const chunk = donors.slice(i, i + chunkSize);
|
||||||
|
await addDonorsBatch(chunk);
|
||||||
|
imported += chunk.length;
|
||||||
|
const pct = Math.round((imported / donors.length) * 100);
|
||||||
|
progressFill.style.width = pct + '%';
|
||||||
|
progressText.textContent = `Imported ${imported} of ${donors.length}...`;
|
||||||
|
}
|
||||||
|
|
||||||
|
showToast(`Successfully imported ${imported} donors!`, 'success');
|
||||||
|
resetCSV();
|
||||||
|
await loadDonors();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Import failed:', err);
|
||||||
|
showToast('Import failed. Some rows may have been imported.', 'error');
|
||||||
|
} finally {
|
||||||
|
importBtn.disabled = false;
|
||||||
|
progressEl.classList.add('hidden');
|
||||||
|
progressFill.style.width = '0%';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetCSV() {
|
||||||
|
csvRows = [];
|
||||||
|
csvHeaders = [];
|
||||||
|
document.getElementById('csvFile').value = '';
|
||||||
|
document.getElementById('csvMapping').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Donor Table =====
|
||||||
|
function renderDonorTable(filter = '') {
|
||||||
|
const tbody = document.getElementById('donorTableBody');
|
||||||
|
const emptyState = document.getElementById('donorListEmpty');
|
||||||
|
const filtered = filter
|
||||||
|
? allDonors.filter((d) => d.name.toLowerCase().includes(filter.toLowerCase()))
|
||||||
|
: allDonors;
|
||||||
|
|
||||||
|
if (filtered.length === 0) {
|
||||||
|
tbody.innerHTML = '';
|
||||||
|
emptyState.classList.remove('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
emptyState.classList.add('hidden');
|
||||||
|
tbody.innerHTML = filtered
|
||||||
|
.map((d) => {
|
||||||
|
const date = d.date?.toDate ? d.date.toDate().toLocaleDateString() : '';
|
||||||
|
return `<tr>
|
||||||
|
<td>${esc(d.name)}${d.anonymous ? ' <span class="anon-badge">Anon</span>' : ''}</td>
|
||||||
|
<td>$${(d.amount || 0).toLocaleString()}</td>
|
||||||
|
<td>${esc(d.classYear)}</td>
|
||||||
|
<td>${date}</td>
|
||||||
|
<td class="msg-cell">${esc(d.message)}</td>
|
||||||
|
<td class="action-cell">
|
||||||
|
<button class="btn-icon btn-edit" data-id="${d.id}" title="Edit">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
|
||||||
|
</button>
|
||||||
|
<button class="btn-icon btn-delete" data-id="${d.id}" title="Delete">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/></svg>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>`;
|
||||||
|
})
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
// Attach handlers
|
||||||
|
tbody.querySelectorAll('.btn-edit').forEach((btn) => {
|
||||||
|
btn.addEventListener('click', () => openEditModal(btn.dataset.id));
|
||||||
|
});
|
||||||
|
tbody.querySelectorAll('.btn-delete').forEach((btn) => {
|
||||||
|
btn.addEventListener('click', () => handleDelete(btn.dataset.id));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Search
|
||||||
|
const searchInput = document.getElementById('donorSearch');
|
||||||
|
searchInput.removeEventListener('input', searchHandler);
|
||||||
|
searchInput.addEventListener('input', searchHandler);
|
||||||
|
}
|
||||||
|
|
||||||
|
function searchHandler(e) {
|
||||||
|
renderDonorTable(e.target.value.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(id) {
|
||||||
|
if (!confirm('Delete this donor? This cannot be undone.')) return;
|
||||||
|
try {
|
||||||
|
await deleteDonor(id);
|
||||||
|
showToast('Donor deleted.', 'success');
|
||||||
|
await loadDonors();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Delete failed:', err);
|
||||||
|
showToast('Failed to delete donor.', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Edit Modal =====
|
||||||
|
function initEditModal() {
|
||||||
|
const modal = document.getElementById('editModal');
|
||||||
|
document.getElementById('editModalClose').addEventListener('click', () => modal.classList.add('hidden'));
|
||||||
|
modal.addEventListener('click', (e) => {
|
||||||
|
if (e.target === modal) modal.classList.add('hidden');
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('editDonorForm').addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const id = document.getElementById('editDonorId').value;
|
||||||
|
const btn = e.target.querySelector('button[type="submit"]');
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = 'Saving...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updateDonor(id, {
|
||||||
|
name: document.getElementById('editAnonymous').checked
|
||||||
|
? 'Anonymous'
|
||||||
|
: document.getElementById('editName').value.trim(),
|
||||||
|
amount: Number(document.getElementById('editAmount').value),
|
||||||
|
classYear: document.getElementById('editClassYear').value.trim(),
|
||||||
|
message: document.getElementById('editMessage').value.trim(),
|
||||||
|
anonymous: document.getElementById('editAnonymous').checked,
|
||||||
|
});
|
||||||
|
showToast('Donor updated!', 'success');
|
||||||
|
modal.classList.add('hidden');
|
||||||
|
await loadDonors();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Update failed:', err);
|
||||||
|
showToast('Failed to update donor.', 'error');
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'Save Changes';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditModal(id) {
|
||||||
|
const donor = allDonors.find((d) => d.id === id);
|
||||||
|
if (!donor) return;
|
||||||
|
|
||||||
|
document.getElementById('editDonorId').value = id;
|
||||||
|
document.getElementById('editName').value = donor.name || '';
|
||||||
|
document.getElementById('editAmount').value = donor.amount || '';
|
||||||
|
document.getElementById('editClassYear').value = donor.classYear || '';
|
||||||
|
document.getElementById('editMessage').value = donor.message || '';
|
||||||
|
document.getElementById('editAnonymous').checked = !!donor.anonymous;
|
||||||
|
document.getElementById('editModal').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Utilities =====
|
||||||
|
function esc(str) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = str || '';
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showToast(message, type = 'info') {
|
||||||
|
const existing = document.querySelector('.toast');
|
||||||
|
if (existing) existing.remove();
|
||||||
|
|
||||||
|
const toast = document.createElement('div');
|
||||||
|
toast.className = `toast ${type}`;
|
||||||
|
toast.textContent = message;
|
||||||
|
document.body.appendChild(toast);
|
||||||
|
setTimeout(() => toast.remove(), 4000);
|
||||||
|
}
|
||||||
@@ -4,7 +4,13 @@ import {
|
|||||||
orderBy,
|
orderBy,
|
||||||
onSnapshot,
|
onSnapshot,
|
||||||
addDoc,
|
addDoc,
|
||||||
|
getDocs,
|
||||||
|
doc,
|
||||||
|
updateDoc,
|
||||||
|
deleteDoc,
|
||||||
|
writeBatch,
|
||||||
serverTimestamp,
|
serverTimestamp,
|
||||||
|
Timestamp,
|
||||||
} from 'firebase/firestore';
|
} from 'firebase/firestore';
|
||||||
import { db } from './firebase.js';
|
import { db } from './firebase.js';
|
||||||
|
|
||||||
@@ -45,6 +51,62 @@ export function calculateTotal(donors) {
|
|||||||
return donors.reduce((sum, d) => sum + (d.amount || 0), 0);
|
return donors.reduce((sum, d) => sum + (d.amount || 0), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== Admin Operations =====
|
||||||
|
|
||||||
|
export async function getDonors() {
|
||||||
|
const q = query(donorsRef, orderBy('date', 'desc'));
|
||||||
|
const snapshot = await getDocs(q);
|
||||||
|
const donors = [];
|
||||||
|
snapshot.forEach((d) => donors.push({ id: d.id, ...d.data() }));
|
||||||
|
return donors;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addDonorWithDate({ name, amount, classYear, message, anonymous, date }) {
|
||||||
|
const dateValue = date ? Timestamp.fromDate(new Date(date)) : serverTimestamp();
|
||||||
|
return addDoc(donorsRef, {
|
||||||
|
name: anonymous ? 'Anonymous' : name,
|
||||||
|
amount: Number(amount),
|
||||||
|
classYear: classYear || '',
|
||||||
|
message: message || '',
|
||||||
|
anonymous: !!anonymous,
|
||||||
|
date: dateValue,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateDonor(id, fields) {
|
||||||
|
return updateDoc(doc(db, 'donors', id), fields);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteDonor(id) {
|
||||||
|
return deleteDoc(doc(db, 'donors', id));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addDonorsBatch(donorsArray) {
|
||||||
|
const chunks = [];
|
||||||
|
for (let i = 0; i < donorsArray.length; i += 500) {
|
||||||
|
chunks.push(donorsArray.slice(i, i + 500));
|
||||||
|
}
|
||||||
|
let imported = 0;
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
const batch = writeBatch(db);
|
||||||
|
for (const d of chunk) {
|
||||||
|
const ref = doc(collection(db, 'donors'));
|
||||||
|
const dateValue = d.date ? Timestamp.fromDate(new Date(d.date)) : serverTimestamp();
|
||||||
|
batch.set(ref, {
|
||||||
|
name: d.anonymous ? 'Anonymous' : (d.name || 'Anonymous'),
|
||||||
|
amount: Number(d.amount) || 0,
|
||||||
|
classYear: d.classYear || '',
|
||||||
|
message: d.message || '',
|
||||||
|
anonymous: !!d.anonymous,
|
||||||
|
date: dateValue,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await batch.commit();
|
||||||
|
imported += chunk.length;
|
||||||
|
}
|
||||||
|
return imported;
|
||||||
|
}
|
||||||
|
|
||||||
export function sortDonors(donors, sortBy) {
|
export function sortDonors(donors, sortBy) {
|
||||||
const sorted = [...donors];
|
const sorted = [...donors];
|
||||||
if (sortBy === 'amount') {
|
if (sortBy === 'amount') {
|
||||||
|
|||||||
138
src/style.css
@@ -204,35 +204,12 @@ img {
|
|||||||
transition: transform 0.5s ease-in-out;
|
transition: transform 0.5s ease-in-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
.carousel-slide {
|
.carousel-slide .placeholder-content {
|
||||||
flex: 0 0 100%;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.carousel-placeholder {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
color: var(--white);
|
color: var(--white);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#slide1 {
|
|
||||||
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
#slide2 {
|
|
||||||
background: linear-gradient(135deg, #2d5a27 0%, #1a3a15 100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
#slide3 {
|
|
||||||
background: linear-gradient(135deg, var(--accent) 0%, #8a6e2f 100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.placeholder-content h2 {
|
.placeholder-content h2 {
|
||||||
font-size: 2.4rem;
|
font-size: 2.4rem;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
@@ -337,6 +314,103 @@ img {
|
|||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===== Carousel Slide Backgrounds ===== */
|
||||||
|
.carousel-slide {
|
||||||
|
flex: 0 0 100%;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.carousel-slide-bg {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.carousel-slide-bg img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.carousel-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(10, 10, 60, 0.55);
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.carousel-slide .placeholder-content {
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
text-shadow: 0 2px 12px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Photo Filmstrip ===== */
|
||||||
|
.filmstrip-section {
|
||||||
|
padding: 32px 0 0;
|
||||||
|
background: var(--white);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filmstrip-title {
|
||||||
|
font-size: 1.6rem;
|
||||||
|
color: var(--gray-900);
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filmstrip-track {
|
||||||
|
display: flex;
|
||||||
|
width: max-content;
|
||||||
|
animation: filmstrip-scroll 40s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filmstrip-track:hover {
|
||||||
|
animation-play-state: paused;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filmstrip-scroll {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
padding-right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filmstrip-frame {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 220px;
|
||||||
|
height: 150px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
transition: transform 0.3s ease, box-shadow 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filmstrip-frame:hover {
|
||||||
|
transform: scale(1.06);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filmstrip-frame img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes filmstrip-scroll {
|
||||||
|
0% {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* ===== Tabs ===== */
|
/* ===== Tabs ===== */
|
||||||
.tabs-section {
|
.tabs-section {
|
||||||
padding: 0 20px;
|
padding: 0 20px;
|
||||||
@@ -351,7 +425,13 @@ img {
|
|||||||
display: flex;
|
display: flex;
|
||||||
border-bottom: 2px solid var(--gray-200);
|
border-bottom: 2px solid var(--gray-200);
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-nav::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab-btn {
|
.tab-btn {
|
||||||
@@ -1117,6 +1197,11 @@ img {
|
|||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.filmstrip-frame {
|
||||||
|
width: 180px;
|
||||||
|
height: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
.footer-inner {
|
.footer-inner {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -1161,6 +1246,11 @@ img {
|
|||||||
height: 40px;
|
height: 40px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.filmstrip-frame {
|
||||||
|
width: 150px;
|
||||||
|
height: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
.carousel {
|
.carousel {
|
||||||
aspect-ratio: 4 / 3;
|
aspect-ratio: 4 / 3;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,17 @@
|
|||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from 'vite';
|
||||||
|
import { resolve } from 'path';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
root: '.',
|
root: '.',
|
||||||
build: {
|
build: {
|
||||||
outDir: 'dist',
|
outDir: 'dist',
|
||||||
emptyOutDir: true,
|
emptyOutDir: true,
|
||||||
|
rollupOptions: {
|
||||||
|
input: {
|
||||||
|
main: resolve(__dirname, 'index.html'),
|
||||||
|
admin: resolve(__dirname, 'admin.html'),
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
port: 3000,
|
port: 3000,
|
||||||
|
|||||||