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:
1 parent
1eedfc7715
commit
6b7baf7297
6 files changed
+135
-5
No files matched your search
+18
-2
@@ -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">
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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 -->
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in new issue
Block a user