From 89ba25cd20098066f3866ec3a1edb25cabee49c1 Mon Sep 17 00:00:00 2001 From: Maxim Dolgolyov Date: Tue, 14 Apr 2026 20:17:25 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20user=20preferences=20sync=20=E2=80=94?= =?UTF-8?q?=20server-side=20storage,=20whiteboard=20defaults,=20dashboard?= =?UTF-8?q?=20widget=20visibility?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../src/controllers/preferencesController.js | 52 +++++++ backend/src/db/migrate.js | 9 ++ backend/src/routes/preferences.js | 10 ++ backend/src/server.js | 1 + frontend/classroom.html | 35 +++++ frontend/dashboard.html | 136 +++++++++++++++++- js/api.js | 55 +++++++ 7 files changed, 292 insertions(+), 6 deletions(-) create mode 100644 backend/src/controllers/preferencesController.js create mode 100644 backend/src/routes/preferences.js diff --git a/backend/src/controllers/preferencesController.js b/backend/src/controllers/preferencesController.js new file mode 100644 index 0000000..7149634 --- /dev/null +++ b/backend/src/controllers/preferencesController.js @@ -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 }; diff --git a/backend/src/db/migrate.js b/backend/src/db/migrate.js index c0891f9..9927cc4 100644 --- a/backend/src/db/migrate.js +++ b/backend/src/db/migrate.js @@ -2898,4 +2898,13 @@ db.exec(` ) `); 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)'); diff --git a/backend/src/routes/preferences.js b/backend/src/routes/preferences.js new file mode 100644 index 0000000..b354892 --- /dev/null +++ b/backend/src/routes/preferences.js @@ -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; diff --git a/backend/src/server.js b/backend/src/server.js index 781c167..1209445 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -143,6 +143,7 @@ app.use('/api/bookmarks', bookmarkRoutes); app.use('/api/search', searchRoutes); app.use('/api/flashcards', flashcardRoutes); app.use('/api/settings', settingsRoutes); +app.use('/api/preferences', require('./routes/preferences')); app.use('/api/analytics', analyticsRoutes); app.use('/api/live', liveRoutes); app.use('/api/classroom/guest', guestClassroomRoutes); // public — MUST be before /api/classroom diff --git a/frontend/classroom.html b/frontend/classroom.html index a81478e..e06f5a7 100644 --- a/frontend/classroom.html +++ b/frontend/classroom.html @@ -4072,6 +4072,36 @@ // Sync board theme selector to whiteboard default { 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 document.getElementById('cr-toolbar').style.display = canEdit ? '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'))) wbSetTool('pencil'); else wbUpdateCursorStyle(); + if (LS.prefs) LS.prefs.set('wb.color', color); } function wbSetWidth(px, btn) { @@ -4825,6 +4856,7 @@ document.querySelectorAll('.cr-width-btn').forEach(b => b.classList.remove('active')); btn.classList.add('active'); wbUpdateCursorStyle(); + if (LS.prefs) LS.prefs.set('wb.width', px); } function wbSetLineStyle(style, btn) { @@ -4832,6 +4864,7 @@ _wb.setLineStyle(style); document.querySelectorAll('.cr-linestyle-btn').forEach(b => b.classList.remove('active')); if (btn) btn.classList.add('active'); + if (LS.prefs) LS.prefs.set('wb.lineStyle', style); } function wbSetOpacity(val) { @@ -5478,6 +5511,7 @@ // Update selector if called programmatically const sel = document.getElementById('wb-theme-select'); if (sel) sel.value = theme; + if (LS.prefs) LS.prefs.set('wb.theme', theme); } /* ── Page context menu ── */ @@ -6434,6 +6468,7 @@ setTimeout(() => { if (input) input.style.outline = ''; }, 300); if (['eraser','laser'].some(t => document.getElementById(`wb-tool-${t}`)?.classList.contains('active'))) wbSetTool('pencil'); + if (LS.prefs) LS.prefs.set('wb.color', input.value); } function wbToggleFullscreen() { diff --git a/frontend/dashboard.html b/frontend/dashboard.html index 44da89c..4c33e4d 100644 --- a/frontend/dashboard.html +++ b/frontend/dashboard.html @@ -1167,6 +1167,39 @@ /* Heatmap popup always right-anchored */ .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; } @@ -1189,6 +1222,20 @@
+
+ +
+
Показывать виджеты
+
+
+
+
+
+
+
@@ -1701,7 +1748,7 @@
${u.sort_xp || u.xp || 0} XP
`; }).join(''); - document.getElementById('lb-section').style.display = ''; + showWidget('lb-section'); document.getElementById('lb-title').innerHTML = lci('trophy', 18) + ' Рейтинг'; } catch { /* silent */ } } @@ -1749,7 +1796,7 @@ `; }).join(''); - document.getElementById('ch-section').style.display = ''; + showWidget('ch-section'); document.getElementById('ch-title').innerHTML = lci('target', 18) + ' Испытания недели'; } catch {} } @@ -2804,7 +2851,7 @@ Сданных работ пока нет.
Когда учитель назначит задание с прикреплением файла, вы сможете сдать его прямо здесь. `; - wrap.style.display = ''; + showWidget('w-my-subs'); reIcons(); return; } @@ -2824,7 +2871,7 @@ `; }).join(''); - wrap.style.display = ''; + showWidget('w-my-subs'); reIcons(); } @@ -3136,7 +3183,7 @@ const active = (Array.isArray(courses) ? courses : []) .filter(c => c.lessonCount > 0 && c.doneCount < c.lessonCount); if (!active.length) { w.style.display = 'none'; return; } - w.style.display = ''; + showWidget('w-theory-progress'); const SUBJ_LABEL = { bio:'Биология', chem:'Химия', math:'Математика', phys:'Физика', other:'Задание' }; document.getElementById('theory-progress-grid').innerHTML = active.slice(0, 6).map(c => { const pct = Math.round(c.doneCount / c.lessonCount * 100); @@ -3485,6 +3532,82 @@ } /* ══ 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() { if (isTeacher) return; try { @@ -3513,7 +3636,7 @@ try { const data = await LS.getStudentStats(); if (!data || (!data.totals.sessions && !data.courseProgress.length)) return; - document.getElementById('stats-section').style.display = ''; + showWidget('stats-section'); // Summary chips const chips = document.getElementById('stats-chips'); @@ -3616,6 +3739,7 @@ loadChallenges(); loadStudentWidgets(); loadDashboardStats(); + applyDashboardPrefs(); } LS.notif.init(); diff --git a/js/api.js b/js/api.js index 85ed86e..05aa804 100644 --- a/js/api.js +++ b/js/api.js @@ -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 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 = { getToken, setToken, removeToken, getUser, setUser, removeUser, @@ -698,6 +752,7 @@ window.LS = { biochemGetElements, biochemGetMolecules, biochemGetMolecule, biochemValidate, biochemGetReactions, biochemGetChallenges, biochemSolveChallenge, biochemGetSaved, biochemSave, biochemDeleteSaved, + prefs: lsPrefs, }; /* ═══════════════════════════════════════════════════════════════════════