Files
2026-08-29 22:39:58 -07:00

450 lines
15 KiB
HTML

<!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>
<footer class="archive-footer" style="text-align: center; padding: 32px 16px; color: var(--text-muted); font-size: 12px; border-top: 1px solid var(--border); margin-top: 48px;">
<p><strong>Brightwheel Memories Offline Archive</strong> &bull; Generated locally with Brightwheel Exporter</p>
<p style="margin-top: 4px; font-size: 11px; opacity: 0.8;">Disclaimer: This offline viewer is an independent backup tool and is not affiliated with, endorsed by, or sponsored by Brightwheel.</p>
</footer>
<script>
// Embedded or fetched manifest data
let ARCHIVE_DATA = window.__BRIGHTWHEEL_ARCHIVE__ || null;
async function init() {
if (!ARCHIVE_DATA) {
try {
const res = await fetch('activities.json');
if (res.ok) {
ARCHIVE_DATA = await res.json();
}
} catch (e) {
try {
const fallbackRes = await fetch('manifest.json');
if (fallbackRes.ok) ARCHIVE_DATA = await fallbackRes.json();
} catch (e2) {
console.warn('Could not fetch activities.json or manifest.json', e2);
}
}
}
if (!ARCHIVE_DATA || !ARCHIVE_DATA.activities) {
document.getElementById('feedGrid').innerHTML = '<div class="empty-state">No activities found in activities.json. Ensure activities.json is in the same directory.</div>';
return;
}
// Sort activities descending by date (newest first)
ARCHIVE_DATA.activities.sort((a, b) => {
const da = new Date(a.created_at || a.event_date || 0).getTime();
const db = new Date(b.created_at || b.event_date || 0).getTime();
return db - da;
});
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 === 'ac_video' || 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</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}`);
}
if (act.attendance_info) {
detailLines.push(`📋 <strong>Attendance</strong>: ${act.attendance_info}`);
}
const displayTitle = act.action_summary || act.title || act.activity_type;
return `
<div class="feed-card">
${mediaHtml}
<div class="card-body">
<div class="card-header">
<span class="type-badge type-${type}">${act.action_subtype || act.activity_type || type}</span>
<span class="card-time">${formattedDate}</span>
</div>
<div class="card-title">${displayTitle}</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>