added admin page
This commit is contained in:
671
src/admin.css
Normal file
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
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,
|
||||
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') {
|
||||
|
||||
138
src/style.css
138
src/style.css
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user