Fix editor full-screen and add parent-side doc viewer
Build and Push Docker Image / build-and-push (push) Successful in 45s
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:
1 parent
6a5ac46294
commit
ad3e0bc7be
5 files changed
+128
-2
No files matched your search
@@ -57,6 +57,9 @@
|
|||||||
<button class="nav-pill" data-panel="decks">
|
<button class="nav-pill" data-panel="decks">
|
||||||
<span class="nav-icon">🗂️</span><span class="nav-label">Flashcards</span>
|
<span class="nav-icon">🗂️</span><span class="nav-label">Flashcards</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button class="nav-pill" data-panel="docs">
|
||||||
|
<span class="nav-icon">📝</span><span class="nav-label">Docs</span>
|
||||||
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="admin-sidebar-footer">
|
<div class="admin-sidebar-footer">
|
||||||
<button class="nav-pill" data-panel="settings">
|
<button class="nav-pill" data-panel="settings">
|
||||||
@@ -679,6 +682,23 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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 -->
|
<!-- Settings -->
|
||||||
<section id="panel-settings" class="panel">
|
<section id="panel-settings" class="panel">
|
||||||
<h1>Settings</h1>
|
<h1>Settings</h1>
|
||||||
|
|||||||
+80
-2
@@ -120,7 +120,7 @@ document.querySelectorAll('[data-nav]').forEach((btn) =>
|
|||||||
const PANEL_TITLES = {
|
const PANEL_TITLES = {
|
||||||
dashboard: 'Dashboard', kids: 'Kids', courses: 'Courses', planner: 'Planner',
|
dashboard: 'Dashboard', kids: 'Kids', courses: 'Courses', planner: 'Planner',
|
||||||
grading: 'Grading', messages: 'Messages', gradebook: 'Gradebook',
|
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',
|
'course-detail': 'Course Detail', 'item-editor': 'Item Editor',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -132,7 +132,7 @@ function showPanel(name) {
|
|||||||
const loaders = {
|
const loaders = {
|
||||||
dashboard: loadDashboard, kids: loadKids, courses: loadCourses, planner: loadPlannerPanel,
|
dashboard: loadDashboard, kids: loadKids, courses: loadCourses, planner: loadPlannerPanel,
|
||||||
grading: loadGrading, messages: loadMessages, gradebook: loadGradebookPanel, spelling: loadSpelling, decks: loadDecks,
|
grading: loadGrading, messages: loadMessages, gradebook: loadGradebookPanel, spelling: loadSpelling, decks: loadDecks,
|
||||||
settings: loadSettings,
|
docs: loadAdminDocs, settings: loadSettings,
|
||||||
};
|
};
|
||||||
if (loaders[name]) loaders[name]();
|
if (loaders[name]) loaders[name]();
|
||||||
if (name === 'course-detail') openCourseDetail(currentCourseId);
|
if (name === 'course-detail') openCourseDetail(currentCourseId);
|
||||||
@@ -3290,3 +3290,81 @@ $('#app-settings-form').addEventListener('submit', async (e) => {
|
|||||||
msg('Settings saved.');
|
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();
|
||||||
|
});
|
||||||
@@ -2571,6 +2571,7 @@ async function openDocEditor(id, key, title) {
|
|||||||
$('#btn-docs-submit').disabled = false;
|
$('#btn-docs-submit').disabled = false;
|
||||||
$('#btn-docs-submit').textContent = 'Submit to Parent ✓';
|
$('#btn-docs-submit').textContent = 'Submit to Parent ✓';
|
||||||
|
|
||||||
|
document.body.classList.add('docs-editor-open');
|
||||||
const frame = $('#docs-editor-frame');
|
const frame = $('#docs-editor-frame');
|
||||||
frame.innerHTML = '';
|
frame.innerHTML = '';
|
||||||
if (docsEditorInstance) {
|
if (docsEditorInstance) {
|
||||||
@@ -2626,6 +2627,7 @@ $('#docs-new-form').addEventListener('submit', async (e) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
$('#btn-docs-back').addEventListener('click', () => {
|
$('#btn-docs-back').addEventListener('click', () => {
|
||||||
|
document.body.classList.remove('docs-editor-open');
|
||||||
if (docsEditorInstance) {
|
if (docsEditorInstance) {
|
||||||
try { docsEditorInstance.destroyEditor(); } catch { /* ignore */ }
|
try { docsEditorInstance.destroyEditor(); } catch { /* ignore */ }
|
||||||
docsEditorInstance = null;
|
docsEditorInstance = null;
|
||||||
|
|||||||
@@ -2213,6 +2213,10 @@ body.bg-bubbles {
|
|||||||
display: flex; flex-direction: column;
|
display: flex; flex-direction: column;
|
||||||
background: #fff;
|
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 { flex: 1; min-height: 0; }
|
||||||
#docs-editor-frame iframe { width: 100%; height: 100%; border: none; display: block; }
|
#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 { padding: 1.5rem; color: var(--muted); font-size: .9rem; }
|
||||||
.docs-error code { background: var(--surface); padding: .1rem .4rem; border-radius: 4px; }
|
.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; }
|
||||||
@@ -2546,6 +2546,20 @@ app.get('/api/docs/config', (req, res) => {
|
|||||||
res.json({ onlyofficeUrl: ONLYOFFICE_URL, serverUrl: SERVER_URL });
|
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
|
// Returns a fully-signed OnlyOffice editor config for a specific document
|
||||||
app.get('/api/docs/:id/editor-config', (req, res) => {
|
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);
|
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);
|
||||||
|
|||||||
Reference in new issue
Block a user