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:
Maxim Dolgolyov
2026-04-14 20:17:25 +03:00
parent ba20a76839
commit 89ba25cd20
7 changed files with 292 additions and 6 deletions
+130 -6
View File
@@ -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; }
</style>
<script src="https://cdn.jsdelivr.net/npm/lucide@0.469.0/dist/umd/lucide.min.js"></script>
</head>
@@ -1189,6 +1222,20 @@
<div class="stat-ring" id="sr-streak"></div>
<div class="stat-ring" id="sr-pending"></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 class="container">
@@ -1701,7 +1748,7 @@
<div class="lb-xp">${u.sort_xp || u.xp || 0} XP</div>
</div>`;
}).join('');
document.getElementById('lb-section').style.display = '';
showWidget('lb-section');
document.getElementById('lb-title').innerHTML = lci('trophy', 18) + ' Рейтинг';
} catch { /* silent */ }
}
@@ -1749,7 +1796,7 @@
</div>
</div>`;
}).join('');
document.getElementById('ch-section').style.display = '';
showWidget('ch-section');
document.getElementById('ch-title').innerHTML = lci('target', 18) + ' Испытания недели';
} catch {}
}
@@ -2804,7 +2851,7 @@
Сданных работ пока нет.<br>
<span style="font-size:0.74rem">Когда учитель назначит задание с прикреплением файла, вы сможете сдать его прямо здесь.</span>
</div>`;
wrap.style.display = '';
showWidget('w-my-subs');
reIcons();
return;
}
@@ -2824,7 +2871,7 @@
</div>`;
}).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();