polish(admin-dash): avatar pills, skeleton loader, mobile breakpoints, palette kept

- Avatar circles in top/worst-5 tables: initials from name, hsl color from hash of name
- Structural skeleton on first load: 4 shimmer card boxes + 5 row placeholders (replaces
  LS.state.loading spinner for better layout-anchored feedback)
- @media ≤640px: 2-column main grid, hero card reverts to normal size, quick-grid 2-col
- Palette: existing per-card colors (violet/cyan/green/amber) already form a good muted
  hue family with vivid pink/amber for alert cards — kept as is to avoid regression

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-05-17 15:07:18 +03:00
parent 124236db58
commit d1d20c4c86
+61 -2
View File
@@ -79,8 +79,27 @@
.ov-quick-btn { display: flex; align-items: center; gap: 10px; padding: 14px 16px; background: var(--surface); border: 1px solid var(--border); border-radius: 12px; cursor: pointer; font-family: inherit; font-size: 0.88rem; font-weight: 600; color: var(--text); text-align: left; transition: background .12s, border-color .12s, transform .12s; }
.ov-quick-btn:hover { background: rgba(155,93,229,0.06); border-color: rgba(155,93,229,0.3); color: var(--violet); transform: translateY(-1px); }
.ov-quick-btn svg { width: 16px; height: 16px; flex-shrink: 0; }
/* ── avatar pills ───────────────────────────────────────────── */
.ov-avatar { display: inline-flex; align-items: center; justify-content: center; width: 24px; height: 24px; border-radius: 50%; font-size: 10px; font-weight: 700; color: #fff; margin-right: 6px; vertical-align: middle; flex-shrink: 0; }
.ov-cell-user { display: flex; align-items: center; }
/* ── skeleton loader ────────────────────────────────────────── */
@keyframes ov-shimmer { 0%{background-position:-400px 0} 100%{background-position:400px 0} }
.ov-skel-box { border-radius: 12px; background: linear-gradient(90deg,var(--border) 25%,var(--surface) 50%,var(--border) 75%); background-size: 400px 100%; animation: ov-shimmer 1.4s infinite linear; }
.ov-skel-cards { display: grid; grid-template-columns: 2fr 1fr 1fr 1fr; gap: 16px; margin-bottom: 28px; }
.ov-skel-card { height: 110px; }
.ov-skel-rows { display: flex; flex-direction: column; gap: 10px; margin-top: 12px; }
.ov-skel-row { height: 38px; }
/* ── misc ───────────────────────────────────────────────────── */
.ov-empty { padding: 18px; text-align: center; color: var(--text-3); font-size: 0.85rem; }
/* ── mobile breakpoints ─────────────────────────────────────── */
@media (max-width: 640px) {
.ov-grid.ov-grid-main { grid-template-columns: 1fr 1fr; }
.ov-card.hero .ov-card-val { font-size: 1.9rem; }
.ov-card.hero .ov-card-icon { width: 38px; height: 38px; border-radius: 12px; }
.ov-results-grid { grid-template-columns: 1fr; }
.ov-quick-grid { grid-template-columns: 1fr 1fr; }
.ov-skel-cards { grid-template-columns: 1fr 1fr; }
}
`;
document.head.appendChild(s);
}
@@ -129,6 +148,45 @@
return '<div class="ov-subj-bar-track">' + segs + '</div><div class="ov-subj-legend">' + legend + '</div>';
}
/* ── avatar helpers ─────────────────────────────────────────────────── */
function initialsOf(name) {
if (!name) return '?';
return name.trim().split(/\s+/).slice(0, 2).map(function (w) { return w[0] ? w[0].toUpperCase() : ''; }).join('') || '?';
}
function hashHue(str) {
var h = 0;
for (var i = 0; i < (str || '').length; i++) {
h = ((h << 5) - h + (str || '').charCodeAt(i)) | 0;
}
return Math.abs(h) % 360;
}
function avatarHtml(name) {
var hue = hashHue(name);
var bg = 'hsl(' + hue + ',55%,60%)';
return '<span class="ov-avatar" style="background:' + bg + '">' + initialsOf(name) + '</span>';
}
/* ── skeleton loader ────────────────────────────────────────────────── */
function renderSkeleton(el) {
ensureOvStyles();
el.innerHTML =
'<div class="ov-skel-cards">' +
'<div class="ov-skel-box ov-skel-card"></div>' +
'<div class="ov-skel-box ov-skel-card"></div>' +
'<div class="ov-skel-box ov-skel-card"></div>' +
'<div class="ov-skel-box ov-skel-card"></div>' +
'</div>' +
'<div class="ov-skel-rows">' +
'<div class="ov-skel-box ov-skel-row"></div>' +
'<div class="ov-skel-box ov-skel-row"></div>' +
'<div class="ov-skel-box ov-skel-row"></div>' +
'<div class="ov-skel-box ov-skel-row"></div>' +
'<div class="ov-skel-box ov-skel-row"></div>' +
'</div>';
}
function pctClassNum(p) {
if (p === null || p === undefined) return '';
return p >= 75 ? 'hi' : p >= 50 ? 'mid' : 'lo';
@@ -181,8 +239,9 @@
function renderSessionRows(sessions, e) {
return sessions.map(function (s) {
var name = s.user_name || '—';
return '<tr>' +
'<td>' + e(s.user_name || '—') + '</td>' +
'<td><span class="ov-cell-user">' + avatarHtml(name) + e(name) + '</span></td>' +
'<td>' + e(s.subject_name || '—') + '</td>' +
'<td>' + (s.score != null ? s.score : 0) + ' / ' + (s.total != null ? s.total : 0) + '</td>' +
'<td><span class="ov-pct ' + pctClassNum(s.percent) + '">' + (s.percent != null ? s.percent : '—') + '%</span></td>' +
@@ -368,7 +427,7 @@
async function load() {
const el = document.getElementById('overview-content');
if (!el) return;
LS.state.loading(el, 'Загружаю обзор…');
renderSkeleton(el);
try {
const data = await LS.adminGetOverview();
_lastLoadTs = Date.now();