diff --git a/public/admin.html b/public/admin.html
index 5faa962..55cc6d5 100644
--- a/public/admin.html
+++ b/public/admin.html
@@ -57,6 +57,9 @@
+
+
+
+ Submitted Documents
+
+
+
+
+
Settings
diff --git a/public/admin.js b/public/admin.js
index eb3c6f9..8465c4b 100644
--- a/public/admin.js
+++ b/public/admin.js
@@ -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 = 'No submitted documents yet.
';
+ return;
+ }
+ el.innerHTML = docs.map((d) => `
+
+
π
+
+
${esc(d.title)}${!d.parent_read ? ' New' : ''}
+
${esc(d.student_name)} Β· Submitted ${new Date(d.submitted_at + 'Z').toLocaleDateString()}
+
+
+ `).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 = `Could not load document config.
`;
+ 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 = `Could not reach OnlyOffice server.
`; };
+ 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();
+});
diff --git a/public/app.js b/public/app.js
index e8d9511..8bd9831 100644
--- a/public/app.js
+++ b/public/app.js
@@ -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;
diff --git a/public/styles.css b/public/styles.css
index 2f1504a..c01d190 100644
--- a/public/styles.css
+++ b/public/styles.css
@@ -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; }
diff --git a/server.js b/server.js
index c51c097..def4550 100644
--- a/server.js
+++ b/server.js
@@ -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);