feat: initial release of Brightwheel Memories & Reports Exporter Chrome extension

This commit is contained in:
2026-08-23 11:25:19 -07:00
commit 13ccc6ca63
16 changed files with 2795 additions and 0 deletions
+616
View File
@@ -0,0 +1,616 @@
:root {
--primary: #FF6B35;
--primary-hover: #E85820;
--primary-light: #FFF0EB;
--primary-gradient: linear-gradient(135deg, #FF6B35 0%, #FF8E53 100%);
--bg: #F8FAFC;
--card-bg: #FFFFFF;
--text-main: #0F172A;
--text-muted: #64748B;
--border: #E2E8F0;
--radius: 12px;
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 16px -2px rgba(0, 0, 0, 0.08);
--success: #10B981;
--warning: #F59E0B;
--danger: #EF4444;
--font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: var(--font);
background-color: var(--bg);
color: var(--text-main);
min-height: 100vh;
-webkit-font-smoothing: antialiased;
}
/* App Layout */
.app-layout {
display: flex;
flex-direction: column;
min-height: 100vh;
}
/* Navigation Bar */
.top-nav {
background: var(--card-bg);
border-bottom: 1px solid var(--border);
padding: 14px 28px;
display: flex;
align-items: center;
justify-content: space-between;
position: sticky;
top: 0;
z-index: 100;
}
.nav-brand {
display: flex;
align-items: center;
gap: 14px;
}
.brand-icon {
width: 42px;
height: 42px;
background: var(--primary-gradient);
color: #FFFFFF;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 12px rgba(255, 107, 53, 0.3);
}
.brand-text h1 {
font-size: 18px;
font-weight: 800;
color: var(--text-main);
line-height: 1.2;
}
.brand-text span {
font-size: 12px;
color: var(--text-muted);
}
.session-badge {
display: flex;
align-items: center;
gap: 8px;
background: #F1F5F9;
padding: 8px 14px;
border-radius: 20px;
font-size: 12.5px;
font-weight: 500;
color: #334155;
border: 1px solid var(--border);
}
.session-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background-color: var(--warning);
}
.session-dot.online {
background-color: var(--success);
box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.2);
}
.icon-btn {
background: none;
border: none;
cursor: pointer;
color: var(--text-muted);
display: flex;
align-items: center;
padding: 2px;
}
.icon-btn:hover {
color: var(--primary);
}
/* Main Container */
.main-container {
max-width: 1280px;
width: 100%;
margin: 28px auto;
padding: 0 24px;
flex: 1;
}
.dashboard-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 28px;
align-items: start;
}
@media (max-width: 900px) {
.dashboard-grid {
grid-template-columns: 1fr;
}
}
/* Cards */
.card {
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow-sm);
overflow: hidden;
margin-bottom: 20px;
}
.card-header {
padding: 16px 20px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
}
.card-title-group {
display: flex;
align-items: center;
gap: 10px;
}
.step-num {
width: 22px;
height: 22px;
background: var(--primary-light);
color: var(--primary);
font-weight: 700;
font-size: 12px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.card-header h2 {
font-size: 15px;
font-weight: 700;
color: var(--text-main);
}
.card-content {
padding: 20px;
}
/* Students Selector */
.students-list {
display: flex;
flex-direction: column;
gap: 10px;
margin-bottom: 12px;
}
.student-card {
display: flex;
align-items: center;
gap: 14px;
padding: 12px 14px;
border: 2px solid var(--border);
border-radius: 10px;
cursor: pointer;
transition: all 0.2s ease;
background: #FAFAFA;
}
.student-card:hover {
border-color: #CBD5E1;
}
.student-card.selected {
border-color: var(--primary);
background: var(--primary-light);
}
.student-avatar {
width: 44px;
height: 44px;
border-radius: 50%;
background: var(--primary-gradient);
color: #FFFFFF;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
overflow: hidden;
flex-shrink: 0;
}
.student-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.student-info h4 {
font-size: 14px;
font-weight: 700;
color: var(--text-main);
}
.student-info p {
font-size: 12px;
color: var(--text-muted);
}
.loading-placeholder {
display: flex;
align-items: center;
gap: 10px;
color: var(--text-muted);
font-size: 13px;
padding: 12px 0;
}
.spinner {
width: 16px;
height: 16px;
border: 2px solid #E2E8F0;
border-top-color: var(--primary);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.manual-id-details summary {
font-size: 12.5px;
color: var(--primary);
cursor: pointer;
margin-top: 4px;
user-select: none;
}
.manual-input-row {
display: flex;
gap: 8px;
margin-top: 10px;
}
.manual-input-row input {
flex: 1;
padding: 8px 12px;
border: 1px solid var(--border);
border-radius: 8px;
font-size: 12.5px;
outline: none;
}
.manual-input-row input:focus {
border-color: var(--primary);
}
/* Date Filters */
.quick-dates-pills {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 14px;
}
.pill-btn {
padding: 6px 12px;
border-radius: 20px;
border: 1px solid var(--border);
background: #FFFFFF;
font-size: 12.5px;
font-weight: 600;
color: var(--text-muted);
cursor: pointer;
transition: all 0.2s ease;
}
.pill-btn:hover {
border-color: #94A3B8;
}
.pill-btn.active {
background: var(--primary);
border-color: var(--primary);
color: #FFFFFF;
}
.date-inputs-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.date-field {
display: flex;
flex-direction: column;
gap: 4px;
}
.date-field label {
font-size: 11.5px;
font-weight: 600;
color: var(--text-muted);
}
.date-field input {
padding: 8px 10px;
border: 1px solid var(--border);
border-radius: 8px;
font-size: 13px;
outline: none;
}
.date-field input:focus {
border-color: var(--primary);
}
/* Checkboxes */
.checkbox-group {
display: flex;
flex-direction: column;
gap: 12px;
}
.custom-checkbox {
display: flex;
align-items: flex-start;
gap: 12px;
cursor: pointer;
position: relative;
user-select: none;
}
.custom-checkbox input {
position: absolute;
opacity: 0;
cursor: pointer;
}
.checkmark {
width: 18px;
height: 18px;
border: 2px solid #CBD5E1;
border-radius: 5px;
margin-top: 2px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
}
.custom-checkbox input:checked ~ .checkmark {
background-color: var(--primary);
border-color: var(--primary);
}
.custom-checkbox input:checked ~ .checkmark::after {
content: "";
width: 4px;
height: 8px;
border: solid white;
border-width: 0 2px 2px 0;
transform: rotate(45deg);
margin-bottom: 2px;
}
.checkbox-label {
display: flex;
flex-direction: column;
}
.checkbox-label strong {
font-size: 13.5px;
font-weight: 600;
color: var(--text-main);
}
.checkbox-label small {
font-size: 12px;
color: var(--text-muted);
}
/* Action Box */
.hero-action-card {
padding: 24px;
}
.action-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 18px;
}
.action-header h3 {
font-size: 16px;
font-weight: 800;
}
.speed-setting {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: var(--text-muted);
}
.speed-setting select {
padding: 4px 8px;
border-radius: 6px;
border: 1px solid var(--border);
font-size: 12px;
outline: none;
}
.primary-controls {
display: flex;
flex-direction: column;
gap: 10px;
margin-bottom: 22px;
}
.primary-action-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
width: 100%;
padding: 14px 20px;
background: var(--primary-gradient);
color: #FFFFFF;
border: none;
border-radius: 10px;
font-size: 15px;
font-weight: 700;
cursor: pointer;
box-shadow: 0 6px 18px rgba(255, 107, 53, 0.35);
transition: all 0.2s ease;
}
.primary-action-btn:hover {
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(255, 107, 53, 0.45);
}
.primary-action-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
.secondary-controls-row {
display: flex;
gap: 10px;
}
.secondary-btn, .danger-btn {
flex: 1;
padding: 8px 14px;
border-radius: 8px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
border: 1px solid var(--border);
background: #FFFFFF;
transition: all 0.2s ease;
}
.secondary-btn:hover {
background: #F1F5F9;
}
.danger-btn {
color: var(--danger);
border-color: #FECACA;
}
.danger-btn:hover {
background: #FEF2F2;
}
/* Progress Bar */
.progress-section {
margin-bottom: 20px;
}
.progress-labels {
display: flex;
justify-content: space-between;
font-size: 12.5px;
font-weight: 600;
margin-bottom: 6px;
}
.progress-bar-track {
width: 100%;
height: 10px;
background: #E2E8F0;
border-radius: 10px;
overflow: hidden;
}
.progress-bar-fill {
height: 100%;
background: var(--primary-gradient);
border-radius: 10px;
transition: width 0.3s ease;
}
/* Metrics Grid */
.metrics-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
}
.metric-card {
background: #F8FAFC;
border: 1px solid var(--border);
border-radius: 10px;
padding: 12px 14px;
display: flex;
flex-direction: column;
}
.metric-value {
font-size: 17px;
font-weight: 800;
color: var(--text-main);
}
.metric-label {
font-size: 11.5px;
color: var(--text-muted);
}
/* Terminal Log */
.log-card {
display: flex;
flex-direction: column;
height: 320px;
}
.log-header h3 {
font-size: 14px;
font-weight: 700;
}
.link-btn {
background: none;
border: none;
color: var(--primary);
font-size: 12px;
cursor: pointer;
}
.terminal-log-body {
flex: 1;
background: #0F172A;
color: #E2E8F0;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;
padding: 14px;
overflow-y: auto;
line-height: 1.6;
}
.log-line.info { color: #94A3B8; }
.log-line.success { color: #34D399; }
.log-line.warn { color: #FBBF24; }
.log-line.error { color: #F87171; }
+294
View File
@@ -0,0 +1,294 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Brightwheel Exporter Dashboard</title>
<link rel="stylesheet" href="dashboard.css">
<script src="../lib/jszip.min.js"></script>
</head>
<body>
<div class="app-layout">
<!-- Navigation Bar -->
<header class="top-nav">
<div class="nav-brand">
<div class="brand-icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="7 10 12 15 17 10"></polyline>
<line x1="12" y1="15" x2="12" y2="3"></line>
</svg>
</div>
<div class="brand-text">
<h1>Brightwheel Exporter</h1>
<span>Memories & Reports Archiver</span>
</div>
</div>
<div class="session-badge" id="sessionBadge">
<span class="session-dot" id="sessionDot"></span>
<span id="sessionStatusText">Detecting Brightwheel Session...</span>
<button id="refreshSessionBtn" class="icon-btn" title="Refresh Session">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="23 4 23 10 17 10"></polyline>
<polyline points="1 20 1 14 7 14"></polyline>
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"></path>
</svg>
</button>
</div>
</header>
<!-- Main Content Grid -->
<main class="main-container">
<div class="dashboard-grid">
<!-- Left Panel: Configuration -->
<div class="config-column">
<!-- Card 1: Student Selection -->
<section class="card">
<div class="card-header">
<div class="card-title-group">
<span class="step-num">1</span>
<h2>Select Child / Student</h2>
</div>
</div>
<div class="card-content">
<div class="students-list" id="studentsList">
<div class="loading-placeholder">
<div class="spinner"></div>
<span>Scanning Brightwheel profile...</span>
</div>
</div>
<!-- Manual ID Fallback Accordion -->
<details class="manual-id-details" id="manualDetails">
<summary>Enter Student UUID Manually</summary>
<div class="manual-input-row">
<input type="text" id="manualStudentId" placeholder="e.g. edda433b-2a24-4da7-bbd5-9f131b4b2565" value="edda433b-2a24-4da7-bbd5-9f131b4b2565">
<button id="setManualIdBtn" class="secondary-btn">Use ID</button>
</div>
</details>
</div>
</section>
<!-- Card 2: Date Range -->
<section class="card">
<div class="card-header">
<div class="card-title-group">
<span class="step-num">2</span>
<h2>Date Range Filter</h2>
</div>
</div>
<div class="card-content">
<div class="quick-dates-pills" id="quickDatePills">
<button class="pill-btn active" data-range="all">All Time</button>
<button class="pill-btn" data-range="this-year">This Year</button>
<button class="pill-btn" data-range="last-year">Last Year</button>
<button class="pill-btn" data-range="last-90">Last 90 Days</button>
<button class="pill-btn" data-range="custom">Custom</button>
</div>
<div class="date-inputs-row" id="customDateRow">
<div class="date-field">
<label for="startDate">From</label>
<input type="date" id="startDate">
</div>
<div class="date-field">
<label for="endDate">To</label>
<input type="date" id="endDate">
</div>
</div>
</div>
</section>
<!-- Card 3: Export Categories -->
<section class="card">
<div class="card-header">
<div class="card-title-group">
<span class="step-num">3</span>
<h2>Export Categories</h2>
</div>
</div>
<div class="card-content">
<div class="checkbox-group">
<label class="custom-checkbox">
<input type="checkbox" id="catPhotos" checked>
<span class="checkmark"></span>
<div class="checkbox-label">
<strong>Photos & Videos</strong>
<small>High resolution original media files & captions</small>
</div>
</label>
<label class="custom-checkbox">
<input type="checkbox" id="catCare" checked>
<span class="checkmark"></span>
<div class="checkbox-label">
<strong>Daily Care Logs</strong>
<small>Meals, nap duration, potty checks, medications</small>
</div>
</label>
<label class="custom-checkbox">
<input type="checkbox" id="catMilestones" checked>
<span class="checkmark"></span>
<div class="checkbox-label">
<strong>Learning Milestones & Observations</strong>
<small>Curriculum tags, developmental notes, kudos</small>
</div>
</label>
<label class="custom-checkbox">
<input type="checkbox" id="catAttendance" checked>
<span class="checkmark"></span>
<div class="checkbox-label">
<strong>Attendance & Health Screening</strong>
<small>Check-in/out timestamps, temperature readings</small>
</div>
</label>
<label class="custom-checkbox">
<input type="checkbox" id="catMessages" checked>
<span class="checkmark"></span>
<div class="checkbox-label">
<strong>Staff Messages & Announcements</strong>
<small>Communication history between school & family</small>
</div>
</label>
</div>
</div>
</section>
<!-- Card 4: Packaging Formats -->
<section class="card">
<div class="card-header">
<div class="card-title-group">
<span class="step-num">4</span>
<h2>Archive Formats</h2>
</div>
</div>
<div class="card-content">
<div class="checkbox-group">
<label class="custom-checkbox">
<input type="checkbox" id="fmtZip" checked>
<span class="checkmark"></span>
<div class="checkbox-label">
<strong>Folder-Organized ZIP Archive</strong>
<small>Structured by <code>/YYYY/MM/photos/</code> and <code>/videos/</code></small>
</div>
</label>
<label class="custom-checkbox">
<input type="checkbox" id="fmtViewer" checked>
<span class="checkmark"></span>
<div class="checkbox-label">
<strong>Offline Interactive Timeline Viewer</strong>
<small>Standalone <code>index.html</code> bundled inside the archive</small>
</div>
</label>
<label class="custom-checkbox">
<input type="checkbox" id="fmtJson" checked>
<span class="checkmark"></span>
<div class="checkbox-label">
<strong>Raw JSON Manifest (<code>manifest.json</code>)</strong>
<small>Complete structured metadata for data portability</small>
</div>
</label>
<label class="custom-checkbox">
<input type="checkbox" id="fmtCsv" checked>
<span class="checkmark"></span>
<div class="checkbox-label">
<strong>Spreadsheet CSV (<code>activities.csv</code>)</strong>
<small>Open in Excel, Numbers, or Google Sheets</small>
</div>
</label>
</div>
</div>
</section>
</div>
<!-- Right Panel: Export Controller & Live Monitor -->
<div class="action-column">
<!-- Primary Action Box -->
<section class="card hero-action-card">
<div class="action-header">
<h3>Export Controller</h3>
<div class="speed-setting">
<label for="concurrencySelect">Pacing / Delay:</label>
<select id="concurrencySelect">
<option value="400">Gentle (400ms delay - Safest)</option>
<option value="200" selected>Standard (200ms delay)</option>
<option value="50">Fast (50ms delay)</option>
</select>
</div>
</div>
<div class="primary-controls">
<button id="startExportBtn" class="primary-action-btn">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="7 10 12 15 17 10"></polyline>
<line x1="12" y1="15" x2="12" y2="3"></line>
</svg>
<span>Start Complete Export</span>
</button>
<div class="secondary-controls-row" id="inProgressControls" style="display: none;">
<button id="pauseExportBtn" class="secondary-btn">Pause</button>
<button id="cancelExportBtn" class="danger-btn">Cancel</button>
</div>
</div>
<!-- Progress Bar Section -->
<div class="progress-section" id="progressSection">
<div class="progress-labels">
<span class="progress-status" id="progressStatusText">Ready to start</span>
<span class="progress-percentage" id="progressPercent">0%</span>
</div>
<div class="progress-bar-track">
<div class="progress-bar-fill" id="progressBarFill" style="width: 0%;"></div>
</div>
</div>
<!-- Live Metrics Grid -->
<div class="metrics-grid">
<div class="metric-card">
<span class="metric-value" id="metricActivitiesCount">0</span>
<span class="metric-label">Activities Found</span>
</div>
<div class="metric-card">
<span class="metric-value" id="metricMediaCount">0 / 0</span>
<span class="metric-label">Media Files</span>
</div>
<div class="metric-card">
<span class="metric-value" id="metricSize">0.0 MB</span>
<span class="metric-label">Downloaded Data</span>
</div>
<div class="metric-card">
<span class="metric-value" id="metricRateLimit">0</span>
<span class="metric-label">Retries / Backoffs</span>
</div>
</div>
</section>
<!-- Live Activity Log Card -->
<section class="card log-card">
<div class="card-header log-header">
<h3>Live Activity Log</h3>
<div class="log-actions">
<button id="clearLogBtn" class="link-btn">Clear</button>
</div>
</div>
<div class="terminal-log-body" id="terminalLog">
<div class="log-line info">[System] Exporter dashboard initialized. Waiting to begin...</div>
</div>
</section>
</div>
</div>
</main>
</div>
<script src="dashboard.js"></script>
</body>
</html>
+804
View File
@@ -0,0 +1,804 @@
/**
* Brightwheel Exporter - Dashboard Controller
*/
// Application State
const state = {
students: [],
selectedStudentId: 'edda433b-2a24-4da7-bbd5-9f131b4b2565',
selectedStudentData: null,
isExporting: false,
isPaused: false,
isCancelled: false,
activities: [],
mediaQueue: [],
stats: {
activitiesCount: 0,
mediaTotal: 0,
mediaDownloaded: 0,
mediaFailed: 0,
bytesDownloaded: 0,
retryCount: 0
}
};
// DOM Elements
let el = {};
document.addEventListener('DOMContentLoaded', async () => {
initDOMElements();
setupEventListeners();
initDatePresets();
await checkSessionAndDiscoverStudents();
});
function initDOMElements() {
el = {
sessionDot: document.getElementById('sessionDot'),
sessionStatusText: document.getElementById('sessionStatusText'),
refreshSessionBtn: document.getElementById('refreshSessionBtn'),
studentsList: document.getElementById('studentsList'),
manualDetails: document.getElementById('manualDetails'),
manualStudentId: document.getElementById('manualStudentId'),
setManualIdBtn: document.getElementById('setManualIdBtn'),
quickDatePills: document.getElementById('quickDatePills'),
customDateRow: document.getElementById('customDateRow'),
startDate: document.getElementById('startDate'),
endDate: document.getElementById('endDate'),
catPhotos: document.getElementById('catPhotos'),
catCare: document.getElementById('catCare'),
catMilestones: document.getElementById('catMilestones'),
catAttendance: document.getElementById('catAttendance'),
catMessages: document.getElementById('catMessages'),
fmtZip: document.getElementById('fmtZip'),
fmtViewer: document.getElementById('fmtViewer'),
fmtJson: document.getElementById('fmtJson'),
fmtCsv: document.getElementById('fmtCsv'),
concurrencySelect: document.getElementById('concurrencySelect'),
startExportBtn: document.getElementById('startExportBtn'),
inProgressControls: document.getElementById('inProgressControls'),
pauseExportBtn: document.getElementById('pauseExportBtn'),
cancelExportBtn: document.getElementById('cancelExportBtn'),
progressStatusText: document.getElementById('progressStatusText'),
progressPercent: document.getElementById('progressPercent'),
progressBarFill: document.getElementById('progressBarFill'),
metricActivitiesCount: document.getElementById('metricActivitiesCount'),
metricMediaCount: document.getElementById('metricMediaCount'),
metricSize: document.getElementById('metricSize'),
metricRateLimit: document.getElementById('metricRateLimit'),
terminalLog: document.getElementById('terminalLog'),
clearLogBtn: document.getElementById('clearLogBtn')
};
}
function log(message, type = 'info') {
const line = document.createElement('div');
line.className = `log-line ${type}`;
const timestamp = new Date().toLocaleTimeString();
line.textContent = `[${timestamp}] ${message}`;
el.terminalLog.appendChild(line);
el.terminalLog.scrollTop = el.terminalLog.scrollHeight;
}
// -------------------------------------------------------------
// Session & Student Discovery
// -------------------------------------------------------------
async function checkSessionAndDiscoverStudents() {
log('Checking active Brightwheel browser session...', 'info');
// Check stored active student ID first
const stored = await chrome.storage.local.get(['lastActiveStudentId']);
if (stored.lastActiveStudentId) {
state.selectedStudentId = stored.lastActiveStudentId;
el.manualStudentId.value = stored.lastActiveStudentId;
}
try {
// 1. Try to fetch user/guardian profile to list students
const userRes = await fetchBrightwheelAPI('https://schools.mybrightwheel.com/api/v1/users/me');
if (userRes && userRes.success && userRes.data) {
el.sessionDot.classList.add('online');
el.sessionStatusText.textContent = 'Brightwheel Session Active';
log('Successfully connected to Brightwheel user profile.', 'success');
const userData = userRes.data;
const studentsFound = userData.students || userData.children || (userData.guardians && userData.guardians[0]?.students) || [];
if (studentsFound.length > 0) {
state.students = studentsFound;
renderStudentsList();
return;
}
}
// 2. Try fetching specific student profile if selected
if (state.selectedStudentId) {
const studentRes = await fetchBrightwheelAPI(`https://schools.mybrightwheel.com/api/v1/students/${state.selectedStudentId}`);
if (studentRes && studentRes.success && studentRes.data) {
el.sessionDot.classList.add('online');
el.sessionStatusText.textContent = 'Brightwheel Session Active';
const s = studentRes.data.student || studentRes.data;
state.students = [s];
state.selectedStudentData = s;
renderStudentsList();
log(`Loaded student profile: ${s.first_name || 'Student'} (${state.selectedStudentId})`, 'success');
return;
}
}
// Fallback: Default student card with manual ID
el.sessionDot.classList.add('online');
el.sessionStatusText.textContent = 'Session Connected';
state.students = [{
id: state.selectedStudentId,
first_name: 'Student',
last_name: `(${state.selectedStudentId.substring(0, 8)}...)`,
school_name: 'Brightwheel School'
}];
renderStudentsList();
log(`Using student UUID: ${state.selectedStudentId}`, 'info');
} catch (err) {
log(`Session check note: ${err.message}. Ready with manual ID.`, 'warn');
renderStudentsList();
}
}
async function fetchBrightwheelAPI(url) {
try {
const res = await fetch(url, {
method: 'GET',
headers: {
'Accept': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
},
credentials: 'include'
});
if (!res.ok) {
return { success: false, status: res.status, statusText: res.statusText };
}
const json = await res.json();
return { success: true, status: res.status, data: json };
} catch (err) {
return { success: false, error: err.message };
}
}
function renderStudentsList() {
el.studentsList.innerHTML = '';
if (state.students.length === 0) {
el.studentsList.innerHTML = '<div class="loading-placeholder">No students auto-detected. Use manual UUID below.</div>';
return;
}
state.students.forEach((student) => {
const sId = student.id || student.object_id || state.selectedStudentId;
const isSelected = sId === state.selectedStudentId;
const firstName = student.first_name || 'My';
const lastName = student.last_name || 'Child';
const initials = (firstName[0] || 'B') + (lastName[0] || 'W');
const school = student.school_name || student.room_name || 'Brightwheel Account';
const card = document.createElement('div');
card.className = `student-card ${isSelected ? 'selected' : ''}`;
card.dataset.id = sId;
let avatarContent = initials;
if (student.picture_url || student.avatar_url) {
avatarContent = `<img src="${student.picture_url || student.avatar_url}" alt="${firstName}">`;
}
card.innerHTML = `
<div class="student-avatar">${avatarContent}</div>
<div class="student-info">
<h4>${firstName} ${lastName}</h4>
<p>${school} • <code>${sId.substring(0, 8)}...</code></p>
</div>
`;
card.addEventListener('click', () => {
document.querySelectorAll('.student-card').forEach(c => c.classList.remove('selected'));
card.classList.add('selected');
state.selectedStudentId = sId;
state.selectedStudentData = student;
el.manualStudentId.value = sId;
log(`Selected child: ${firstName} ${lastName} (${sId})`, 'info');
});
el.studentsList.appendChild(card);
});
}
// -------------------------------------------------------------
// Date Filters & Presets
// -------------------------------------------------------------
function initDatePresets() {
const today = new Date();
const currentYear = today.getFullYear();
el.startDate.value = '';
el.endDate.value = today.toISOString().split('T')[0];
}
function setDateRange(rangeType) {
const today = new Date();
const todayStr = today.toISOString().split('T')[0];
if (rangeType === 'all') {
el.startDate.value = '';
el.endDate.value = todayStr;
} else if (rangeType === 'this-year') {
el.startDate.value = `${today.getFullYear()}-01-01`;
el.endDate.value = todayStr;
} else if (rangeType === 'last-year') {
el.startDate.value = `${today.getFullYear() - 1}-01-01`;
el.endDate.value = `${today.getFullYear() - 1}-12-31`;
} else if (rangeType === 'last-90') {
const d = new Date();
d.setDate(d.getDate() - 90);
el.startDate.value = d.toISOString().split('T')[0];
el.endDate.value = todayStr;
}
}
// -------------------------------------------------------------
// Event Listeners
// -------------------------------------------------------------
function setupEventListeners() {
el.refreshSessionBtn.addEventListener('click', () => {
checkSessionAndDiscoverStudents();
});
el.setManualIdBtn.addEventListener('click', () => {
const customId = el.manualStudentId.value.trim();
if (!customId) return;
state.selectedStudentId = customId;
state.students = [{
id: customId,
first_name: 'Custom',
last_name: `(${customId.substring(0, 8)}...)`,
school_name: 'Brightwheel Profile'
}];
renderStudentsList();
chrome.storage.local.set({ lastActiveStudentId: customId });
log(`Updated active student ID: ${customId}`, 'info');
});
el.quickDatePills.addEventListener('click', (e) => {
const btn = e.target.closest('.pill-btn');
if (!btn) return;
document.querySelectorAll('.pill-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
setDateRange(btn.dataset.range);
});
el.clearLogBtn.addEventListener('click', () => {
el.terminalLog.innerHTML = '<div class="log-line info">[System] Log cleared.</div>';
});
el.startExportBtn.addEventListener('click', () => {
if (!state.isExporting) {
startExportProcess();
}
});
el.pauseExportBtn.addEventListener('click', () => {
state.isPaused = !state.isPaused;
el.pauseExportBtn.textContent = state.isPaused ? 'Resume' : 'Pause';
log(state.isPaused ? 'Export process PAUSED.' : 'Export process RESUMED.', 'warn');
});
el.cancelExportBtn.addEventListener('click', () => {
state.isCancelled = true;
log('Cancelling export process...', 'error');
});
}
// -------------------------------------------------------------
// Main Export Process Engine
// -------------------------------------------------------------
async function startExportProcess() {
const studentId = state.selectedStudentId;
if (!studentId) {
alert('Please select or enter a Student UUID first.');
return;
}
// Reset State
state.isExporting = true;
state.isPaused = false;
state.isCancelled = false;
state.activities = [];
state.mediaQueue = [];
state.stats = {
activitiesCount: 0,
mediaTotal: 0,
mediaDownloaded: 0,
mediaFailed: 0,
bytesDownloaded: 0,
retryCount: 0
};
updateUIForExportStart();
log(`Starting export for Student ID: ${studentId}`, 'info');
try {
// 1. Fetch Student Profile details
let studentProfile = state.selectedStudentData;
if (!studentProfile) {
log('Fetching student profile details...', 'info');
const pRes = await fetchBrightwheelAPI(`https://schools.mybrightwheel.com/api/v1/students/${studentId}`);
if (pRes && pRes.success && pRes.data) {
studentProfile = pRes.data.student || pRes.data;
} else {
studentProfile = { id: studentId, first_name: 'Student', last_name: 'Brightwheel' };
}
}
// 2. Fetch Activities Feed with pagination
await fetchAllActivities(studentId);
if (state.isCancelled) {
log('Export cancelled by user.', 'error');
finalizeExportUI(false);
return;
}
log(`Total matching activities retrieved: ${state.activities.length}`, 'success');
// 3. Extract media downloads queue if selected
if (el.catPhotos.checked) {
extractMediaQueue();
log(`Found ${state.mediaQueue.length} media files to download.`, 'info');
}
// 4. Download media files with concurrency and pacing
if (state.mediaQueue.length > 0) {
await downloadAllMediaFiles();
}
if (state.isCancelled) {
log('Export cancelled during media download.', 'error');
finalizeExportUI(false);
return;
}
// 5. Build ZIP & Bundled Archives
await buildAndDownloadZip(studentProfile);
log('🎉 Export completed successfully! Enjoy your memories.', 'success');
updateProgress(100, 'Export Complete!');
finalizeExportUI(true);
} catch (err) {
log(`Fatal Export Error: ${err.message}`, 'error');
console.error(err);
finalizeExportUI(false);
}
}
function updateUIForExportStart() {
el.startExportBtn.disabled = true;
el.startExportBtn.style.display = 'none';
el.inProgressControls.style.display = 'flex';
el.pauseExportBtn.textContent = 'Pause';
updateProgress(0, 'Initializing export...');
updateMetrics();
}
function finalizeExportUI(success) {
state.isExporting = false;
el.startExportBtn.disabled = false;
el.startExportBtn.style.display = 'flex';
el.inProgressControls.style.display = 'none';
if (!success) {
el.progressStatusText.textContent = 'Export Stopped';
}
}
function updateProgress(percent, statusText) {
el.progressPercent.textContent = `${Math.round(percent)}%`;
el.progressBarFill.style.width = `${percent}%`;
if (statusText) el.progressStatusText.textContent = statusText;
}
function updateMetrics() {
el.metricActivitiesCount.textContent = state.stats.activitiesCount;
el.metricMediaCount.textContent = `${state.stats.mediaDownloaded} / ${state.stats.mediaTotal}`;
const mb = (state.stats.bytesDownloaded / (1024 * 1024)).toFixed(1);
el.metricSize.textContent = `${mb} MB`;
el.metricRateLimit.textContent = state.stats.retryCount;
}
// -------------------------------------------------------------
// Activity Feed Harvester with Pagination & Backoff
// -------------------------------------------------------------
async function fetchAllActivities(studentId) {
const pageSize = 50;
let page = 1;
let hasMore = true;
const startDate = el.startDate.value;
const endDate = el.endDate.value;
const pacingDelay = parseInt(el.concurrencySelect.value) || 200;
updateProgress(5, 'Harvesting activity logs...');
while (hasMore && !state.isCancelled) {
while (state.isPaused && !state.isCancelled) {
await sleep(500);
}
if (state.isCancelled) break;
// Build URL query
let url = `https://schools.mybrightwheel.com/api/v1/students/${studentId}/activities?page_size=${pageSize}&page=${page}`;
if (startDate) url += `&start_date=${startDate}`;
if (endDate) url += `&end_date=${endDate}`;
log(`Fetching activities page ${page}...`, 'info');
let response = null;
let retries = 0;
const maxRetries = 5;
while (retries < maxRetries) {
response = await fetchBrightwheelAPI(url);
if (response && response.status === 429) {
state.stats.retryCount++;
updateMetrics();
const backoffTime = Math.pow(2, retries) * 1000;
log(`Rate limited (429). Backing off for ${backoffTime / 1000}s...`, 'warn');
await sleep(backoffTime);
retries++;
} else {
break;
}
}
if (!response || !response.success || !response.data) {
// If page 1 failed or reached end
if (page === 1) {
// Try fallback endpoint /api/v1/activities?student_ids[]
log('Trying alternate activity feed endpoint...', 'info');
const altUrl = `https://schools.mybrightwheel.com/api/v1/activities?student_ids[]=${studentId}&page_size=${pageSize}&page=${page}`;
const altRes = await fetchBrightwheelAPI(altUrl);
if (altRes && altRes.success && altRes.data) {
response = altRes;
}
}
}
const rawActivities = response?.data?.activities || response?.data?.items || response?.data || [];
if (!Array.isArray(rawActivities) || rawActivities.length === 0) {
log(`No more activities found after page ${page - 1}. Done harvesting.`, 'info');
hasMore = false;
break;
}
// Filter and normalize activities
for (const act of rawActivities) {
if (matchesSelectedCategories(act)) {
state.activities.push(normalizeActivity(act));
}
}
state.stats.activitiesCount = state.activities.length;
updateMetrics();
updateProgress(Math.min(30, 5 + page * 2), `Harvested ${state.activities.length} activities (Page ${page})`);
if (rawActivities.length < pageSize) {
hasMore = false;
break;
}
page++;
await sleep(pacingDelay);
}
}
function matchesSelectedCategories(act) {
const type = (act.activity_type || act.action_type || act.type || '').toLowerCase();
if (act.media || act.image_url || act.video_url || type.includes('photo') || type.includes('video')) {
if (el.catPhotos.checked) return true;
}
if (type.includes('food') || type.includes('meal') || type.includes('nap') || type.includes('potty') || type.includes('diaper') || type.includes('med')) {
if (el.catCare.checked) return true;
}
if (type.includes('milestone') || type.includes('learn') || type.includes('observation') || type.includes('kudos')) {
if (el.catMilestones.checked) return true;
}
if (type.includes('checkin') || type.includes('checkout') || type.includes('attendance') || type.includes('health') || type.includes('incident')) {
if (el.catAttendance.checked) return true;
}
if (type.includes('note') || type.includes('message') || type.includes('announcement')) {
if (el.catMessages.checked) return true;
}
return true;
}
function normalizeActivity(act) {
const mediaObj = act.media || {};
const highResImage = mediaObj.image_url || act.image_url || mediaObj.url || act.url;
const highResVideo = mediaObj.video_url || act.video_url;
return {
id: act.id || act.object_id || Math.random().toString(36).substring(2),
created_at: act.created_at || act.event_date || new Date().toISOString(),
activity_type: act.activity_type || act.action_type || act.type || 'activity',
title: act.title || '',
note: act.note || act.description || act.body || '',
teacher_name: act.actor_name || act.created_by?.name || act.teacher_name || '',
image_url: highResImage || null,
video_url: highResVideo || null,
thumb_url: mediaObj.thumb_url || act.thumb_url || null,
food_type: act.food_type || act.meal_type || null,
amount_eaten: act.amount_eaten || act.quantity || null,
nap_start: act.start_time || act.nap_start || null,
nap_end: act.end_time || act.nap_end || null,
duration_minutes: act.duration_minutes || null,
potty_type: act.potty_type || act.diaper_type || null,
milestone_name: act.milestone_name || act.tag_name || null,
media_local_path: null // Populated during ZIP bundling
};
}
// -------------------------------------------------------------
// Media Downloader with Concurrency & Progress
// -------------------------------------------------------------
function extractMediaQueue() {
state.mediaQueue = [];
state.activities.forEach((act) => {
const url = act.video_url || act.image_url;
if (url) {
const isVideo = !!act.video_url || url.includes('.mp4') || act.activity_type === 'video';
const ext = isVideo ? 'mp4' : 'jpg';
const datePrefix = (act.created_at || '').substring(0, 10) || 'unknown_date';
const filename = `${datePrefix}_${act.id.substring(0, 12)}.${ext}`;
const folder = isVideo ? 'videos' : 'photos';
const yearMonth = datePrefix.substring(0, 7) || 'all';
const localZipPath = `${folder}/${yearMonth}/${filename}`;
act.media_local_path = localZipPath;
state.mediaQueue.push({
activityId: act.id,
url: url,
filename: filename,
localZipPath: localZipPath,
blob: null,
status: 'pending'
});
}
});
state.stats.mediaTotal = state.mediaQueue.length;
updateMetrics();
}
async function downloadAllMediaFiles() {
const queue = state.mediaQueue;
const concurrency = 3;
let activeIndex = 0;
updateProgress(35, `Downloading 0 / ${queue.length} media files...`);
async function worker() {
while (activeIndex < queue.length && !state.isCancelled) {
while (state.isPaused && !state.isCancelled) {
await sleep(500);
}
if (state.isCancelled) break;
const itemIndex = activeIndex++;
const item = queue[itemIndex];
if (!item) break;
try {
const blob = await fetchMediaBlob(item.url);
item.blob = blob;
item.status = 'done';
state.stats.mediaDownloaded++;
state.stats.bytesDownloaded += blob.size;
} catch (err) {
item.status = 'failed';
state.stats.mediaFailed++;
log(`Failed to download media ${item.filename}: ${err.message}`, 'warn');
}
updateMetrics();
const progressPercent = 35 + (state.stats.mediaDownloaded / queue.length) * 50;
updateProgress(progressPercent, `Downloading media: ${state.stats.mediaDownloaded} / ${queue.length}`);
}
}
const workers = [];
for (let i = 0; i < Math.min(concurrency, queue.length); i++) {
workers.push(worker());
}
await Promise.all(workers);
}
async function fetchMediaBlob(url) {
let retries = 0;
while (retries < 3) {
try {
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return await res.blob();
} catch (err) {
retries++;
if (retries >= 3) throw err;
await sleep(1000 * retries);
}
}
}
// -------------------------------------------------------------
// ZIP Archiving & Packaging
// -------------------------------------------------------------
async function buildAndDownloadZip(studentProfile) {
if (typeof JSZip === 'undefined') {
throw new Error('JSZip library is not loaded.');
}
updateProgress(88, 'Generating ZIP package...');
log('Packaging media and manifests into ZIP archive...', 'info');
const zip = new JSZip();
const childName = sanitizeFilename(`${studentProfile.first_name || 'Child'}_${studentProfile.last_name || ''}`).trim() || 'Brightwheel_Archive';
const rootFolder = zip.folder(childName);
// 1. Add Media Files
for (const item of state.mediaQueue) {
if (item.blob && item.status === 'done') {
rootFolder.file(item.localZipPath, item.blob);
}
}
// 2. Add JSON Manifest
if (el.fmtJson.checked) {
const manifestData = {
export_date: new Date().toISOString(),
student: studentProfile,
total_activities: state.activities.length,
activities: state.activities
};
rootFolder.file('manifest.json', JSON.stringify(manifestData, null, 2));
}
// 3. Add CSV Spreadsheet
if (el.fmtCsv.checked) {
const csvContent = generateCSV(state.activities);
rootFolder.file('activities.csv', csvContent);
}
// 4. Add Standalone Interactive Offline Viewer
if (el.fmtViewer.checked) {
try {
const templateRes = await fetch(chrome.runtime.getURL('dashboard/viewer_template.html'));
if (templateRes.ok) {
let viewerHtml = await templateRes.text();
// Embed manifest data into index.html for instant zero-server loading
const embeddedManifest = JSON.stringify({
export_date: new Date().toISOString(),
student: studentProfile,
activities: state.activities
});
viewerHtml = viewerHtml.replace(
'let ARCHIVE_DATA = window.__BRIGHTWHEEL_ARCHIVE__ || null;',
`let ARCHIVE_DATA = ${embeddedManifest};`
);
rootFolder.file('index.html', viewerHtml);
}
} catch (e) {
log('Note: could not embed standalone index.html viewer: ' + e.message, 'warn');
}
}
// 5. Generate ZIP Blob
updateProgress(94, 'Compressing archive...');
const zipBlob = await zip.generateAsync({
type: 'blob',
compression: 'DEFLATE',
compressionOptions: { level: 6 }
}, (metadata) => {
updateProgress(88 + (metadata.percent * 0.08), `Compressing ZIP: ${Math.round(metadata.percent)}%`);
});
const zipFilename = `${childName}_Brightwheel_Archive_${new Date().toISOString().split('T')[0]}.zip`;
// 6. Trigger Download
const blobUrl = URL.createObjectURL(zipBlob);
if (chrome.downloads) {
chrome.runtime.sendMessage({
action: 'DOWNLOAD_BLOB_URL',
url: blobUrl,
filename: zipFilename
}, (res) => {
if (!res || !res.success) {
triggerBrowserDownload(blobUrl, zipFilename);
}
});
} else {
triggerBrowserDownload(blobUrl, zipFilename);
}
log(`Archive created: ${zipFilename} (${(zipBlob.size / (1024 * 1024)).toFixed(2)} MB)`, 'success');
}
function triggerBrowserDownload(url, filename) {
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}
function generateCSV(activities) {
const headers = [
'Activity ID',
'Date & Time',
'Type',
'Title',
'Note / Caption',
'Logged By',
'Media File Path',
'Food / Meal',
'Amount Eaten',
'Nap Start',
'Nap End',
'Nap Mins',
'Potty Type',
'Milestone'
];
const rows = activities.map(a => [
escapeCSV(a.id),
escapeCSV(a.created_at),
escapeCSV(a.activity_type),
escapeCSV(a.title),
escapeCSV(a.note),
escapeCSV(a.teacher_name),
escapeCSV(a.media_local_path || a.video_url || a.image_url || ''),
escapeCSV(a.food_type || ''),
escapeCSV(a.amount_eaten || ''),
escapeCSV(a.nap_start || ''),
escapeCSV(a.nap_end || ''),
escapeCSV(a.duration_minutes || ''),
escapeCSV(a.potty_type || ''),
escapeCSV(a.milestone_name || '')
]);
return [headers.join(','), ...rows.map(r => r.join(','))].join('\r\n');
}
function escapeCSV(val) {
if (val === null || val === undefined) return '""';
const str = String(val).replace(/"/g, '""');
return `"${str}"`;
}
function sanitizeFilename(str) {
return str.replace(/[^a-zA-Z0-9_-]/g, '_');
}
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
+427
View File
@@ -0,0 +1,427 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Brightwheel Memories Archive</title>
<style>
:root {
--primary: #FF6B35;
--primary-gradient: linear-gradient(135deg, #FF6B35 0%, #FF8E53 100%);
--bg: #F8FAFC;
--card-bg: #FFFFFF;
--text: #0F172A;
--text-muted: #64748B;
--border: #E2E8F0;
--radius: 14px;
--shadow: 0 4px 20px -2px rgba(0, 0, 0, 0.06);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.5;
padding-bottom: 60px;
}
header {
background: #FFFFFF;
border-bottom: 1px solid var(--border);
position: sticky;
top: 0;
z-index: 100;
backdrop-filter: blur(10px);
}
.header-inner {
max-width: 1200px;
margin: 0 auto;
padding: 16px 24px;
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 16px;
}
.hero-profile {
display: flex;
align-items: center;
gap: 16px;
}
.avatar {
width: 52px;
height: 52px;
border-radius: 50%;
background: var(--primary-gradient);
color: white;
font-weight: 700;
font-size: 20px;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 12px rgba(255, 107, 53, 0.3);
overflow: hidden;
}
.avatar img { width: 100%; height: 100%; object-fit: cover; }
.hero-text h1 { font-size: 20px; font-weight: 800; }
.hero-text p { font-size: 13px; color: var(--text-muted); }
.stats-badge {
display: flex;
gap: 12px;
}
.stat-pill {
background: #F1F5F9;
padding: 6px 14px;
border-radius: 20px;
font-size: 12px;
font-weight: 600;
color: #334155;
}
.container {
max-width: 1200px;
margin: 24px auto;
padding: 0 24px;
}
.controls-bar {
background: #FFFFFF;
padding: 16px 20px;
border-radius: var(--radius);
border: 1px solid var(--border);
box-shadow: var(--shadow);
display: flex;
flex-wrap: wrap;
gap: 14px;
align-items: center;
margin-bottom: 24px;
}
.search-input {
flex: 1;
min-width: 220px;
padding: 10px 16px;
border: 1px solid var(--border);
border-radius: 10px;
font-size: 14px;
outline: none;
}
.search-input:focus { border-color: var(--primary); }
.filter-tabs {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.filter-btn {
padding: 8px 14px;
border-radius: 8px;
border: 1px solid var(--border);
background: #FFFFFF;
font-size: 13px;
font-weight: 600;
cursor: pointer;
color: var(--text-muted);
transition: all 0.2s ease;
}
.filter-btn.active {
background: var(--primary);
color: #FFFFFF;
border-color: var(--primary);
}
.feed-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 20px;
}
.feed-card {
background: var(--card-bg);
border-radius: var(--radius);
border: 1px solid var(--border);
box-shadow: var(--shadow);
overflow: hidden;
display: flex;
flex-direction: column;
transition: transform 0.2s ease;
}
.feed-card:hover {
transform: translateY(-2px);
}
.card-media {
width: 100%;
height: 240px;
background: #0F172A;
position: relative;
cursor: pointer;
}
.card-media img, .card-media video {
width: 100%;
height: 100%;
object-fit: cover;
}
.card-body {
padding: 16px;
display: flex;
flex-direction: column;
gap: 10px;
flex: 1;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.type-badge {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
padding: 4px 8px;
border-radius: 6px;
}
.type-photo { background: #FEF3C7; color: #D97706; }
.type-video { background: #FCE7F3; color: #DB2777; }
.type-food { background: #DCFCE7; color: #15803D; }
.type-nap { background: #E0E7FF; color: #4338CA; }
.type-potty { background: #E0F2FE; color: #0284C7; }
.type-milestone { background: #F3E8FF; color: #7E22CE; }
.type-checkin { background: #F1F5F9; color: #475569; }
.type-note { background: #FFFBEB; color: #B45309; }
.card-time {
font-size: 12px;
color: var(--text-muted);
}
.card-title {
font-size: 15px;
font-weight: 700;
color: var(--text);
}
.card-note {
font-size: 13.5px;
color: #334155;
}
.card-details {
background: #F8FAFC;
border-radius: 8px;
padding: 8px 12px;
font-size: 12px;
color: #475569;
}
.card-footer {
margin-top: auto;
padding-top: 8px;
border-top: 1px dashed var(--border);
font-size: 11.5px;
color: var(--text-muted);
display: flex;
justify-content: space-between;
}
.empty-state {
text-align: center;
padding: 60px 20px;
color: var(--text-muted);
grid-column: 1 / -1;
}
</style>
</head>
<body>
<header>
<div class="header-inner">
<div class="hero-profile">
<div class="avatar" id="avatarBox">BW</div>
<div class="hero-text">
<h1 id="childNameTitle">Brightwheel Archive</h1>
<p id="archiveSubtitle">Offline Memory Collection</p>
</div>
</div>
<div class="stats-badge">
<div class="stat-pill" id="totalItemsPill">0 Activities</div>
<div class="stat-pill" id="totalPhotosPill">0 Media</div>
</div>
</div>
</header>
<main class="container">
<div class="controls-bar">
<input type="text" id="searchInput" class="search-input" placeholder="Search captions, notes, milestones...">
<div class="filter-tabs" id="filterTabs">
<button class="filter-btn active" data-filter="all">All</button>
<button class="filter-btn" data-filter="photo">Photos & Videos</button>
<button class="filter-btn" data-filter="food">Meals</button>
<button class="filter-btn" data-filter="nap">Naps</button>
<button class="filter-btn" data-filter="potty">Potty</button>
<button class="filter-btn" data-filter="milestone">Milestones</button>
<button class="filter-btn" data-filter="checkin">Attendance</button>
</div>
</div>
<div class="feed-grid" id="feedGrid">
<div class="empty-state">Loading archive data...</div>
</div>
</main>
<script>
// Embedded or fetched manifest data
let ARCHIVE_DATA = window.__BRIGHTWHEEL_ARCHIVE__ || null;
async function init() {
if (!ARCHIVE_DATA) {
try {
const res = await fetch('manifest.json');
if (res.ok) {
ARCHIVE_DATA = await res.json();
}
} catch (e) {
console.warn('Could not fetch manifest.json, checking global', e);
}
}
if (!ARCHIVE_DATA || !ARCHIVE_DATA.activities) {
document.getElementById('feedGrid').innerHTML = '<div class="empty-state">No activities found in manifest.json. Ensure manifest.json is in the same directory.</div>';
return;
}
renderProfile();
renderFeed('all', '');
setupEvents();
}
function renderProfile() {
const student = ARCHIVE_DATA.student || {};
const name = (student.first_name || '') + ' ' + (student.last_name || '');
if (name.trim()) {
document.getElementById('childNameTitle').textContent = name.trim();
document.title = name.trim() + ' - Brightwheel Memories Archive';
}
if (student.school_name) {
document.getElementById('archiveSubtitle').textContent = student.school_name;
}
if (student.avatar_local_path) {
document.getElementById('avatarBox').innerHTML = `<img src="${student.avatar_local_path}" alt="Avatar">`;
} else if (name.trim()) {
document.getElementById('avatarBox').textContent = (student.first_name?.[0] || 'B') + (student.last_name?.[0] || 'W');
}
const acts = ARCHIVE_DATA.activities || [];
const mediaCount = acts.filter(a => a.media_local_path || a.image_url || a.video_url).length;
document.getElementById('totalItemsPill').textContent = acts.length + ' Activities';
document.getElementById('totalPhotosPill').textContent = mediaCount + ' Media Files';
}
function getNormalizedType(act) {
const t = (act.activity_type || act.action_type || act.type || '').toLowerCase();
if (t.includes('photo') || t.includes('video') || t.includes('media') || act.media_local_path || act.image_url) return 'photo';
if (t.includes('food') || t.includes('meal') || t.includes('snack') || t.includes('lunch') || t.includes('breakfast')) return 'food';
if (t.includes('nap') || t.includes('sleep')) return 'nap';
if (t.includes('potty') || t.includes('diaper')) return 'potty';
if (t.includes('milestone') || t.includes('learn') || t.includes('observation') || t.includes('kudos')) return 'milestone';
if (t.includes('checkin') || t.includes('checkout') || t.includes('attendance') || t.includes('health') || t.includes('incident')) return 'checkin';
return 'note';
}
function renderFeed(filterType, searchQuery) {
const grid = document.getElementById('feedGrid');
const acts = ARCHIVE_DATA.activities || [];
const q = searchQuery.toLowerCase().trim();
const filtered = acts.filter(a => {
const type = getNormalizedType(a);
if (filterType !== 'all') {
if (filterType === 'photo' && type !== 'photo') return false;
if (filterType === 'food' && type !== 'food') return false;
if (filterType === 'nap' && type !== 'nap') return false;
if (filterType === 'potty' && type !== 'potty') return false;
if (filterType === 'milestone' && type !== 'milestone') return false;
if (filterType === 'checkin' && type !== 'checkin') return false;
}
if (q) {
const matchStr = (
(a.note || '') + ' ' +
(a.description || '') + ' ' +
(a.teacher_name || '') + ' ' +
(a.activity_type || '') + ' ' +
(a.title || '') + ' ' +
(a.created_at || '')
).toLowerCase();
if (!matchStr.includes(q)) return false;
}
return true;
});
if (filtered.length === 0) {
grid.innerHTML = '<div class="empty-state">No matching activities found for current filter.</div>';
return;
}
grid.innerHTML = filtered.map(act => {
const type = getNormalizedType(act);
const dateStr = act.created_at || act.event_date || '';
const formattedDate = dateStr ? new Date(dateStr).toLocaleString(undefined, {
weekday: 'short', month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit'
}) : '';
const mediaPath = act.media_local_path || act.video_url || act.image_url;
const isVideo = (act.video_url || mediaPath?.endsWith('.mp4') || act.activity_type === 'video');
let mediaHtml = '';
if (mediaPath) {
if (isVideo) {
mediaHtml = `<div class="card-media"><video src="${mediaPath}" controls preload="metadata"></video></div>`;
} else {
mediaHtml = `<div class="card-media"><img src="${mediaPath}" alt="Activity Media" loading="lazy"></div>`;
}
}
let detailLines = [];
if (act.food_type || act.amount_eaten) {
detailLines.push(`🍽️ <strong>${act.food_type || 'Meal'}</strong>: ${act.amount_eaten || 'Eaten'}`);
}
if (act.nap_start || act.nap_end || act.duration_minutes) {
detailLines.push(`😴 <strong>Nap Duration</strong>: ${act.duration_minutes ? act.duration_minutes + ' mins' : (act.nap_start || '') + ' - ' + (act.nap_end || '')}`);
}
if (act.potty_type) {
detailLines.push(`🚽 <strong>Potty</strong>: ${act.potty_type}`);
}
if (act.milestone_name) {
detailLines.push(`🌟 <strong>Milestone</strong>: ${act.milestone_name}`);
}
return `
<div class="feed-card">
${mediaHtml}
<div class="card-body">
<div class="card-header">
<span class="type-badge type-${type}">${act.activity_type || type}</span>
<span class="card-time">${formattedDate}</span>
</div>
${act.title ? `<div class="card-title">${act.title}</div>` : ''}
${act.note ? `<div class="card-note">${act.note}</div>` : ''}
${detailLines.length > 0 ? `<div class="card-details">${detailLines.join('<br>')}</div>` : ''}
<div class="card-footer">
<span>${act.teacher_name ? 'Logged by ' + act.teacher_name : 'Staff'}</span>
</div>
</div>
</div>
`;
}).join('');
}
function setupEvents() {
let currentFilter = 'all';
const searchInput = document.getElementById('searchInput');
document.getElementById('filterTabs').addEventListener('click', (e) => {
const btn = e.target.closest('.filter-btn');
if (!btn) return;
document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
currentFilter = btn.dataset.filter;
renderFeed(currentFilter, searchInput.value);
});
searchInput.addEventListener('input', () => {
renderFeed(currentFilter, searchInput.value);
});
}
window.addEventListener('DOMContentLoaded', init);
</script>
</body>
</html>