feat: initial release of Brightwheel Memories & Reports Exporter Chrome extension
This commit is contained in:
@@ -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; }
|
||||
@@ -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>
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user