Add school name and logo to home screen and admin header

- School name now appears on the student home screen and updates the page title
- Logo can be uploaded in Settings (PNG/JPG/GIF/WebP/SVG, max 2 MB); stored in the data volume so it persists across container restarts
- When a logo is uploaded the text school name is hidden in favour of the logo
- Admin header shows the school name instead of hardcoded "Homeschool", updates live when saved
- New server endpoints: GET /api/logo, POST /api/admin/upload-logo, DELETE /api/admin/logo
- /api/public-settings now includes school_name and has_logo

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 4.6 committed 2026-08-10 16:46:00 -04:00
1 parent 1eedfc7715
commit 6b7baf7297
6 files changed
+135 -5

No files matched your search

+18 -2
View File
@@ -22,7 +22,7 @@
<div id="console" hidden> <div id="console" hidden>
<header class="admin-header"> <header class="admin-header">
<a href="index.html" class="admin-logo">🎓 Homeschool</a> <a href="index.html" class="admin-logo" id="admin-school-name">🎓 Homeschool</a>
<nav class="admin-nav"> <nav class="admin-nav">
<button class="nav-pill active" data-panel="kids">🧒 Kids</button> <button class="nav-pill active" data-panel="kids">🧒 Kids</button>
<button class="nav-pill" data-panel="courses">📚 Courses</button> <button class="nav-pill" data-panel="courses">📚 Courses</button>
@@ -516,10 +516,26 @@
<h2>School</h2> <h2>School</h2>
<form id="app-settings-form"> <form id="app-settings-form">
<div class="field-group"> <div class="field-group">
<label for="setting-school-name">School / family name <span class="hint">(appears on printed reports)</span></label> <label for="setting-school-name">School / family name <span class="hint">(appears on home screen and printed reports)</span></label>
<input id="setting-school-name" type="text" placeholder="e.g. Emmert Academy" style="max-width:22rem;display:block;margin-top:.35rem"> <input id="setting-school-name" type="text" placeholder="e.g. Emmert Academy" style="max-width:22rem;display:block;margin-top:.35rem">
</div> </div>
<div class="field-group" style="margin-top:1rem">
<label>School logo <span class="hint">(shown on the student home screen)</span></label>
<div id="logo-preview-wrap" style="margin:.4rem 0 .6rem">
<img id="logo-preview" src="" alt="School logo" hidden style="max-height:80px;max-width:240px;border-radius:6px;display:block;margin-bottom:.4rem">
<p id="logo-none-msg" class="hint" style="margin:0">No logo uploaded.</p>
</div>
<div class="inline-form" style="gap:.5rem;flex-wrap:wrap">
<label class="btn" style="cursor:pointer;display:inline-flex;align-items:center;gap:.3rem">
📁 Choose logo
<input id="logo-file-input" type="file" accept="image/png,image/jpeg,image/gif,image/webp,image/svg+xml" style="display:none">
</label>
<button type="button" id="logo-remove-btn" hidden>Remove logo</button>
</div>
<p class="hint" style="margin-top:.3rem">PNG, JPG, GIF, WebP, or SVG · max 2 MB</p>
</div>
<div class="field-group" style="margin-top:1rem"> <div class="field-group" style="margin-top:1rem">
<label for="setting-passing-pct">Passing grade threshold</label> <label for="setting-passing-pct">Passing grade threshold</label>
<div class="inline-form" style="margin-top:.35rem"> <div class="inline-form" style="margin-top:.35rem">
+47
View File
@@ -58,12 +58,17 @@ $('#pin-form').addEventListener('submit', async (e) => {
unlock(); unlock();
}); });
function updateAdminHeader() {
$('#admin-school-name').textContent = `🎓 ${appSettings.schoolName || 'Homeschool'}`;
}
async function loadAppSettings() { async function loadAppSettings() {
try { try {
const s = await api('/api/admin/app-settings'); const s = await api('/api/admin/app-settings');
appSettings.schoolName = s.school_name || ''; appSettings.schoolName = s.school_name || '';
appSettings.passingPct = Number(s.passing_pct) || 80; appSettings.passingPct = Number(s.passing_pct) || 80;
appSettings.weekStartDay = s.week_start_day || 'monday'; appSettings.weekStartDay = s.week_start_day || 'monday';
updateAdminHeader();
} catch {} } catch {}
} }
@@ -2551,8 +2556,49 @@ async function loadSettings() {
const radio = document.querySelector(`input[name="week_start_day"][value="${appSettings.weekStartDay}"]`); const radio = document.querySelector(`input[name="week_start_day"][value="${appSettings.weekStartDay}"]`);
if (radio) radio.checked = true; if (radio) radio.checked = true;
$('#setting-show-emoji').checked = s.show_home_emoji !== false; $('#setting-show-emoji').checked = s.show_home_emoji !== false;
const pub = await fetch('/api/public-settings').then((r) => r.json()).catch(() => ({}));
setLogoPreview(pub.has_logo);
} }
function setLogoPreview(hasLogo) {
const preview = $('#logo-preview');
const noneMsg = $('#logo-none-msg');
const removeBtn = $('#logo-remove-btn');
if (hasLogo) {
preview.src = `/api/logo?v=${Date.now()}`;
preview.hidden = false;
noneMsg.hidden = true;
removeBtn.hidden = false;
} else {
preview.hidden = true;
noneMsg.hidden = false;
removeBtn.hidden = true;
}
}
$('#logo-file-input').addEventListener('change', async (e) => {
const file = e.target.files[0];
if (!file) return;
if (file.size > 2 * 1024 * 1024) { msg('Logo must be under 2 MB.'); e.target.value = ''; return; }
const b64 = await new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result.split(',')[1]);
reader.onerror = reject;
reader.readAsDataURL(file);
});
await api('/api/admin/upload-logo', { method: 'POST', body: { base64: b64, mime: file.type } });
e.target.value = '';
setLogoPreview(true);
msg('Logo saved.');
});
$('#logo-remove-btn').addEventListener('click', async () => {
await api('/api/admin/logo', { method: 'DELETE' });
setLogoPreview(false);
msg('Logo removed.');
});
$('#app-settings-form').addEventListener('submit', async (e) => { $('#app-settings-form').addEventListener('submit', async (e) => {
e.preventDefault(); e.preventDefault();
const school_name = $('#setting-school-name').value.trim(); const school_name = $('#setting-school-name').value.trim();
@@ -2561,6 +2607,7 @@ $('#app-settings-form').addEventListener('submit', async (e) => {
const show_home_emoji = $('#setting-show-emoji').checked; const show_home_emoji = $('#setting-show-emoji').checked;
await api('/api/admin/app-settings', { method: 'POST', body: { school_name, passing_pct, week_start_day, show_home_emoji } }); await api('/api/admin/app-settings', { method: 'POST', body: { school_name, passing_pct, week_start_day, show_home_emoji } });
appSettings.schoolName = school_name; appSettings.schoolName = school_name;
updateAdminHeader();
appSettings.passingPct = passing_pct; appSettings.passingPct = passing_pct;
if (appSettings.weekStartDay !== week_start_day) { if (appSettings.weekStartDay !== week_start_day) {
appSettings.weekStartDay = week_start_day; appSettings.weekStartDay = week_start_day;
+15
View File
@@ -247,6 +247,21 @@ async function loadHome() {
document.querySelector('.page-confetti').style.display = document.querySelector('.page-confetti').style.display =
pubSettings.show_home_emoji === false ? 'none' : ''; pubSettings.show_home_emoji === false ? 'none' : '';
const nameTitle = document.getElementById('school-name-title');
if (pubSettings.school_name) {
nameTitle.textContent = pubSettings.school_name;
document.title = pubSettings.school_name;
}
const logoEl = document.getElementById('school-logo');
if (pubSettings.has_logo) {
logoEl.src = `/api/logo?v=${Date.now()}`;
logoEl.hidden = false;
nameTitle.hidden = true;
} else {
logoEl.hidden = true;
nameTitle.hidden = false;
}
const students = await api('/api/students'); const students = await api('/api/students');
$('#no-kids').hidden = students.length > 0; $('#no-kids').hidden = students.length > 0;
$('#kid-cards').innerHTML = students $('#kid-cards').innerHTML = students
+2 -1
View File
@@ -34,11 +34,12 @@
<!-- Kid picker --> <!-- Kid picker -->
<section id="view-home" class="view"> <section id="view-home" class="view">
<h1 class="app-title">🎓 Homeschool</h1> <h1 class="app-title" id="school-name-title">🎓 Homeschool</h1>
<p class="subtitle">Who's learning today?</p> <p class="subtitle">Who's learning today?</p>
<div id="kid-cards" class="kid-cards"></div> <div id="kid-cards" class="kid-cards"></div>
<p id="no-kids" class="hint" hidden>No kids yet — a parent needs to add them.</p> <p id="no-kids" class="hint" hidden>No kids yet — a parent needs to add them.</p>
<a class="parent-dash-card" href="admin.html">🔐 Parent dashboard</a> <a class="parent-dash-card" href="admin.html">🔐 Parent dashboard</a>
<img id="school-logo" src="" alt="School logo" hidden>
</section> </section>
<!-- Kid home: Today's work + nav --> <!-- Kid home: Today's work + nav -->
+2
View File
@@ -53,6 +53,8 @@ body {
} }
.app-title { font-size: 2.4rem; margin-bottom: 0.5rem; } .app-title { font-size: 2.4rem; margin-bottom: 0.5rem; }
#school-logo { display: block; margin: 2.5rem auto 0; max-width: min(520px, 92vw); height: auto; }
.subtitle { font-size: 1.25rem; color: var(--muted); margin-bottom: 2rem; } .subtitle { font-size: 1.25rem; color: var(--muted); margin-bottom: 2rem; }
.hint { color: var(--muted); margin: 1rem 0; } .hint { color: var(--muted); margin: 1rem 0; }
.error { color: var(--bad); margin: 0.5rem 0; } .error { color: var(--bad); margin: 0.5rem 0; }
+51 -2
View File
@@ -3,7 +3,7 @@ import { join, dirname, extname } from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { spawnSync } from 'node:child_process'; import { spawnSync } from 'node:child_process';
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync } from 'node:fs'; import { existsSync, mkdirSync, readFileSync, readdirSync, statSync } from 'node:fs';
import { readFile, writeFile } from 'node:fs/promises'; import { readFile, writeFile, unlink } from 'node:fs/promises';
import { createHash, randomUUID } from 'node:crypto'; import { createHash, randomUUID } from 'node:crypto';
import mammoth from 'mammoth'; import mammoth from 'mammoth';
import multer from 'multer'; import multer from 'multer';
@@ -711,7 +711,56 @@ app.post('/api/admin/app-settings', requirePin, (req, res) => {
}); });
app.get('/api/public-settings', (req, res) => { app.get('/api/public-settings', (req, res) => {
res.json({ show_home_emoji: getSetting('show_home_emoji') !== 'false' }); const logoExt = getSetting('logo_ext');
res.json({
show_home_emoji: getSetting('show_home_emoji') !== 'false',
school_name: getSetting('school_name') || '',
has_logo: !!(logoExt && existsSync(join(DATA_DIR, `logo${logoExt}`))),
});
});
app.get('/api/logo', (req, res) => {
const ext = getSetting('logo_ext');
if (!ext) return res.status(404).end();
const filePath = join(DATA_DIR, `logo${ext}`);
if (!existsSync(filePath)) return res.status(404).end();
const mimeMap = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml' };
res.setHeader('Content-Type', mimeMap[ext] || 'application/octet-stream');
res.setHeader('Cache-Control', 'no-cache');
res.sendFile(filePath);
});
app.post('/api/admin/upload-logo', requirePin, async (req, res) => {
const { base64: b64, mime } = req.body;
if (!b64 || !mime) return res.status(400).json({ error: 'base64 and mime required' });
const mimeToExt = { 'image/png': '.png', 'image/jpeg': '.jpg', 'image/gif': '.gif', 'image/webp': '.webp', 'image/svg+xml': '.svg' };
const ext = mimeToExt[mime];
if (!ext) return res.status(400).json({ error: 'Unsupported image type' });
let buffer;
try { buffer = Buffer.from(b64, 'base64'); }
catch (_) { return res.status(400).json({ error: 'Invalid base64' }); }
if (buffer.byteLength > 2 * 1024 * 1024) return res.status(400).json({ error: 'Logo must be under 2 MB' });
const oldExt = getSetting('logo_ext');
if (oldExt && oldExt !== ext) {
await unlink(join(DATA_DIR, `logo${oldExt}`)).catch(() => {});
}
await writeFile(join(DATA_DIR, `logo${ext}`), buffer);
const upsert = (k, v) => db.prepare(`INSERT INTO settings (key,value) VALUES (?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value`).run(k, String(v));
upsert('logo_ext', ext);
res.json({ ok: true });
});
app.delete('/api/admin/logo', requirePin, async (req, res) => {
const ext = getSetting('logo_ext');
if (ext) {
await unlink(join(DATA_DIR, `logo${ext}`)).catch(() => {});
db.prepare(`DELETE FROM settings WHERE key = 'logo_ext'`).run();
}
res.json({ ok: true });
}); });
// Validate MM-DD format (no year stored — birthday repeats annually) // Validate MM-DD format (no year stored — birthday repeats annually)