added admin page

This commit is contained in:
2026-03-11 21:18:58 -04:00
parent 10ab8a64cb
commit 504e5b9bc7
44 changed files with 2075 additions and 140 deletions

235
admin.html Normal file
View 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">&times;</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
View 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">&times;</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
View 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

File diff suppressed because one or more lines are too long

36
dist/assets/donors-DDFnEjnq.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

1
dist/assets/main-BYboBf7A.css vendored Normal file

File diff suppressed because one or more lines are too long

32
dist/assets/main-f9-9VaoM.js vendored Normal file
View 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?` &middot; 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

BIN
dist/images/image-10.jpeg vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

BIN
dist/images/image-11.jpeg vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 280 KiB

BIN
dist/images/image-2.jpeg vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

BIN
dist/images/image-3.jpeg vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

BIN
dist/images/image-4.jpeg vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

BIN
dist/images/image-5.jpeg vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

BIN
dist/images/image-6.jpeg vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

BIN
dist/images/image-7.jpeg vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

BIN
dist/images/image-8.jpeg vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

BIN
dist/images/image-9.jpeg vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 253 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

View File

@@ -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>
<!-- Divider line -->
<line x1="55" y1="110" x2="145" y2="110" stroke="#c8a951" stroke-width="2"/>
<!-- Est 1925 -->
<text x="100" y="132" font-family="Georgia, serif" font-size="14" fill="#c8a951" text-anchor="middle">EST. 1925</text>
<!-- Est 1926 -->
<text x="100" y="132" font-family="Georgia, serif" font-size="14" fill="#c8a951" text-anchor="middle">EST. 1926</text>
<!-- 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>
</svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

89
dist/index.html vendored
View File

@@ -9,8 +9,9 @@
<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/index-DWzoJss_.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Cpged3wR.css">
<script type="module" crossorigin src="/assets/main-f9-9VaoM.js"></script>
<link rel="modulepreload" crossorigin href="/assets/donors-DDFnEjnq.js">
<link rel="stylesheet" crossorigin href="/assets/main-BYboBf7A.css">
</head>
<body>
<!-- Top Header Bar -->
@@ -45,31 +46,37 @@
<div class="carousel" id="carousel">
<div class="carousel-track" id="carouselTrack">
<div class="carousel-slide active">
<div class="carousel-placeholder" id="slide1">
<div class="placeholder-content">
<h2>Wesley High School</h2>
<p>100th Anniversary</p>
<p class="year-range">1925 - 2025</p>
<p>Celebrating a Century of Excellence</p>
</div>
<div class="carousel-slide-bg">
<img src="/images/image-1.jpeg" alt="" />
</div>
<div class="carousel-overlay"></div>
<div class="placeholder-content">
<h2>Wesley High School</h2>
<p>100th Anniversary</p>
<p class="year-range">1926 - 2026</p>
<p>Celebrating a Century of Excellence</p>
</div>
</div>
<div class="carousel-slide">
<div class="carousel-placeholder" id="slide2">
<div class="placeholder-content">
<h2>Support Our Legacy</h2>
<p>Help us continue building</p>
<p>the next 100 years</p>
</div>
<div class="carousel-slide-bg">
<img src="/images/image-9.jpeg" alt="" />
</div>
<div class="carousel-overlay"></div>
<div class="placeholder-content">
<h2>Support Our Legacy</h2>
<p>Help us continue building</p>
<p>the next 100 years</p>
</div>
</div>
<div class="carousel-slide">
<div class="carousel-placeholder" id="slide3">
<div class="placeholder-content">
<h2>Class Challenge</h2>
<p>Click below to make</p>
<p>your gift today!</p>
</div>
<div class="carousel-slide-bg">
<img src="/images/image-6.jpeg" alt="" />
</div>
<div class="carousel-overlay"></div>
<div class="placeholder-content">
<h2>Class Challenge</h2>
<p>Click below to make</p>
<p>your gift today!</p>
</div>
</div>
</div>
@@ -93,6 +100,40 @@
</div>
</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 -->
<section class="tabs-section">
<div class="tabs-container">
@@ -110,8 +151,8 @@
<div class="tab-panel active" id="tab-about">
<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>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>Our goal is to raise funds that will support scholarships, campus improvements, and programs that will benefit students for the next century.</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, and a science lab for our students.</p>
<div class="about-cta">
<a href="https://pay.shopdm.store/dominica-methodist-circuit" target="_blank" rel="noopener" class="btn btn-primary">Donate Now</a>
</div>
@@ -252,7 +293,7 @@
</div>
<div class="footer-bottom">
<p>Privacy Policy &nbsp;|&nbsp; Terms and Conditions</p>
<p>Copyright &copy; 2025 Wesley High School. All rights reserved.</p>
<p>Copyright &copy; 2026 Wesley High School. All rights reserved.</p>
</div>
</footer>

View File

@@ -3,6 +3,21 @@ rules_version = '2';
service cloud.firestore {
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)
match /students/{studentId} {
allow read: if true;
@@ -13,10 +28,9 @@ service cloud.firestore {
match /donors/{donorId} {
// Anyone can read donors
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;
// No one can update or delete donors (admin-only via console)
allow update, delete: if false;
allow update, delete: if isAdmin();
}
// Comments collection

View File

@@ -44,31 +44,37 @@
<div class="carousel" id="carousel">
<div class="carousel-track" id="carouselTrack">
<div class="carousel-slide active">
<div class="carousel-placeholder" id="slide1">
<div class="placeholder-content">
<h2>Wesley High School</h2>
<p>100th Anniversary</p>
<p class="year-range">1926 - 2026</p>
<p>Celebrating a Century of Excellence</p>
</div>
<div class="carousel-slide-bg">
<img src="/images/image-1.jpeg" alt="" />
</div>
<div class="carousel-overlay"></div>
<div class="placeholder-content">
<h2>Wesley High School</h2>
<p>100th Anniversary</p>
<p class="year-range">1926 - 2026</p>
<p>Celebrating a Century of Excellence</p>
</div>
</div>
<div class="carousel-slide">
<div class="carousel-placeholder" id="slide2">
<div class="placeholder-content">
<h2>Support Our Legacy</h2>
<p>Help us continue building</p>
<p>the next 100 years</p>
</div>
<div class="carousel-slide-bg">
<img src="/images/image-9.jpeg" alt="" />
</div>
<div class="carousel-overlay"></div>
<div class="placeholder-content">
<h2>Support Our Legacy</h2>
<p>Help us continue building</p>
<p>the next 100 years</p>
</div>
</div>
<div class="carousel-slide">
<div class="carousel-placeholder" id="slide3">
<div class="placeholder-content">
<h2>Class Challenge</h2>
<p>Click below to make</p>
<p>your gift today!</p>
</div>
<div class="carousel-slide-bg">
<img src="/images/image-6.jpeg" alt="" />
</div>
<div class="carousel-overlay"></div>
<div class="placeholder-content">
<h2>Class Challenge</h2>
<p>Click below to make</p>
<p>your gift today!</p>
</div>
</div>
</div>
@@ -92,6 +98,40 @@
</div>
</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 -->
<section class="tabs-section">
<div class="tabs-container">

BIN
public/images/image-1.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

BIN
public/images/image-10.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

BIN
public/images/image-11.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 280 KiB

BIN
public/images/image-2.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

BIN
public/images/image-3.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

BIN
public/images/image-4.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

BIN
public/images/image-5.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

BIN
public/images/image-6.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

BIN
public/images/image-7.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

BIN
public/images/image-8.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

BIN
public/images/image-9.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

671
src/admin.css Normal file
View 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
View 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);
}

View File

@@ -4,7 +4,13 @@ import {
orderBy,
onSnapshot,
addDoc,
getDocs,
doc,
updateDoc,
deleteDoc,
writeBatch,
serverTimestamp,
Timestamp,
} from 'firebase/firestore';
import { db } from './firebase.js';
@@ -45,6 +51,62 @@ export function calculateTotal(donors) {
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) {
const sorted = [...donors];
if (sortBy === 'amount') {

View File

@@ -204,35 +204,12 @@ img {
transition: transform 0.5s ease-in-out;
}
.carousel-slide {
flex: 0 0 100%;
width: 100%;
height: 100%;
}
.carousel-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
.carousel-slide .placeholder-content {
color: var(--white);
text-align: center;
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 {
font-size: 2.4rem;
margin-bottom: 8px;
@@ -337,6 +314,103 @@ img {
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-section {
padding: 0 20px;
@@ -351,7 +425,13 @@ img {
display: flex;
border-bottom: 2px solid var(--gray-200);
overflow-x: auto;
overflow-y: hidden;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
}
.tab-nav::-webkit-scrollbar {
display: none;
}
.tab-btn {
@@ -1117,6 +1197,11 @@ img {
font-size: 1rem;
}
.filmstrip-frame {
width: 180px;
height: 120px;
}
.footer-inner {
grid-template-columns: 1fr;
text-align: center;
@@ -1161,6 +1246,11 @@ img {
height: 40px;
}
.filmstrip-frame {
width: 150px;
height: 100px;
}
.carousel {
aspect-ratio: 4 / 3;
}

View File

@@ -1,10 +1,17 @@
import { defineConfig } from 'vite';
import { resolve } from 'path';
export default defineConfig({
root: '.',
build: {
outDir: 'dist',
emptyOutDir: true,
rollupOptions: {
input: {
main: resolve(__dirname, 'index.html'),
admin: resolve(__dirname, 'admin.html'),
},
},
},
server: {
port: 3000,