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 @@
+