Redesign student UI and add OnlyOffice document integration
Build and Push Docker Image / build-and-push (push) Successful in 33s

- Student section: new hero gradient header, fixed bottom nav bar with
  icon+label pills (Today/Courses/Spelling/Inbox/Docs)
- Today rows: card style with left accent stripe, lift on hover
- Course cards: grid layout with top color bar per course
- Docs tab: list view with doc cards, OnlyOffice editor embedded via
  iframe using server-signed JWT config
- Server: documents table, CRUD routes, OnlyOffice callback endpoint
  for auto-save, /api/docs/:id/editor-config returns signed config
- Blank DOCX template embedded as base64 for new document creation
- OnlyOffice server: https://onlyoffice.necloud.us (set ONLYOFFICE_JWT_SECRET
  env var to match your Document Server JWT secret)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 4.6 committed 2026-10-19 14:12:39 -04:00
1 parent 16e3bbbd83
commit 95c0da5c51
6 files changed
+519 -56

No files matched your search

+144 -3
View File
@@ -4,7 +4,7 @@ import { fileURLToPath } from 'node:url';
import { spawnSync } from 'node:child_process';
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync } from 'node:fs';
import { readFile, writeFile, unlink } from 'node:fs/promises';
import { createHash, randomUUID } from 'node:crypto';
import { createHash, createHmac, randomUUID } from 'node:crypto';
import mammoth from 'mammoth';
import multer from 'multer';
import { db, sha256 } from './db.js';
@@ -15,6 +15,19 @@ const TTS_CACHE = join(DATA_DIR, 'tts-cache');
mkdirSync(TTS_CACHE, { recursive: true });
const ATTACHMENTS_DIR = join(DATA_DIR, 'attachments');
mkdirSync(ATTACHMENTS_DIR, { recursive: true });
const DOCS_DIR = join(DATA_DIR, 'docs');
mkdirSync(DOCS_DIR, { recursive: true });
const ONLYOFFICE_URL = (process.env.ONLYOFFICE_URL || 'https://onlyoffice.necloud.us').replace(/\/$/, '');
const SERVER_URL = (process.env.SERVER_URL || 'http://localhost:3002').replace(/\/$/, '');
const ONLYOFFICE_JWT_SECRET = process.env.ONLYOFFICE_JWT_SECRET || '';
function jwtSign(payload) {
if (!ONLYOFFICE_JWT_SECRET) return undefined;
const header = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64url');
const body = Buffer.from(JSON.stringify(payload)).toString('base64url');
const sig = createHmac('sha256', ONLYOFFICE_JWT_SECRET).update(`${header}.${body}`).digest('base64url');
return `${header}.${body}.${sig}`;
}
const PIPER_BIN = process.env.PIPER_BIN || 'piper';
const PIPER_MODEL = process.env.PIPER_MODEL || join(ROOT, 'voices', 'en_US-lessac-medium.onnx');
const PIPER_OK = existsSync(PIPER_MODEL);
@@ -838,10 +851,10 @@ app.get('/api/admin/schedule', requirePin, (req, res) => {
app.get('/api/admin/recent-scores', requirePin, (req, res) => {
const scores = db.prepare(`
SELECT s.id, s.student_id, i.title, s.score, s.total, s.submitted_at
SELECT s.id, s.student_id, i.title, s.score, s.points_possible, s.completed_at
FROM submissions s
JOIN items i ON i.id = s.item_id
ORDER BY s.submitted_at DESC
ORDER BY s.completed_at DESC
LIMIT 20
`).all();
res.json({ scores });
@@ -2435,6 +2448,134 @@ app.post('/api/admin/upload-attachment', requirePin, async (req, res) => {
return res.json({ url: `/api/attachments/${filename}`, htmlBody, originalName });
});
// ── Documents (OnlyOffice) ────────────────────────────────────────────────────
app.use('/api/docs/file', express.static(DOCS_DIR));
app.get('/api/docs', (req, res) => {
const { studentId } = req.query;
if (!studentId) return res.status(400).json({ error: 'studentId required' });
const docs = db.prepare(`SELECT id, title, doc_key, submitted, submitted_at, parent_read, created_at, updated_at FROM documents WHERE student_id = ? ORDER BY updated_at DESC`).all(studentId);
res.json({ docs });
});
// Minimal blank DOCX (base64-encoded zip/OOXML)
const BLANK_DOCX = Buffer.from(
'UEsDBBQAAAAIADtxU13XeYTq8QAAALgBAAATAAAAW0NvbnRlbnRfVHlwZXNdLnhtbH2QzU7DMBCE730Ky9cqccoBIZSkB36OwKE8wMreJFb9J69b2rdn00KREOVozXwz62nXB+/EHjPZGDq5qhspMOhobBg7+b55ru6koALBgIsBO3lEkut+0W6OCUkwHKiTUynpXinSE3qgOiYMrAwxeyj8zKNKoLcworppmlulYygYSlXmDNkvhGgfcYCdK+LpwMr5loyOpHg4e+e6TkJKzmoorKt9ML+Kqq+SmsmThyabaMkGqa6VzOL1jh/0lSfK1qB4g1xewLNRfcRslIl65xmu/0/649o4DFbjhZ/TUo4aiXh77+qL4sGG71+06jR8/wlQSwMEFAAAAAgAO3FTXSAbhuqyAAAALgEAAAsAAABfcmVscy8ucmVsc43Puw6CMBQG4J2naM4uBQdjDIXFmLAafICmPZRGeklbL7y9HRzEODie23fyN93TzOSOIWpnGdRlBQStcFJbxeAynDZ7IDFxK/nsLDJYMELXFs0ZZ57yTZy0jyQjNjKYUvIHSqOY0PBYOo82T0YXDE+5DIp6Lq5cId1W1Y6GTwPagpAVS3rJIPSyBjIsHv/h3ThqgUcnbgZt+vHlayPLPChMDB4uSCrf7TKzQHNKuorZvgBQSwMEFAAAAAgAO3FTXYwOhdB9AAAAnQAAABwAAAB3b3JkL19yZWxzL2RvY3VtZW50LnhtbC5yZWxzVcxBDsIgEIXhvacgs7egC2NMaXc9gNEDTOgIjTAQhhi9vSx1+fLnfeP8TlG9qMqW2cJhMKCIXV439hbut2V/BiUNecWYmSx8SGCeduOVIrb+kbAVUR1hsRBaKxetxQVKKEMuxL08ck3Y+qxeF3RP9KSPxpx0/TWgo/pPnb5QSwMEFAAAAAgAO3FTXf4sp2t6AQAA0wQAABEAAAB3b3JkL2RvY3VtZW50LnhtbKWUzW7DIAzH73uKiHtLMk3TFDXpYdWmXqZK3R6AEtqgAkZAk/XtB/nsNKnK2ktsBP75bzuwWH5LEVXMWA4qQ8k8RhFTFAquDhn6+nybvaDIOqIKIkCxDJ2ZRcv8YVGnBdCTZMpFnqBsWmuaodI5nWJsackksXPJqQELezenIDHs95wyXIMp8GOcxI2nDVBmrU/3SlRFLOpw8i8NNFN+cw9GEueX5oAlMceTnnm6Jo7vuODu7Nnxc4+BDJ2MSjvEbBAUQtJWUGf6CDMlbxuy6jrQZMSGCa8BlC25Hsu4leY3yx5SXSuikgINI0ie7pvBypDamxE4RX7RBknRKr9OTOIJEwmIIWKKhN85eyWScDUmvqk1l8093NfbdwMnPdL4fbS1Og6scC//wepmdFmavU/MtiTaXyBJ0/VBgSE74RX5jkfhj0T5QxT592IHxTm4zULn4bMxjdm6s2BRnVZEZOgjjFUgnC9wdyLYIdAy6jYGN0zcQ4PXP0f5D1BLAQIUAxQAAAAIADtxU13XeYTq8QAAALgBAAATAAAAAAAAAAAAAACAAQAAAABbQ29udGVudF9UeXBlc10ueG1sUEsBAhQDFAAAAAgAO3FTXSAbhuqyAAAALgEAAAsAAAAAAAAAAAAAAIABIgEAAF9yZWxzLy5yZWxzUEsBAhQDFAAAAAgAO3FTXYwOhdB9AAAAnQAAABwAAAAAAAAAAAAAAIAB/QEAAHdvcmQvX3JlbHMvZG9jdW1lbnQueG1sLnJlbHNQSwECFAMUAAAACAA7cVNd/iyna3oBAADTBAAAEQAAAAAAAAAAAAAAgAG0AgAAd29yZC9kb2N1bWVudC54bWxQSwUGAAAAAAQABAADAQAAXQQAAAAA',
'base64'
);
app.post('/api/docs', async (req, res) => {
const { studentId, title = 'Untitled Document' } = req.body;
if (!studentId) return res.status(400).json({ error: 'studentId required' });
const key = randomUUID();
const filename = `${key}.docx`;
await writeFile(join(DOCS_DIR, filename), BLANK_DOCX);
const id = db.prepare(`INSERT INTO documents (student_id, title, doc_key, filename) VALUES (?, ?, ?, ?)`).run(studentId, title, key, filename).lastInsertRowid;
res.json({ id, key, title });
});
app.put('/api/docs/:id/title', (req, res) => {
const { title } = req.body;
db.prepare(`UPDATE documents SET title = ?, updated_at = datetime('now') WHERE id = ?`).run(title, req.params.id);
res.json({ ok: true });
});
app.post('/api/docs/:id/submit', (req, res) => {
const doc = db.prepare(`SELECT * FROM documents WHERE id = ?`).get(req.params.id);
if (!doc) return res.status(404).json({ error: 'Not found' });
db.prepare(`UPDATE documents SET submitted = 1, submitted_at = datetime('now'), updated_at = datetime('now') WHERE id = ?`).run(doc.id);
db.prepare(`INSERT INTO messages (student_id, sender, body) VALUES (?, 'student', ?)`).run(doc.student_id, `📄 Submitted document: "${doc.title}"`);
res.json({ ok: true });
});
app.delete('/api/docs/:id', async (req, res) => {
const doc = db.prepare(`SELECT * FROM documents WHERE id = ?`).get(req.params.id);
if (!doc) return res.status(404).json({ error: 'Not found' });
db.prepare(`DELETE FROM documents WHERE id = ?`).run(doc.id);
try { await unlink(join(DOCS_DIR, doc.filename)); } catch { /* already gone */ }
res.json({ ok: true });
});
// OnlyOffice save callback
app.post('/api/docs/callback/:key', express.json(), async (req, res) => {
const { status, url } = req.body;
// status 2 = ready to save, 6 = force-save
if (status === 2 || status === 6) {
const doc = db.prepare(`SELECT * FROM documents WHERE doc_key = ?`).get(req.params.key);
if (doc && url) {
try {
const r = await fetch(url);
const buf = Buffer.from(await r.arrayBuffer());
await writeFile(join(DOCS_DIR, doc.filename), buf);
db.prepare(`UPDATE documents SET updated_at = datetime('now') WHERE doc_key = ?`).run(req.params.key);
} catch { /* save failed silently */ }
}
}
res.json({ error: 0 });
});
// Admin: list submitted docs
app.get('/api/admin/docs', requirePin, (req, res) => {
const docs = db.prepare(`
SELECT d.id, d.title, d.doc_key, d.filename, d.submitted_at, d.parent_read, d.updated_at,
s.name AS student_name
FROM documents d JOIN students s ON s.id = d.student_id
WHERE d.submitted = 1
ORDER BY d.submitted_at DESC
`).all();
res.json({ docs });
});
app.put('/api/admin/docs/:id/read', requirePin, (req, res) => {
db.prepare(`UPDATE documents SET parent_read = 1 WHERE id = ?`).run(req.params.id);
res.json({ ok: true });
});
app.get('/api/admin/docs/config', requirePin, (req, res) => {
res.json({ onlyofficeUrl: ONLYOFFICE_URL, serverUrl: SERVER_URL });
});
app.get('/api/docs/config', (req, res) => {
res.json({ onlyofficeUrl: ONLYOFFICE_URL, serverUrl: SERVER_URL });
});
// Returns a fully-signed OnlyOffice editor config for a specific document
app.get('/api/docs/:id/editor-config', (req, res) => {
const doc = db.prepare(`SELECT d.*, s.name AS student_name FROM documents d JOIN students s ON s.id = d.student_id WHERE d.id = ?`).get(req.params.id);
if (!doc) return res.status(404).json({ error: 'Not found' });
const config = {
document: {
fileType: 'docx',
key: doc.doc_key,
title: doc.title,
url: `${SERVER_URL}/api/docs/file/${doc.filename}`,
permissions: { edit: true, download: true, print: true },
},
editorConfig: {
callbackUrl: `${SERVER_URL}/api/docs/callback/${doc.doc_key}`,
user: { id: String(doc.student_id), name: doc.student_name },
mode: 'edit',
lang: 'en',
customization: {
autosave: true,
forcesave: true,
logo: { visible: false },
compactHeader: true,
toolbarNoTabs: true,
},
},
type: 'desktop',
height: '100%',
width: '100%',
};
const token = jwtSign(config);
if (token) config.token = token;
res.json({ onlyofficeUrl: ONLYOFFICE_URL, config });
});
app.get('/api/health', (req, res) => res.json({ ok: true }));
app.get('/health', (req, res) => res.json({ ok: true }));
const PORT = process.env.PORT || 3000;