Redesign admin dashboard with sidebar layout, planner overhaul, and readability fixes
Build and Push Docker Image / build-and-push (push) Successful in 41s
Build and Push Docker Image / build-and-push (push) Successful in 41s
- Replace horizontal top-nav with a dark sidebar (grouped: People / Academics / Tools) - Add Dashboard landing page with kid stat cards, today's schedule, and recent scores - Add /api/admin/schedule and /api/admin/recent-scores endpoints for dashboard data - Add Lock button and mobile sidebar toggle in top bar - Redesign planner toolbar: two-section layout (nav left, actions right) - Elevate auto-schedule out of hidden accordion into always-visible inline bar - Richer planner task cards: course color stripe, course name label, completion counter per day - Dashed "+ Add task" empty state instead of plain dash - Fix nav-pill CSS conflict: student tabs (light bg) now use dark text; admin sidebar keeps white text - Add seed.js demo data script for two students (Emma and Liam) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
ab32ba8634
commit
7775c4fc3a
5 files changed
+1423
-126
No files matched your search
+154
-22
@@ -82,22 +82,25 @@ async function unlock() {
|
||||
await loadAppSettings();
|
||||
$('#pin-gate').hidden = true;
|
||||
$('#console').hidden = false;
|
||||
showPanel('kids');
|
||||
showPanel('dashboard');
|
||||
if ('Notification' in window && Notification.permission === 'default') Notification.requestPermission();
|
||||
loadGradingBadge();
|
||||
loadMessagesBadge();
|
||||
startAdminMsgPoll();
|
||||
loadDashboard();
|
||||
}
|
||||
|
||||
async function loadGradingBadge() {
|
||||
try {
|
||||
const { count } = await api('/api/grading-queue/count');
|
||||
const pill = document.querySelector('.nav-pill[data-panel="grading"]');
|
||||
if (!pill) return;
|
||||
const lbl = pill.querySelector('.nav-label');
|
||||
if (count > 0) {
|
||||
pill.textContent = `✅ Grading (${count})`;
|
||||
if (lbl) lbl.textContent = `Grading (${count})`;
|
||||
pill.classList.add('grading-pending');
|
||||
} else {
|
||||
pill.textContent = '✅ Grading';
|
||||
if (lbl) lbl.textContent = 'Grading';
|
||||
pill.classList.remove('grading-pending');
|
||||
}
|
||||
} catch {}
|
||||
@@ -114,11 +117,20 @@ document.querySelectorAll('[data-nav]').forEach((btn) =>
|
||||
btn.addEventListener('click', () => showPanel(btn.dataset.nav))
|
||||
);
|
||||
|
||||
const PANEL_TITLES = {
|
||||
dashboard: 'Dashboard', kids: 'Kids', courses: 'Courses', planner: 'Planner',
|
||||
grading: 'Grading', messages: 'Messages', gradebook: 'Gradebook',
|
||||
spelling: 'Spelling', decks: 'Flashcards', settings: 'Settings',
|
||||
'course-detail': 'Course Detail', 'item-editor': 'Item Editor',
|
||||
};
|
||||
|
||||
function showPanel(name) {
|
||||
document.querySelectorAll('.panel').forEach((p) => (p.hidden = p.id !== `panel-${name}`));
|
||||
document.querySelectorAll('.admin-nav .nav-pill').forEach((p) => p.classList.toggle('active', p.dataset.panel === name));
|
||||
const titleEl = document.getElementById('admin-page-title');
|
||||
if (titleEl) titleEl.textContent = PANEL_TITLES[name] || name;
|
||||
const loaders = {
|
||||
kids: loadKids, courses: loadCourses, planner: loadPlannerPanel,
|
||||
dashboard: loadDashboard, kids: loadKids, courses: loadCourses, planner: loadPlannerPanel,
|
||||
grading: loadGrading, messages: loadMessages, gradebook: loadGradebookPanel, spelling: loadSpelling, decks: loadDecks,
|
||||
settings: loadSettings,
|
||||
};
|
||||
@@ -126,6 +138,99 @@ function showPanel(name) {
|
||||
if (name === 'course-detail') openCourseDetail(currentCourseId);
|
||||
}
|
||||
|
||||
// sidebar toggle + lock button
|
||||
document.getElementById('sidebar-toggle')?.addEventListener('click', () => {
|
||||
document.querySelector('.admin-sidebar')?.classList.toggle('open');
|
||||
});
|
||||
document.getElementById('btn-lock')?.addEventListener('click', () => {
|
||||
sessionStorage.removeItem('admin_pin');
|
||||
location.reload();
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Dashboard
|
||||
// ============================================================
|
||||
|
||||
async function loadDashboard() {
|
||||
const greetEl = document.getElementById('dashboard-greeting');
|
||||
const kidGrid = document.getElementById('dashboard-kid-cards');
|
||||
const schedEl = document.getElementById('dashboard-schedule');
|
||||
const scoresEl = document.getElementById('dashboard-scores');
|
||||
if (!greetEl) return;
|
||||
|
||||
const hr = new Date().getHours();
|
||||
const greet = hr < 12 ? 'Good morning' : hr < 17 ? 'Good afternoon' : 'Good evening';
|
||||
const dateStr = new Date().toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric' });
|
||||
greetEl.innerHTML = `<h1>${greet}! 👋</h1><p>${dateStr}</p>`;
|
||||
|
||||
try {
|
||||
const [students, scheduleData, recentScores] = await Promise.all([
|
||||
api('/api/students'),
|
||||
api(`/api/admin/schedule?date=${new Date().toISOString().slice(0,10)}`).catch(() => ({ rows: [] })),
|
||||
api('/api/admin/recent-scores').catch(() => ({ scores: [] })),
|
||||
]);
|
||||
|
||||
const THEME_HEX = { blue:'#4f86f7', green:'#2e9e5b', purple:'#7c5cbf', orange:'#e8802a', pink:'#d45d8a', red:'#e84040', teal:'#20a8a0', yellow:'#c8960c', indigo:'#5b6abf' };
|
||||
const rows = scheduleData.rows || scheduleData || [];
|
||||
|
||||
kidGrid.innerHTML = '';
|
||||
for (const s of students) {
|
||||
const kidRows = rows.filter(r => r.student_id === s.id);
|
||||
const total = kidRows.length;
|
||||
const done = kidRows.filter(r => r.done).length;
|
||||
const pct = total ? Math.round(done / total * 100) : 0;
|
||||
const accent = THEME_HEX[s.theme?.replace('theme-','')] || '#4f7ef8';
|
||||
kidGrid.innerHTML += `
|
||||
<div class="dash-kid-card" style="--kid-accent:${accent}">
|
||||
<div class="dash-kid-top">
|
||||
<span class="dash-kid-avatar">${s.emoji || '🧒'}</span>
|
||||
<div>
|
||||
<div class="dash-kid-name">${s.name}</div>
|
||||
<div class="dash-kid-streak">🔥 ${s.streak_count || 0}-day streak</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dash-kid-stats">
|
||||
<div class="dash-stat-box">
|
||||
<div class="dash-stat-num">${done}</div>
|
||||
<div class="dash-stat-lbl">Done today</div>
|
||||
</div>
|
||||
<div class="dash-stat-box">
|
||||
<div class="dash-stat-num">${total}</div>
|
||||
<div class="dash-stat-lbl">Scheduled</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dash-progress-bar">
|
||||
<div class="dash-progress-fill" style="width:${pct}%"></div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
schedEl.innerHTML = rows.length ? rows.slice(0, 12).map(r => {
|
||||
const kidName = students.find(s => s.id === r.student_id)?.name || '';
|
||||
return `<div class="dash-schedule-item${r.done ? ' done' : ''}">
|
||||
<span class="sch-dot"></span>
|
||||
<span>${r.title}</span>
|
||||
<span class="dash-schedule-who">${kidName}</span>
|
||||
</div>`;
|
||||
}).join('') : '<p style="color:var(--muted);font-size:.82rem">No schedule for today.</p>';
|
||||
|
||||
const scores = recentScores.scores || recentScores || [];
|
||||
scoresEl.innerHTML = scores.length ? scores.slice(0, 8).map(sc => {
|
||||
const pct = sc.total > 0 ? Math.round(sc.score / sc.total * 100) : null;
|
||||
const cls = pct === null ? '' : pct >= 80 ? '' : pct >= 60 ? ' mid' : ' low';
|
||||
const kidName = students.find(s => s.id === sc.student_id)?.name || '';
|
||||
return `<div class="dash-score-item">
|
||||
<span>${sc.title}</span>
|
||||
<span class="dash-score-who" style="font-size:.7rem;color:var(--muted);margin-left:.25rem">${kidName}</span>
|
||||
<span class="dash-score-pct${cls}">${pct !== null ? pct+'%' : sc.score+' pts'}</span>
|
||||
</div>`;
|
||||
}).join('') : '<p style="color:var(--muted);font-size:.82rem">No scores yet.</p>';
|
||||
|
||||
} catch (e) {
|
||||
kidGrid.innerHTML = '<p style="color:var(--muted)">Could not load dashboard data.</p>';
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Kids
|
||||
// ============================================================
|
||||
@@ -1310,8 +1415,8 @@ $('#planner-student').addEventListener('change', () => {
|
||||
});
|
||||
$('#planner-view-week').addEventListener('click', () => {
|
||||
plannerMode = 'week';
|
||||
$('#planner-view-week').classList.add('active-view');
|
||||
$('#planner-view-month').classList.remove('active-view');
|
||||
$('#planner-view-week').classList.add('active');
|
||||
$('#planner-view-month').classList.remove('active');
|
||||
$('#planner-copy').hidden = false;
|
||||
renderPlanner();
|
||||
});
|
||||
@@ -1321,8 +1426,8 @@ $('#planner-view-month').addEventListener('click', () => {
|
||||
const ref = new Date(plannerWeekStart + 'T00:00:00');
|
||||
plannerMonthRef = `${ref.getFullYear()}-${String(ref.getMonth() + 1).padStart(2, '0')}-01`;
|
||||
plannerWeekStart = firstWeekStartOfMonth(plannerMonthRef);
|
||||
$('#planner-view-month').classList.add('active-view');
|
||||
$('#planner-view-week').classList.remove('active-view');
|
||||
$('#planner-view-month').classList.add('active');
|
||||
$('#planner-view-week').classList.remove('active');
|
||||
$('#planner-copy').hidden = true;
|
||||
renderPlanner();
|
||||
});
|
||||
@@ -1455,26 +1560,53 @@ function renderDayCell(day, tasks, compact = false, spellingTests = [], today =
|
||||
const dayTasks = tasks.filter((t) => t.date === day.date);
|
||||
const dayTests = spellingTests.filter((t) => t.date === day.date);
|
||||
const isToday = day.date === today;
|
||||
const isPast = day.date < today;
|
||||
const doneCount = dayTasks.filter(t => t.done).length;
|
||||
const total = dayTasks.length + dayTests.length;
|
||||
|
||||
const testBadges = dayTests.map((t) => {
|
||||
const pct = Math.round((t.score / t.total) * 100);
|
||||
return `<div class="planner-task planner-test-badge" title="${esc(t.list)}">⭐ Spelling ${t.score}/${t.total} (${pct}%)</div>`;
|
||||
}).join('');
|
||||
const taskHtml = dayTasks.map((t) => {
|
||||
const colorStyle = t.courseColor ? ` style="border-left:3px solid ${esc(t.courseColor)}"` : '';
|
||||
return `<div class="planner-task${t.done ? ' done' : ''}" draggable="true" data-task-id="${t.id}"${colorStyle}>
|
||||
<button class="planner-done-btn${t.done ? ' is-done' : ''}" data-done-schedule="${t.id}" data-is-done="${t.done ? '1' : '0'}" title="${t.done ? 'Mark not done' : 'Mark done'}">✓</button>
|
||||
<span>${t.itemId ? TYPE_ICON[t.type] : '📌'} ${esc(t.itemTitle || t.offlineTitle)}</span>
|
||||
<button class="danger tiny" data-del-schedule="${t.id}">✕</button>
|
||||
const cls = pct >= 80 ? 'good' : pct >= 60 ? 'mid' : 'bad';
|
||||
return `<div class="planner-task planner-test-badge">
|
||||
<span class="task-icon">✏️</span>
|
||||
<span class="task-title">Spelling test</span>
|
||||
<span class="task-score ${cls}">${pct}%</span>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
const taskHtml = dayTasks.map((t) => {
|
||||
const border = t.courseColor ? `border-left-color:${esc(t.courseColor)}` : '';
|
||||
const courseName = t.courseName ? `<span class="task-course">${esc(t.courseName)}</span>` : '';
|
||||
return `<div class="planner-task${t.done ? ' done' : ''}" draggable="true" data-task-id="${t.id}" style="${border}">
|
||||
<button class="planner-done-btn${t.done ? ' is-done' : ''}" data-done-schedule="${t.id}" data-is-done="${t.done ? '1' : '0'}" title="${t.done ? 'Undo' : 'Mark done'}">✓</button>
|
||||
<div class="task-body">
|
||||
<span class="task-title">${t.itemId ? TYPE_ICON[t.type] || '' : '📌'} ${esc(t.itemTitle || t.offlineTitle)}</span>
|
||||
${courseName}
|
||||
</div>
|
||||
<button class="planner-del-btn" data-del-schedule="${t.id}" title="Remove">✕</button>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
const progress = total > 0
|
||||
? `<span class="day-progress${doneCount === total ? ' all-done' : ''}">${doneCount}/${total}</span>`
|
||||
: '';
|
||||
const emptyState = !taskHtml && !testBadges
|
||||
? `<button class="planner-empty-add" data-add-task="${day.date}">+ Add task</button>`
|
||||
: '';
|
||||
|
||||
const fmtDate = (d) => { const dt = new Date(d + 'T00:00:00'); return dt.toLocaleString('default', { month: 'short', day: 'numeric' }); };
|
||||
return `<div class="planner-day${compact ? ' planner-day-compact' : ''}${isToday ? ' today' : ''}" data-drop-date="${day.date}">
|
||||
<h3>${compact ? day.name.slice(0,3) : day.name}<small>${fmtDate(day.date)}</small></h3>
|
||||
<div class="planner-tasks" data-drop-date="${day.date}">
|
||||
${testBadges}
|
||||
${taskHtml || (!testBadges ? `<p class="hint tiny">—</p>` : '')}
|
||||
return `<div class="planner-day${compact ? ' planner-day-compact' : ''}${isToday ? ' is-today' : ''}${isPast && !compact ? ' is-past' : ''}" data-drop-date="${day.date}">
|
||||
<div class="planner-day-header">
|
||||
<div class="day-label">
|
||||
<span class="day-name">${compact ? day.name.slice(0,3) : day.name}</span>
|
||||
<span class="day-date">${fmtDate(day.date)}</span>
|
||||
</div>
|
||||
${progress}
|
||||
</div>
|
||||
<button class="secondary small" data-add-task="${day.date}">+ Add</button>
|
||||
<div class="planner-tasks" data-drop-date="${day.date}">
|
||||
${testBadges}${taskHtml}${emptyState}
|
||||
</div>
|
||||
${taskHtml || testBadges ? `<button class="planner-add-more" data-add-task="${day.date}">+ Add</button>` : ''}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user