feat: user preferences sync — server-side storage, whiteboard defaults, dashboard widget visibility
- New table `user_preferences` (user_id PK, JSON blob, updated_at) - GET/PATCH/DELETE /api/preferences with deep-merge UPSERT - LS.prefs singleton in api.js: dot-notation get/set, debounced flush (1.5s), server sync - classroom.html: load wb.color/width/lineStyle/theme from prefs on init; save on change - dashboard.html: widget configurator panel (gear button) — toggle visibility per-user, persisted server-side Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,52 @@
|
|||||||
|
const db = require('../db/db');
|
||||||
|
|
||||||
|
// Recursive deep merge: values from `patch` override `base`, objects are merged
|
||||||
|
function deepMerge(base, patch) {
|
||||||
|
const result = Object.assign({}, base);
|
||||||
|
for (const key of Object.keys(patch)) {
|
||||||
|
if (
|
||||||
|
patch[key] !== null &&
|
||||||
|
typeof patch[key] === 'object' &&
|
||||||
|
!Array.isArray(patch[key]) &&
|
||||||
|
typeof result[key] === 'object' &&
|
||||||
|
result[key] !== null &&
|
||||||
|
!Array.isArray(result[key])
|
||||||
|
) {
|
||||||
|
result[key] = deepMerge(result[key], patch[key]);
|
||||||
|
} else {
|
||||||
|
result[key] = patch[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── GET /api/preferences ────────────────────────────────────────────────── */
|
||||||
|
function getPreferences(req, res) {
|
||||||
|
const row = db.prepare('SELECT data FROM user_preferences WHERE user_id = ?').get(req.user.id);
|
||||||
|
res.json(JSON.parse(row?.data || '{}'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── PATCH /api/preferences ──────────────────────────────────────────────── */
|
||||||
|
function patchPreferences(req, res) {
|
||||||
|
if (!req.body || typeof req.body !== 'object' || Array.isArray(req.body)) {
|
||||||
|
return res.status(400).json({ error: 'Body must be a JSON object' });
|
||||||
|
}
|
||||||
|
const current = JSON.parse(
|
||||||
|
db.prepare('SELECT data FROM user_preferences WHERE user_id = ?').get(req.user.id)?.data || '{}'
|
||||||
|
);
|
||||||
|
const merged = deepMerge(current, req.body);
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO user_preferences (user_id, data, updated_at)
|
||||||
|
VALUES (?, ?, datetime('now'))
|
||||||
|
ON CONFLICT(user_id) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at
|
||||||
|
`).run(req.user.id, JSON.stringify(merged));
|
||||||
|
res.json(merged);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── DELETE /api/preferences ─────────────────────────────────────────────── */
|
||||||
|
function resetPreferences(req, res) {
|
||||||
|
db.prepare('DELETE FROM user_preferences WHERE user_id = ?').run(req.user.id);
|
||||||
|
res.json({});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { getPreferences, patchPreferences, resetPreferences };
|
||||||
@@ -2898,4 +2898,13 @@ db.exec(`
|
|||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
db.exec('CREATE INDEX IF NOT EXISTS idx_geo_subs_task ON geometry_submissions(task_id)');
|
db.exec('CREATE INDEX IF NOT EXISTS idx_geo_subs_task ON geometry_submissions(task_id)');
|
||||||
|
|
||||||
|
// User preferences (server-synced: whiteboard defaults, dashboard visibility, etc.)
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS user_preferences (
|
||||||
|
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
data TEXT NOT NULL DEFAULT '{}',
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
)
|
||||||
|
`);
|
||||||
db.exec('CREATE INDEX IF NOT EXISTS idx_geo_subs_student ON geometry_submissions(student_id)');
|
db.exec('CREATE INDEX IF NOT EXISTS idx_geo_subs_student ON geometry_submissions(student_id)');
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const { authMiddleware } = require('../middleware/auth');
|
||||||
|
const { getPreferences, patchPreferences, resetPreferences } = require('../controllers/preferencesController');
|
||||||
|
|
||||||
|
router.get('/', authMiddleware, getPreferences);
|
||||||
|
router.patch('/', authMiddleware, patchPreferences);
|
||||||
|
router.delete('/', authMiddleware, resetPreferences);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -143,6 +143,7 @@ app.use('/api/bookmarks', bookmarkRoutes);
|
|||||||
app.use('/api/search', searchRoutes);
|
app.use('/api/search', searchRoutes);
|
||||||
app.use('/api/flashcards', flashcardRoutes);
|
app.use('/api/flashcards', flashcardRoutes);
|
||||||
app.use('/api/settings', settingsRoutes);
|
app.use('/api/settings', settingsRoutes);
|
||||||
|
app.use('/api/preferences', require('./routes/preferences'));
|
||||||
app.use('/api/analytics', analyticsRoutes);
|
app.use('/api/analytics', analyticsRoutes);
|
||||||
app.use('/api/live', liveRoutes);
|
app.use('/api/live', liveRoutes);
|
||||||
app.use('/api/classroom/guest', guestClassroomRoutes); // public — MUST be before /api/classroom
|
app.use('/api/classroom/guest', guestClassroomRoutes); // public — MUST be before /api/classroom
|
||||||
|
|||||||
@@ -4072,6 +4072,36 @@
|
|||||||
// Sync board theme selector to whiteboard default
|
// Sync board theme selector to whiteboard default
|
||||||
{ const sel = document.getElementById('wb-theme-select'); if (sel) sel.value = 'chalkboard'; }
|
{ const sel = document.getElementById('wb-theme-select'); if (sel) sel.value = 'chalkboard'; }
|
||||||
|
|
||||||
|
// Apply saved whiteboard defaults from user preferences
|
||||||
|
if (canEdit && LS.prefs) {
|
||||||
|
LS.prefs.init().then(() => {
|
||||||
|
const wb = LS.prefs.get('wb', {});
|
||||||
|
if (wb.color) {
|
||||||
|
_wb.setColor(wb.color);
|
||||||
|
document.querySelectorAll('.cr-color-btn').forEach(b => b.classList.remove('active'));
|
||||||
|
const match = document.querySelector(`.cr-color-btn[data-color="${wb.color}"]`);
|
||||||
|
if (match) match.classList.add('active');
|
||||||
|
}
|
||||||
|
if (wb.width) {
|
||||||
|
_wb.setWidth(wb.width);
|
||||||
|
document.querySelectorAll('.cr-width-btn').forEach(b => b.classList.remove('active'));
|
||||||
|
const wBtn = document.getElementById(`wb-w${wb.width}`);
|
||||||
|
if (wBtn) wBtn.classList.add('active');
|
||||||
|
}
|
||||||
|
if (wb.lineStyle) {
|
||||||
|
_wb.setLineStyle(wb.lineStyle);
|
||||||
|
document.querySelectorAll('.cr-linestyle-btn').forEach(b => b.classList.remove('active'));
|
||||||
|
const lBtn = document.getElementById(`wb-ls-${wb.lineStyle}`);
|
||||||
|
if (lBtn) lBtn.classList.add('active');
|
||||||
|
}
|
||||||
|
if (wb.theme) {
|
||||||
|
_wb.setBoardTheme(wb.theme);
|
||||||
|
const sel = document.getElementById('wb-theme-select');
|
||||||
|
if (sel) sel.value = wb.theme;
|
||||||
|
}
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
// show/hide toolbar, thumbs panel, and readonly label
|
// show/hide toolbar, thumbs panel, and readonly label
|
||||||
document.getElementById('cr-toolbar').style.display = canEdit ? 'flex' : 'none';
|
document.getElementById('cr-toolbar').style.display = canEdit ? 'flex' : 'none';
|
||||||
document.getElementById('wb-thumbs-panel').style.display = isTeacher ? 'flex' : 'none';
|
document.getElementById('wb-thumbs-panel').style.display = isTeacher ? 'flex' : 'none';
|
||||||
@@ -4817,6 +4847,7 @@
|
|||||||
if (activeTools.some(t => document.getElementById(`wb-tool-${t}`)?.classList.contains('active')))
|
if (activeTools.some(t => document.getElementById(`wb-tool-${t}`)?.classList.contains('active')))
|
||||||
wbSetTool('pencil');
|
wbSetTool('pencil');
|
||||||
else wbUpdateCursorStyle();
|
else wbUpdateCursorStyle();
|
||||||
|
if (LS.prefs) LS.prefs.set('wb.color', color);
|
||||||
}
|
}
|
||||||
|
|
||||||
function wbSetWidth(px, btn) {
|
function wbSetWidth(px, btn) {
|
||||||
@@ -4825,6 +4856,7 @@
|
|||||||
document.querySelectorAll('.cr-width-btn').forEach(b => b.classList.remove('active'));
|
document.querySelectorAll('.cr-width-btn').forEach(b => b.classList.remove('active'));
|
||||||
btn.classList.add('active');
|
btn.classList.add('active');
|
||||||
wbUpdateCursorStyle();
|
wbUpdateCursorStyle();
|
||||||
|
if (LS.prefs) LS.prefs.set('wb.width', px);
|
||||||
}
|
}
|
||||||
|
|
||||||
function wbSetLineStyle(style, btn) {
|
function wbSetLineStyle(style, btn) {
|
||||||
@@ -4832,6 +4864,7 @@
|
|||||||
_wb.setLineStyle(style);
|
_wb.setLineStyle(style);
|
||||||
document.querySelectorAll('.cr-linestyle-btn').forEach(b => b.classList.remove('active'));
|
document.querySelectorAll('.cr-linestyle-btn').forEach(b => b.classList.remove('active'));
|
||||||
if (btn) btn.classList.add('active');
|
if (btn) btn.classList.add('active');
|
||||||
|
if (LS.prefs) LS.prefs.set('wb.lineStyle', style);
|
||||||
}
|
}
|
||||||
|
|
||||||
function wbSetOpacity(val) {
|
function wbSetOpacity(val) {
|
||||||
@@ -5478,6 +5511,7 @@
|
|||||||
// Update selector if called programmatically
|
// Update selector if called programmatically
|
||||||
const sel = document.getElementById('wb-theme-select');
|
const sel = document.getElementById('wb-theme-select');
|
||||||
if (sel) sel.value = theme;
|
if (sel) sel.value = theme;
|
||||||
|
if (LS.prefs) LS.prefs.set('wb.theme', theme);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Page context menu ── */
|
/* ── Page context menu ── */
|
||||||
@@ -6434,6 +6468,7 @@
|
|||||||
setTimeout(() => { if (input) input.style.outline = ''; }, 300);
|
setTimeout(() => { if (input) input.style.outline = ''; }, 300);
|
||||||
if (['eraser','laser'].some(t => document.getElementById(`wb-tool-${t}`)?.classList.contains('active')))
|
if (['eraser','laser'].some(t => document.getElementById(`wb-tool-${t}`)?.classList.contains('active')))
|
||||||
wbSetTool('pencil');
|
wbSetTool('pencil');
|
||||||
|
if (LS.prefs) LS.prefs.set('wb.color', input.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
function wbToggleFullscreen() {
|
function wbToggleFullscreen() {
|
||||||
|
|||||||
+130
-6
@@ -1167,6 +1167,39 @@
|
|||||||
/* Heatmap popup always right-anchored */
|
/* Heatmap popup always right-anchored */
|
||||||
.hm-day-popup { right: 10px !important; left: auto !important; top: auto !important; }
|
.hm-day-popup { right: 10px !important; left: auto !important; top: auto !important; }
|
||||||
}
|
}
|
||||||
|
/* ── Widget configurator ── */
|
||||||
|
.dash-cfg-btn {
|
||||||
|
margin-left: auto; flex-shrink: 0;
|
||||||
|
display: none; /* shown only for students via JS */
|
||||||
|
align-items: center; gap: 6px;
|
||||||
|
padding: 6px 12px; border-radius: 8px;
|
||||||
|
background: rgba(155,93,229,0.13); border: 1px solid rgba(155,93,229,0.3);
|
||||||
|
color: #9B5DE5; font-size: 0.78rem; font-weight: 600; cursor: pointer;
|
||||||
|
transition: background .15s;
|
||||||
|
}
|
||||||
|
.dash-cfg-btn:hover { background: rgba(155,93,229,0.22); }
|
||||||
|
.dash-cfg-btn svg { width: 14px; height: 14px; flex-shrink: 0; }
|
||||||
|
.dash-cfg-panel {
|
||||||
|
display: none; position: absolute; top: calc(100% + 6px); right: 0;
|
||||||
|
background: #1e1b2e; border: 1px solid rgba(155,93,229,0.3);
|
||||||
|
border-radius: 12px; padding: 12px; min-width: 210px; z-index: 50;
|
||||||
|
box-shadow: 0 8px 24px rgba(0,0,0,.35);
|
||||||
|
}
|
||||||
|
.dash-cfg-panel.open { display: block; }
|
||||||
|
.dash-cfg-title {
|
||||||
|
font-size: 0.72rem; font-weight: 700; color: #9B5DE5;
|
||||||
|
text-transform: uppercase; letter-spacing: .5px; margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.dash-cfg-row {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
padding: 6px 4px; border-radius: 6px; cursor: pointer;
|
||||||
|
}
|
||||||
|
.dash-cfg-row:hover { background: rgba(255,255,255,.05); }
|
||||||
|
.dash-cfg-row label {
|
||||||
|
font-size: 0.82rem; color: #e2e8f0; cursor: pointer; flex: 1;
|
||||||
|
}
|
||||||
|
.dash-cfg-row input[type=checkbox] { accent-color: #9B5DE5; width: 15px; height: 15px; cursor: pointer; }
|
||||||
|
.dash-cfg-wrap { position: relative; }
|
||||||
</style>
|
</style>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/lucide@0.469.0/dist/umd/lucide.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/lucide@0.469.0/dist/umd/lucide.min.js"></script>
|
||||||
</head>
|
</head>
|
||||||
@@ -1189,6 +1222,20 @@
|
|||||||
<div class="stat-ring" id="sr-streak"></div>
|
<div class="stat-ring" id="sr-streak"></div>
|
||||||
<div class="stat-ring" id="sr-pending"></div>
|
<div class="stat-ring" id="sr-pending"></div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="dash-cfg-wrap" id="dash-cfg-wrap">
|
||||||
|
<button class="dash-cfg-btn" id="dash-cfg-btn" onclick="toggleDashCfg(event)" title="Настроить виджеты">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
|
||||||
|
Виджеты
|
||||||
|
</button>
|
||||||
|
<div class="dash-cfg-panel" id="dash-cfg-panel">
|
||||||
|
<div class="dash-cfg-title">Показывать виджеты</div>
|
||||||
|
<div class="dash-cfg-row" onclick="toggleDashWidget('lb-section',this)"><label>Рейтинг</label><input type="checkbox" data-widget="lb-section" checked></div>
|
||||||
|
<div class="dash-cfg-row" onclick="toggleDashWidget('ch-section',this)"><label>Испытания недели</label><input type="checkbox" data-widget="ch-section" checked></div>
|
||||||
|
<div class="dash-cfg-row" onclick="toggleDashWidget('stats-section',this)"><label>Статистика</label><input type="checkbox" data-widget="stats-section" checked></div>
|
||||||
|
<div class="dash-cfg-row" onclick="toggleDashWidget('w-my-subs',this)"><label>Мои сдачи</label><input type="checkbox" data-widget="w-my-subs" checked></div>
|
||||||
|
<div class="dash-cfg-row" onclick="toggleDashWidget('w-theory-progress',this)"><label>Теория</label><input type="checkbox" data-widget="w-theory-progress" checked></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="container">
|
<div class="container">
|
||||||
@@ -1701,7 +1748,7 @@
|
|||||||
<div class="lb-xp">${u.sort_xp || u.xp || 0} XP</div>
|
<div class="lb-xp">${u.sort_xp || u.xp || 0} XP</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
document.getElementById('lb-section').style.display = '';
|
showWidget('lb-section');
|
||||||
document.getElementById('lb-title').innerHTML = lci('trophy', 18) + ' Рейтинг';
|
document.getElementById('lb-title').innerHTML = lci('trophy', 18) + ' Рейтинг';
|
||||||
} catch { /* silent */ }
|
} catch { /* silent */ }
|
||||||
}
|
}
|
||||||
@@ -1749,7 +1796,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
document.getElementById('ch-section').style.display = '';
|
showWidget('ch-section');
|
||||||
document.getElementById('ch-title').innerHTML = lci('target', 18) + ' Испытания недели';
|
document.getElementById('ch-title').innerHTML = lci('target', 18) + ' Испытания недели';
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
@@ -2804,7 +2851,7 @@
|
|||||||
Сданных работ пока нет.<br>
|
Сданных работ пока нет.<br>
|
||||||
<span style="font-size:0.74rem">Когда учитель назначит задание с прикреплением файла, вы сможете сдать его прямо здесь.</span>
|
<span style="font-size:0.74rem">Когда учитель назначит задание с прикреплением файла, вы сможете сдать его прямо здесь.</span>
|
||||||
</div>`;
|
</div>`;
|
||||||
wrap.style.display = '';
|
showWidget('w-my-subs');
|
||||||
reIcons();
|
reIcons();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -2824,7 +2871,7 @@
|
|||||||
</div>`;
|
</div>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
wrap.style.display = '';
|
showWidget('w-my-subs');
|
||||||
reIcons();
|
reIcons();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3136,7 +3183,7 @@
|
|||||||
const active = (Array.isArray(courses) ? courses : [])
|
const active = (Array.isArray(courses) ? courses : [])
|
||||||
.filter(c => c.lessonCount > 0 && c.doneCount < c.lessonCount);
|
.filter(c => c.lessonCount > 0 && c.doneCount < c.lessonCount);
|
||||||
if (!active.length) { w.style.display = 'none'; return; }
|
if (!active.length) { w.style.display = 'none'; return; }
|
||||||
w.style.display = '';
|
showWidget('w-theory-progress');
|
||||||
const SUBJ_LABEL = { bio:'Биология', chem:'Химия', math:'Математика', phys:'Физика', other:'Задание' };
|
const SUBJ_LABEL = { bio:'Биология', chem:'Химия', math:'Математика', phys:'Физика', other:'Задание' };
|
||||||
document.getElementById('theory-progress-grid').innerHTML = active.slice(0, 6).map(c => {
|
document.getElementById('theory-progress-grid').innerHTML = active.slice(0, 6).map(c => {
|
||||||
const pct = Math.round(c.doneCount / c.lessonCount * 100);
|
const pct = Math.round(c.doneCount / c.lessonCount * 100);
|
||||||
@@ -3485,6 +3532,82 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ══ Load all student widgets ═════════════════════════════════════ */
|
/* ══ Load all student widgets ═════════════════════════════════════ */
|
||||||
|
/* Show a widget section, but respect the cfg-hidden flag */
|
||||||
|
function showWidget(id) {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (!el || el.dataset.cfgHidden) return;
|
||||||
|
el.style.display = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Dashboard widget visibility ──────────────────────────────────── */
|
||||||
|
const _DASH_WIDGETS = [
|
||||||
|
{ id: 'lb-section', label: 'Рейтинг' },
|
||||||
|
{ id: 'ch-section', label: 'Испытания недели' },
|
||||||
|
{ id: 'stats-section', label: 'Статистика' },
|
||||||
|
{ id: 'w-my-subs', label: 'Мои сдачи' },
|
||||||
|
{ id: 'w-theory-progress',label: 'Теория' },
|
||||||
|
];
|
||||||
|
|
||||||
|
async function applyDashboardPrefs() {
|
||||||
|
if (isTeacher) return;
|
||||||
|
await LS.prefs.init();
|
||||||
|
const hidden = LS.prefs.get('dashboard.hidden', []);
|
||||||
|
// Show cfg button for students
|
||||||
|
const cfgBtn = document.getElementById('dash-cfg-btn');
|
||||||
|
if (cfgBtn) cfgBtn.style.display = 'flex';
|
||||||
|
// Sync checkboxes + visibility
|
||||||
|
_DASH_WIDGETS.forEach(w => {
|
||||||
|
const isHidden = hidden.includes(w.id);
|
||||||
|
// Update checkbox state
|
||||||
|
const cb = document.querySelector(`#dash-cfg-panel input[data-widget="${w.id}"]`);
|
||||||
|
if (cb) cb.checked = !isHidden;
|
||||||
|
// Apply visibility only if the element is already visible (don't override display:none set by data loaders)
|
||||||
|
if (isHidden) {
|
||||||
|
const el = document.getElementById(w.id);
|
||||||
|
if (el) el.dataset.cfgHidden = '1';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function _saveDashHidden() {
|
||||||
|
const hidden = _DASH_WIDGETS
|
||||||
|
.filter(w => document.querySelector(`#dash-cfg-panel input[data-widget="${w.id}"]`)?.checked === false)
|
||||||
|
.map(w => w.id);
|
||||||
|
LS.prefs.set('dashboard.hidden', hidden);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleDashWidget(widgetId, row) {
|
||||||
|
// Don't let row click double-trigger when checkbox itself is clicked
|
||||||
|
const cb = row.querySelector('input[type=checkbox]');
|
||||||
|
if (!cb) return;
|
||||||
|
// If row was clicked (not the checkbox directly), toggle the checkbox
|
||||||
|
if (event && event.target !== cb) cb.checked = !cb.checked;
|
||||||
|
const el = document.getElementById(widgetId);
|
||||||
|
if (el) {
|
||||||
|
if (cb.checked) {
|
||||||
|
delete el.dataset.cfgHidden;
|
||||||
|
// Only show if data loader hasn't hidden it for another reason
|
||||||
|
if (!el.dataset.loaderHidden) el.style.display = '';
|
||||||
|
} else {
|
||||||
|
el.dataset.cfgHidden = '1';
|
||||||
|
el.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_saveDashHidden();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleDashCfg(e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
const panel = document.getElementById('dash-cfg-panel');
|
||||||
|
panel.classList.toggle('open');
|
||||||
|
if (panel.classList.contains('open')) {
|
||||||
|
setTimeout(() => document.addEventListener('click', _closeDashCfg, { once: true }), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function _closeDashCfg() {
|
||||||
|
document.getElementById('dash-cfg-panel')?.classList.remove('open');
|
||||||
|
}
|
||||||
|
|
||||||
async function loadStudentWidgets() {
|
async function loadStudentWidgets() {
|
||||||
if (isTeacher) return;
|
if (isTeacher) return;
|
||||||
try {
|
try {
|
||||||
@@ -3513,7 +3636,7 @@
|
|||||||
try {
|
try {
|
||||||
const data = await LS.getStudentStats();
|
const data = await LS.getStudentStats();
|
||||||
if (!data || (!data.totals.sessions && !data.courseProgress.length)) return;
|
if (!data || (!data.totals.sessions && !data.courseProgress.length)) return;
|
||||||
document.getElementById('stats-section').style.display = '';
|
showWidget('stats-section');
|
||||||
|
|
||||||
// Summary chips
|
// Summary chips
|
||||||
const chips = document.getElementById('stats-chips');
|
const chips = document.getElementById('stats-chips');
|
||||||
@@ -3616,6 +3739,7 @@
|
|||||||
loadChallenges();
|
loadChallenges();
|
||||||
loadStudentWidgets();
|
loadStudentWidgets();
|
||||||
loadDashboardStats();
|
loadDashboardStats();
|
||||||
|
applyDashboardPrefs();
|
||||||
}
|
}
|
||||||
LS.notif.init();
|
LS.notif.init();
|
||||||
|
|
||||||
|
|||||||
@@ -641,6 +641,60 @@ async function biochemGetSaved() { return req('GET', '/biochem/saved');
|
|||||||
async function biochemSave(atoms,bonds,name){ return req('POST','/biochem/saved',{atoms,bonds,name}); }
|
async function biochemSave(atoms,bonds,name){ return req('POST','/biochem/saved',{atoms,bonds,name}); }
|
||||||
async function biochemDeleteSaved(id) { return req('DELETE',`/biochem/saved/${id}`); }
|
async function biochemDeleteSaved(id) { return req('DELETE',`/biochem/saved/${id}`); }
|
||||||
|
|
||||||
|
/* ── LS.prefs — server-synced user preferences ──────────────────────────
|
||||||
|
Keys use dot-notation: 'wb.color', 'dashboard.hidden', etc.
|
||||||
|
Writes are debounced (1.5 s) before flushing to /api/preferences.
|
||||||
|
─────────────────────────────────────────────────────────────────────── */
|
||||||
|
const _prefsCache = {};
|
||||||
|
let _prefsDirty = false;
|
||||||
|
let _prefsTimer = null;
|
||||||
|
|
||||||
|
async function _prefsLoad() {
|
||||||
|
if (!isLoggedIn()) return;
|
||||||
|
try {
|
||||||
|
const data = await apiFetch('/api/preferences', { method: 'GET' });
|
||||||
|
Object.assign(_prefsCache, data);
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _prefsFlush() {
|
||||||
|
if (!_prefsDirty) return;
|
||||||
|
_prefsDirty = false;
|
||||||
|
if (!isLoggedIn()) return;
|
||||||
|
apiFetch('/api/preferences', {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(_prefsCache),
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
const lsPrefs = {
|
||||||
|
get(key, def) {
|
||||||
|
const parts = key.split('.');
|
||||||
|
let cur = _prefsCache;
|
||||||
|
for (const k of parts) {
|
||||||
|
if (cur === undefined || cur === null) return def;
|
||||||
|
cur = cur[k];
|
||||||
|
}
|
||||||
|
return cur !== undefined ? cur : def;
|
||||||
|
},
|
||||||
|
set(key, value) {
|
||||||
|
const parts = key.split('.');
|
||||||
|
let cur = _prefsCache;
|
||||||
|
for (let i = 0; i < parts.length - 1; i++) {
|
||||||
|
if (!cur[parts[i]] || typeof cur[parts[i]] !== 'object' || Array.isArray(cur[parts[i]])) {
|
||||||
|
cur[parts[i]] = {};
|
||||||
|
}
|
||||||
|
cur = cur[parts[i]];
|
||||||
|
}
|
||||||
|
cur[parts[parts.length - 1]] = value;
|
||||||
|
_prefsDirty = true;
|
||||||
|
clearTimeout(_prefsTimer);
|
||||||
|
_prefsTimer = setTimeout(_prefsFlush, 1500);
|
||||||
|
},
|
||||||
|
async init() { await _prefsLoad(); },
|
||||||
|
flush() { clearTimeout(_prefsTimer); _prefsFlush(); },
|
||||||
|
};
|
||||||
|
|
||||||
window.LS = {
|
window.LS = {
|
||||||
getToken, setToken, removeToken,
|
getToken, setToken, removeToken,
|
||||||
getUser, setUser, removeUser,
|
getUser, setUser, removeUser,
|
||||||
@@ -698,6 +752,7 @@ window.LS = {
|
|||||||
biochemGetElements, biochemGetMolecules, biochemGetMolecule, biochemValidate,
|
biochemGetElements, biochemGetMolecules, biochemGetMolecule, biochemValidate,
|
||||||
biochemGetReactions, biochemGetChallenges, biochemSolveChallenge,
|
biochemGetReactions, biochemGetChallenges, biochemSolveChallenge,
|
||||||
biochemGetSaved, biochemSave, biochemDeleteSaved,
|
biochemGetSaved, biochemSave, biochemDeleteSaved,
|
||||||
|
prefs: lsPrefs,
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ═══════════════════════════════════════════════════════════════════════
|
/* ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|||||||
Reference in New Issue
Block a user