Fix editor full-screen and add parent-side doc viewer
Build and Push Docker Image / build-and-push (push) Successful in 45s

Student editor:
- Add body.docs-editor-open class when editor opens; hide hero, nav,
  confetti so nothing bleeds behind the fixed overlay

Admin docs panel:
- New "Documents" sidebar entry listing all submitted docs with student
  name, date, and unread badge
- Click any doc to open it in OnlyOffice in view (read-only) mode
- Download button for offline access
- /api/docs/bykey/:key/editor-config server route for admin viewer

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 4.6 committed 2026-10-19 14:54:38 -04:00
1 parent 6a5ac46294
commit ad3e0bc7be
5 files changed
+128 -2

No files matched your search

+20
View File
@@ -57,6 +57,9 @@
<button class="nav-pill" data-panel="decks">
<span class="nav-icon">🗂️</span><span class="nav-label">Flashcards</span>
</button>
<button class="nav-pill" data-panel="docs">
<span class="nav-icon">📝</span><span class="nav-label">Docs</span>
</button>
</nav>
<div class="admin-sidebar-footer">
<button class="nav-pill" data-panel="settings">
@@ -679,6 +682,23 @@
</div>
</section>
<!-- Docs -->
<section id="panel-docs" class="panel" hidden>
<h1>Submitted Documents</h1>
<div id="admin-docs-list">
<p class="hint">Loading…</p>
</div>
<!-- Full-screen doc viewer -->
<div id="admin-doc-viewer" hidden>
<div class="docs-editor-toolbar">
<button id="btn-admin-doc-back" class="docs-back-btn">← Back to list</button>
<span id="admin-doc-viewer-title" class="docs-editor-title-text"></span>
<a id="admin-doc-download" class="docs-submit-btn" download>⬇ Download</a>
</div>
<div id="admin-doc-frame"></div>
</div>
</section>
<!-- Settings -->
<section id="panel-settings" class="panel">
<h1>Settings</h1>
+80 -2
View File
@@ -120,7 +120,7 @@ document.querySelectorAll('[data-nav]').forEach((btn) =>
const PANEL_TITLES = {
dashboard: 'Dashboard', kids: 'Kids', courses: 'Courses', planner: 'Planner',
grading: 'Grading', messages: 'Messages', gradebook: 'Gradebook',
spelling: 'Spelling', decks: 'Flashcards', settings: 'Settings',
spelling: 'Spelling', decks: 'Flashcards', docs: 'Documents', settings: 'Settings',
'course-detail': 'Course Detail', 'item-editor': 'Item Editor',
};
@@ -132,7 +132,7 @@ function showPanel(name) {
const loaders = {
dashboard: loadDashboard, kids: loadKids, courses: loadCourses, planner: loadPlannerPanel,
grading: loadGrading, messages: loadMessages, gradebook: loadGradebookPanel, spelling: loadSpelling, decks: loadDecks,
settings: loadSettings,
docs: loadAdminDocs, settings: loadSettings,
};
if (loaders[name]) loaders[name]();
if (name === 'course-detail') openCourseDetail(currentCourseId);
@@ -3290,3 +3290,81 @@ $('#app-settings-form').addEventListener('submit', async (e) => {
msg('Settings saved.');
});
// ── Admin: Submitted Documents ────────────────────────────────────────────────
let adminDocsEditorInstance = null;
async function loadAdminDocs() {
const { docs } = await api('/api/admin/docs');
const el = $('#admin-docs-list');
if (!docs.length) {
el.innerHTML = '<p class="hint">No submitted documents yet.</p>';
return;
}
el.innerHTML = docs.map((d) => `
<div class="doc-card${!d.parent_read ? ' inbox-new' : ''}" style="cursor:pointer"
data-id="${d.id}" data-key="${esc(d.doc_key)}" data-filename="${esc(d.filename)}" data-title="${esc(d.title)}">
<div class="doc-card-icon">📄</div>
<div class="doc-card-info">
<div class="doc-card-title">${esc(d.title)}${!d.parent_read ? ' <span class="feedback-badge">New</span>' : ''}</div>
<div class="doc-card-meta">${esc(d.student_name)} · Submitted ${new Date(d.submitted_at + 'Z').toLocaleDateString()}</div>
</div>
</div>
`).join('');
el.querySelectorAll('.doc-card').forEach((card) => {
card.addEventListener('click', () => openAdminDocViewer(
+card.dataset.id, card.dataset.key, card.dataset.filename, card.dataset.title
));
});
}
async function openAdminDocViewer(id, key, filename, title) {
await api(`/api/admin/docs/${id}/read`, { method: 'PUT' });
$('#admin-docs-list').hidden = true;
$('#admin-doc-viewer').hidden = false;
$('#admin-doc-viewer-title').textContent = title;
$('#admin-doc-download').href = `/api/docs/file/${filename}`;
$('#admin-doc-download').download = title + '.docx';
const frame = $('#admin-doc-frame');
frame.innerHTML = '';
if (adminDocsEditorInstance) {
try { adminDocsEditorInstance.destroyEditor(); } catch { /* ignore */ }
adminDocsEditorInstance = null;
}
const { onlyofficeUrl, config } = await api(`/api/docs/bykey/${key}/editor-config`).catch(() => null) || {};
if (!onlyofficeUrl || !config) {
frame.innerHTML = `<div class="docs-error">Could not load document config.</div>`;
return;
}
// Parent views in read mode
config.editorConfig = config.editorConfig || {};
config.editorConfig.mode = 'view';
function initEditor() {
adminDocsEditorInstance = new window.DocsAPI.DocEditor('admin-doc-frame', config);
}
if (window.DocsAPI) {
initEditor();
} else {
const s = document.createElement('script');
s.src = `${onlyofficeUrl}/web-apps/apps/api/documents/api.js`;
s.onload = initEditor;
s.onerror = () => { frame.innerHTML = `<div class="docs-error">Could not reach OnlyOffice server.</div>`; };
document.head.appendChild(s);
}
}
document.getElementById('btn-admin-doc-back')?.addEventListener('click', () => {
if (adminDocsEditorInstance) {
try { adminDocsEditorInstance.destroyEditor(); } catch { /* ignore */ }
adminDocsEditorInstance = null;
}
$('#admin-doc-frame').innerHTML = '';
$('#admin-doc-viewer').hidden = true;
$('#admin-docs-list').hidden = false;
loadAdminDocs();
});
+2
View File
@@ -2571,6 +2571,7 @@ async function openDocEditor(id, key, title) {
$('#btn-docs-submit').disabled = false;
$('#btn-docs-submit').textContent = 'Submit to Parent ✓';
document.body.classList.add('docs-editor-open');
const frame = $('#docs-editor-frame');
frame.innerHTML = '';
if (docsEditorInstance) {
@@ -2626,6 +2627,7 @@ $('#docs-new-form').addEventListener('submit', async (e) => {
});
$('#btn-docs-back').addEventListener('click', () => {
document.body.classList.remove('docs-editor-open');
if (docsEditorInstance) {
try { docsEditorInstance.destroyEditor(); } catch { /* ignore */ }
docsEditorInstance = null;
+12
View File
@@ -2213,6 +2213,10 @@ body.bg-bubbles {
display: flex; flex-direction: column;
background: #fff;
}
body.docs-editor-open .kid-hero,
body.docs-editor-open .kid-nav,
body.docs-editor-open .page-confetti,
body.docs-editor-open .tab-panel { display: none !important; }
#docs-editor-frame { flex: 1; min-height: 0; }
#docs-editor-frame iframe { width: 100%; height: 100%; border: none; display: block; }
@@ -2243,3 +2247,11 @@ body.bg-bubbles {
.docs-error { padding: 1.5rem; color: var(--muted); font-size: .9rem; }
.docs-error code { background: var(--surface); padding: .1rem .4rem; border-radius: 4px; }
/* Admin doc viewer */
#admin-doc-viewer {
position: fixed; inset: 0; z-index: 10000;
display: flex; flex-direction: column; background: #fff;
}
#admin-doc-frame { flex: 1; min-height: 0; }
#admin-doc-frame iframe { width: 100%; height: 100%; border: none; display: block; }
+14
View File
@@ -2546,6 +2546,20 @@ app.get('/api/docs/config', (req, res) => {
res.json({ onlyofficeUrl: ONLYOFFICE_URL, serverUrl: SERVER_URL });
});
// Returns a fully-signed OnlyOffice editor config — lookup by doc key (for admin)
app.get('/api/docs/bykey/:key/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.doc_key = ?`).get(req.params.key);
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: false, download: true, print: true } },
editorConfig: { user: { id: 'parent', name: 'Parent' }, mode: 'view', lang: 'en', customization: { 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 });
});
// 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);