Redesign student UI and add OnlyOffice document integration
Build and Push Docker Image / build-and-push (push) Successful in 33s
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:
1 parent
16e3bbbd83
commit
95c0da5c51
6 files changed
+519
-56
No files matched your search
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user