Files
Maxim Dolgolyov 6fcdafed50 feat(imggen): фон питомца, обложки курсов, аватары и доска через ИИ
Питомец: кастомный фон (миграция 068 pet_bg_custom, POST /api/pet/bg/custom,
  карточка «Свой фон (ИИ)» в гардеробной, применение картинкой).
Курсы: обложка-картинка (миграция 069 cover_image, генерация в модалке
  редактирования, рендер вместо эмодзи).
Аватар: кнопка «Сгенерировать (ИИ)» в загрузке → кадрирование → модерация.
Доска (classroom): кнопка-инструмент «Сгенерировать картинку (ИИ)».

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 10:59:26 +03:00

2689 lines
120 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Профиль — LearnSpace</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link href="https://fonts.googleapis.com/css2?family=Unbounded:wght@400;700;800&family=Manrope:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/css/ls.css" />
<style>
html, body { height: 100%; overflow: hidden; }
.app-layout { height: 100vh; overflow: hidden; }
.sb-content { height: 100vh; overflow: hidden; display: flex; align-items: stretch; }
.profile-layout {
display: flex; flex: 1; gap: 0; overflow: hidden;
padding: 20px 20px 20px 0;
}
/* ══════════ LEFT PANEL ══════════ */
.p-left {
width: 290px; flex-shrink: 0;
background: linear-gradient(170deg, #0d0b28 0%, #1a1248 45%, #0f1635 100%);
border-radius: 22px;
display: flex; flex-direction: column;
position: relative; overflow: hidden;
}
/* decorative blobs */
.p-left-blob1 {
position: absolute; width: 300px; height: 300px; border-radius: 50%;
background: radial-gradient(circle, rgba(155,93,229,0.35) 0%, transparent 70%);
top: -80px; right: -80px; pointer-events: none;
}
.p-left-blob2 {
position: absolute; width: 200px; height: 200px; border-radius: 50%;
background: radial-gradient(circle, rgba(6,214,224,0.15) 0%, transparent 70%);
bottom: 40px; left: -40px; pointer-events: none;
}
.p-left-dots {
position: absolute; inset: 0; pointer-events: none;
background-image: radial-gradient(circle, rgba(255,255,255,0.04) 1px, transparent 1px);
background-size: 18px 18px;
}
/* top accent line */
.p-left-accent {
position: absolute; top: 0; left: 0; right: 0; height: 3px;
background: linear-gradient(90deg, #9B5DE5, #06D6E0);
border-radius: 22px 22px 0 0;
}
/* inner scroll area */
.p-left-inner {
position: relative; z-index: 1;
display: flex; flex-direction: column;
padding: 28px 22px 22px; flex: 1; overflow: hidden;
}
/* ── Avatar ── */
.p-avatar-wrap {
position: relative; width: 80px; height: 80px; flex-shrink: 0; margin-bottom: 16px;
}
.p-avatar-ring {
position: absolute; inset: -3px; border-radius: 50%;
background: var(--grad-1);
padding: 3px; border-radius: 50%;
}
.p-avatar-ring-inner {
width: 100%; height: 100%; border-radius: 50%;
background: #1a1248;
}
.p-avatar {
position: absolute; inset: 3px; border-radius: 50%;
background: linear-gradient(135deg, rgba(155,93,229,0.6), rgba(6,214,224,0.4));
display: flex; align-items: center; justify-content: center;
font-family: 'Unbounded', sans-serif; font-size: 1.3rem; font-weight: 800; color: #fff;
overflow: hidden;
}
.p-avatar img { width: 100%; height: 100%; object-fit: cover; border-radius: 50%; }
.p-avatar-edit-btn {
position: absolute; bottom: 0; right: 0;
width: 26px; height: 26px; border-radius: 50%;
background: var(--violet); border: 2px solid #1a1248;
display: flex; align-items: center; justify-content: center;
cursor: pointer; z-index: 2; transition: background .15s;
}
.p-avatar-edit-btn:hover { background: #7c3fd4; }
.p-avatar-edit-btn svg { width: 13px; height: 13px; color: #fff; }
.p-avatar-status {
margin-top: 8px; font-size: 0.68rem; font-weight: 600;
padding: 3px 10px; border-radius: 99px;
display: none;
}
.p-avatar-status.pending { display: inline-block; background: rgba(255,200,0,0.18); color: #ffd166; }
.p-avatar-status.rejected { display: inline-block; background: rgba(241,91,181,0.18); color: #F15BB5; }
.p-avatar-status.approved { display: inline-block; background: rgba(6,214,96,0.18); color: #06d660; }
.p-name {
font-family: 'Unbounded', sans-serif; font-size: 0.92rem; font-weight: 800;
color: #fff; line-height: 1.3; margin-bottom: 3px; word-break: break-word;
}
.p-email { font-size: 0.72rem; color: rgba(255,255,255,0.45); margin-bottom: 10px; word-break: break-all; }
.p-badge {
display: inline-flex; align-items: center; gap: 5px;
padding: 4px 11px; border-radius: 99px;
background: linear-gradient(135deg, rgba(155,93,229,0.25), rgba(6,214,224,0.15));
color: rgba(255,255,255,0.85); font-size: 0.67rem; font-weight: 700;
letter-spacing: 0.05em; text-transform: uppercase;
border: 1px solid rgba(155,93,229,0.4); margin-bottom: 5px;
}
.p-badge-dot {
width: 5px; height: 5px; border-radius: 50%;
background: var(--grad-1);
}
.p-since { font-size: 0.67rem; color: rgba(255,255,255,0.3); }
/* ── Divider ── */
.p-div { height: 1px; background: rgba(255,255,255,0.08); margin: 18px 0; flex-shrink: 0; }
/* ── Stats grid ── */
.p-stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; flex-shrink: 0; }
.p-stat {
background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.08);
border-radius: 12px; padding: 10px 6px; text-align: center;
transition: background .2s;
}
.p-stat-val {
font-family: 'Unbounded', sans-serif; font-size: 1rem; font-weight: 900;
color: #fff; line-height: 1;
}
.p-stat-val.grad {
background: var(--grad-1);
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
}
.p-stat-lbl { font-size: 0.58rem; color: rgba(255,255,255,0.4); font-weight: 600; margin-top: 4px; text-transform: uppercase; letter-spacing: 0.04em; }
/* ── Subject bars ── */
.p-subjects { flex: 1; overflow: hidden; display: flex; flex-direction: column; justify-content: flex-end; gap: 10px; }
.p-subj-row { display: flex; flex-direction: column; gap: 4px; }
.p-subj-top { display: flex; justify-content: space-between; align-items: baseline; }
.p-subj-name { font-size: 0.7rem; font-weight: 600; color: rgba(255,255,255,0.65); }
.p-subj-pct { font-size: 0.7rem; font-weight: 800; font-family: 'Unbounded', sans-serif; color: rgba(255,255,255,0.85); }
.p-subj-bar { height: 5px; background: rgba(255,255,255,0.08); border-radius: 99px; overflow: hidden; }
.p-subj-fill { height: 100%; border-radius: 99px; transition: width .7s cubic-bezier(.4,0,.2,1); }
/* ── Teacher classes ── */
.p-classes { flex: 1; overflow-y: auto; overflow-x: hidden; display: flex; flex-direction: column; gap: 6px; }
.p-class-row {
padding: 9px 11px; background: rgba(255,255,255,0.06);
border-radius: 12px; border: 1px solid rgba(255,255,255,0.09);
transition: background .2s;
}
.p-class-row:hover { background: rgba(255,255,255,0.10); }
.p-class-name { font-size: 0.77rem; font-weight: 700; color: rgba(255,255,255,0.9); margin-bottom: 2px; }
.p-class-meta { font-size: 0.64rem; color: rgba(255,255,255,0.4); }
/* ══════════ RIGHT PANEL ══════════ */
.p-right {
flex: 1; display: flex; flex-direction: column;
padding-left: 20px; overflow: hidden; min-width: 0;
}
/* page title */
.p-page-title {
font-family: 'Unbounded', sans-serif; font-size: 1rem; font-weight: 800;
color: var(--text); margin-bottom: 16px; flex-shrink: 0;
}
/* ── Tabs ── */
.p-tabs {
display: flex; gap: 2px; background: rgba(15,23,42,0.06);
border-radius: 13px; padding: 3px; margin-bottom: 18px;
flex-shrink: 0; width: fit-content;
}
.p-tab {
padding: 7px 20px; border: none; border-radius: 10px;
background: transparent; font-family: 'Manrope', sans-serif;
font-size: 0.83rem; font-weight: 600; color: var(--text-3);
cursor: pointer; transition: all .2s; white-space: nowrap;
}
.p-tab:hover:not(.active) { color: var(--text-2); background: rgba(15,23,42,0.04); }
.p-tab.active {
background: #fff; color: var(--text);
box-shadow: 0 1px 6px rgba(15,23,42,0.10);
}
/* ── Tab panes ── */
.p-pane { display: none; flex: 1; flex-direction: column; gap: 14px; overflow: hidden; }
.p-pane.active { display: flex; }
#tab-achievements { overflow-y: auto; padding-right: 4px; }
#tab-bookmarks { overflow-y: auto; padding-right: 4px; }
#tab-prefs { overflow-y: auto; padding-right: 4px; }
/* ── Prefs (settings) tab ── */
.pref-row {
display: flex; align-items: center; justify-content: space-between;
padding: 10px 0; border-bottom: 1px solid rgba(15,23,42,0.06);
}
.pref-row:last-child { border-bottom: none; padding-bottom: 0; }
.pref-row-info { display: flex; flex-direction: column; gap: 2px; }
.pref-row-label {
font-size: 0.83rem; font-weight: 600; color: var(--text);
}
.pref-row-desc { font-size: 0.72rem; color: var(--text-3); }
.pref-toggle {
position: relative; width: 38px; height: 22px; flex-shrink: 0;
}
.pref-toggle input { opacity: 0; width: 0; height: 0; }
.pref-toggle-track {
position: absolute; inset: 0; border-radius: 999px;
background: rgba(15,23,42,0.12); cursor: pointer; transition: background .2s;
}
.pref-toggle-track::after {
content: ''; position: absolute; left: 3px; top: 50%;
transform: translateY(-50%); width: 16px; height: 16px;
border-radius: 50%; background: #fff;
box-shadow: 0 1px 3px rgba(15,23,42,0.2); transition: left .2s;
}
.pref-toggle input:checked + .pref-toggle-track { background: var(--violet); }
.pref-toggle input:checked + .pref-toggle-track::after { left: calc(100% - 19px); }
.pref-volume-wrap {
display: flex; align-items: center; gap: 10px; min-width: 160px;
}
.pref-volume-wrap input[type=range] {
flex: 1; accent-color: var(--violet); cursor: pointer;
}
.pref-volume-val {
font-size: 0.78rem; font-weight: 700; color: var(--violet);
min-width: 32px; text-align: right;
}
.pref-test-btn {
padding: 5px 14px; border-radius: 8px; border: 1.5px solid rgba(155,93,229,0.3);
background: rgba(155,93,229,0.06); font-size: 0.76rem; font-weight: 600;
color: var(--violet); cursor: pointer; transition: all .15s; white-space: nowrap;
}
.pref-test-btn:hover { background: rgba(155,93,229,0.12); border-color: var(--violet); }
.pref-section-label {
font-size: 0.65rem; font-weight: 800; text-transform: uppercase;
letter-spacing: 0.08em; color: var(--text-3); margin: 4px 0 8px;
}
/* ── Cards ── */
.p-card {
background: #fff; border: 1.5px solid var(--border);
border-radius: 18px; padding: 20px 22px; flex-shrink: 0;
box-shadow: 0 2px 12px rgba(15,23,42,0.05);
transition: box-shadow .2s;
}
.p-card:hover { box-shadow: 0 4px 20px rgba(15,23,42,0.08); }
.p-card-header {
display: flex; align-items: center; gap: 8px; margin-bottom: 18px;
}
.p-card-icon {
width: 32px; height: 32px; border-radius: 9px; flex-shrink: 0;
display: flex; align-items: center; justify-content: center;
background: linear-gradient(135deg, rgba(155,93,229,0.12), rgba(6,214,224,0.08));
}
.p-card-icon i { color: var(--violet); }
.p-card-title {
font-family: 'Unbounded', sans-serif; font-size: 0.7rem; font-weight: 800;
color: var(--text); letter-spacing: 0.03em;
}
.p-card-sub { font-size: 0.72rem; color: var(--text-3); margin-top: 1px; }
/* ── bookmarks ── */
.bm-filter {
padding: 5px 14px; border-radius: 999px; border: 1.5px solid rgba(15,23,42,0.1);
background: transparent; font-family: 'Manrope', sans-serif; font-size: 0.75rem;
font-weight: 600; color: var(--text-3); cursor: pointer; transition: all .15s;
}
.bm-filter:hover { border-color: var(--violet); color: var(--violet); }
.bm-filter.active { background: rgba(155,93,229,0.08); border-color: var(--violet); color: var(--violet); }
.bm-item {
display: flex; align-items: center; gap: 14px; padding: 12px 14px;
border-radius: 14px; border: 1px solid rgba(15,23,42,0.06); margin-bottom: 8px;
background: rgba(255,255,255,0.5); transition: all .15s; cursor: pointer;
}
.bm-item:hover { background: rgba(155,93,229,0.04); border-color: rgba(155,93,229,0.15); }
.bm-item-icon {
width: 36px; height: 36px; border-radius: 10px; display: flex; align-items: center;
justify-content: center; font-size: 0.9rem; flex-shrink: 0;
}
.bm-item-icon-lesson { background: rgba(155,93,229,0.1); color: var(--violet); }
.bm-item-icon-course { background: rgba(6,214,160,0.1); color: #06D6A0; }
.bm-item-icon-file { background: rgba(6,214,224,0.1); color: var(--cyan); }
.bm-item-body { flex: 1; min-width: 0; }
.bm-item-title { font-size: 0.85rem; font-weight: 600; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.bm-item-sub { font-size: 0.72rem; color: var(--text-3); margin-top: 2px; }
.bm-item-del {
width: 28px; height: 28px; border-radius: 8px; border: none; background: transparent;
color: #ccc; cursor: pointer; display: flex; align-items: center; justify-content: center;
transition: all .15s; flex-shrink: 0;
}
.bm-item-del:hover { background: rgba(239,71,111,0.08); color: #EF476F; }
/* ── Form ── */
.form-group { margin-bottom: 12px; }
.form-group:last-of-type { margin-bottom: 0; }
.form-label {
display: block; font-size: 0.67rem; font-weight: 700; color: var(--text-3);
text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 5px;
}
.form-input {
width: 100%; padding: 10px 13px;
border: 1.5px solid rgba(15,23,42,0.11); border-radius: 11px;
font-family: 'Manrope', sans-serif; font-size: 0.88rem; color: var(--text);
background: var(--bg); transition: border-color .2s, box-shadow .2s, background .2s;
box-sizing: border-box;
}
.form-input:focus {
outline: none; border-color: var(--violet); background: #fff;
box-shadow: 0 0 0 3px rgba(155,93,229,0.12);
}
.form-footer { display: flex; align-items: center; gap: 12px; margin-top: 16px; }
.btn-save {
padding: 9px 22px; border: none; border-radius: 99px;
background: var(--grad-1);
color: #fff; font-family: 'Manrope', sans-serif;
font-size: 0.82rem; font-weight: 700; cursor: pointer;
transition: all .2s; position: relative; overflow: hidden;
}
.btn-save::after {
content: ''; position: absolute; inset: 0;
background: rgba(255,255,255,0); transition: background .2s;
}
.btn-save:hover::after { background: rgba(255,255,255,0.1); }
.btn-save:hover { transform: translateY(-1px); box-shadow: 0 6px 20px rgba(155,93,229,0.32); }
.btn-save:active { transform: none; }
.btn-save:disabled { opacity: .5; cursor: not-allowed; transform: none; box-shadow: none; }
.form-msg { font-size: 0.78rem; font-weight: 600; }
.form-msg.ok { color: var(--green); }
.form-msg.err { color: var(--pink); }
/* ── Spacer ── */
.p-spacer { flex: 1; min-height: 8px; }
/* ── Logout row ── */
.p-logout-row {
display: flex; align-items: center; justify-content: space-between;
padding: 14px 18px;
background: rgba(241,91,181,0.04);
border: 1.5px solid rgba(241,91,181,0.15);
border-radius: 15px; flex-shrink: 0;
transition: background .2s;
}
.p-logout-row:hover { background: rgba(241,91,181,0.07); }
.p-logout-info {}
.p-logout-label { font-size: 0.79rem; font-weight: 700; color: var(--text-2); margin-bottom: 1px; }
.p-logout-sub { font-size: 0.68rem; color: var(--text-3); }
.btn-logout {
padding: 8px 18px; border: 1.5px solid var(--pink); border-radius: 99px;
background: transparent; color: var(--pink);
font-family: 'Manrope', sans-serif; font-size: 0.79rem; font-weight: 700;
cursor: pointer; transition: all .2s; white-space: nowrap; flex-shrink: 0;
}
.btn-logout:hover { background: var(--pink); color: #fff; box-shadow: 0 4px 14px rgba(241,91,181,0.3); }
/* ── Info block ── */
.p-info-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.p-info-item {
background: var(--bg); border: 1.5px solid var(--border);
border-radius: 12px; padding: 11px 14px;
}
.p-info-lbl { font-size: 0.64rem; font-weight: 700; color: var(--text-3); text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 4px; }
.p-info-val { font-size: 0.86rem; font-weight: 600; color: var(--text); }
/* ── Achievements ── */
/* The outer container now hosts multiple .ach-group sections; each
group has its own inner grid. */
.ach-grid { display: flex; flex-direction: column; gap: 20px; }
.ach-group { display: flex; flex-direction: column; gap: 10px; }
.ach-group-head {
display: flex; align-items: center; gap: 10px;
padding: 6px 4px 4px;
border-bottom: 1.5px solid var(--border);
}
.ach-group-icon {
display: inline-flex; align-items: center; justify-content: center;
width: 26px; height: 26px; border-radius: 8px;
background: rgba(155,93,229,0.10); color: var(--violet);
}
.ach-group-title {
font-family: 'Unbounded', sans-serif;
font-size: 0.85rem; font-weight: 800; letter-spacing: 0.02em;
color: var(--text); flex: 1;
}
.ach-group-count {
font-size: 0.72rem; font-weight: 700; color: var(--text-3);
font-family: 'Manrope', sans-serif;
}
.ach-group-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 12px;
}
/* Tier stars inline with the title — only shown for tier >= 2. */
.ach-tier {
display: inline-block; margin-left: 6px;
font-size: 0.62rem; color: #FFB347; vertical-align: middle;
letter-spacing: 1px;
}
.ach-item {
display: flex; align-items: center; gap: 12px;
padding: 14px 16px; border-radius: 14px;
background: var(--bg); border: 1.5px solid var(--border);
transition: all 0.18s;
}
.ach-item.unlocked { border-color: rgba(155,93,229,0.25); background: rgba(155,93,229,0.04); }
.ach-item.locked { opacity: 0.55; filter: grayscale(0.4); }
.ach-icon {
width: 42px; height: 42px; border-radius: 12px; flex-shrink: 0;
display: flex; align-items: center; justify-content: center;
background: rgba(155,93,229,0.12); color: var(--violet);
}
.ach-item[data-cat="streak"] .ach-icon { background: rgba(255,107,53,0.1); color: #FF6B35; }
.ach-item[data-cat="volume"] .ach-icon { background: rgba(6,214,224,0.1); color: #06D6E0; }
.ach-item[data-cat="mastery"] .ach-icon { background: rgba(255,209,102,0.1); color: #FFB347; }
.ach-item[data-cat="level"] .ach-icon { background: rgba(34,197,94,0.1); color: #22c55e; }
.ach-item[data-cat="xp"] .ach-icon { background: rgba(99,102,241,0.1); color: #6366f1; }
.ach-body { flex: 1; min-width: 0; }
.ach-title { font-size: 0.82rem; font-weight: 700; color: var(--text); }
.ach-desc { font-size: 0.7rem; color: var(--text-3); margin-top: 2px; }
.ach-date { font-size: 0.62rem; color: var(--violet); font-weight: 600; margin-top: 3px; }
.ach-xp-bar {
display: flex; align-items: center; gap: 12px; margin-bottom: 18px;
padding: 14px 18px; border-radius: 14px;
background: linear-gradient(135deg, rgba(155,93,229,0.08), rgba(6,214,224,0.05));
border: 1.5px solid rgba(155,93,229,0.12);
flex-shrink: 0;
}
.ach-xp-lvl {
width: 48px; height: 48px; border-radius: 50%; flex-shrink: 0;
background: var(--grad-1);
display: flex; flex-direction: column; align-items: center; justify-content: center;
color: #fff; line-height: 1;
}
.ach-xp-lvl-num { font-family: 'Unbounded', sans-serif; font-size: 1rem; font-weight: 900; }
.ach-xp-lvl-lbl { font-size: 0.5rem; font-weight: 700; opacity: 0.85; }
.ach-xp-info { flex: 1; }
.ach-xp-top { display: flex; align-items: baseline; gap: 8px; margin-bottom: 5px; }
.ach-xp-rank { font-family: 'Unbounded', sans-serif; font-size: 0.82rem; font-weight: 800; color: var(--text); }
.ach-xp-text { font-size: 0.72rem; color: var(--text-3); font-weight: 600; }
.ach-xp-progress { height: 7px; border-radius: 99px; background: rgba(15,23,42,0.07); overflow: hidden; }
.ach-xp-fill { height: 100%; border-radius: 99px; background: linear-gradient(90deg, #9B5DE5, #06D6E0); transition: width 0.6s; }
.ach-summary { display: flex; gap: 8px; margin-bottom: 14px; flex-shrink: 0; flex-wrap: wrap; }
.ach-sum-chip {
padding: 5px 12px; border-radius: 99px; font-size: 0.72rem; font-weight: 700;
background: rgba(155,93,229,0.08); color: var(--violet);
}
/* ── Avatar Frames ── */
.frames-section { margin-bottom: 18px; flex-shrink: 0; }
.frames-grid { display: flex; flex-wrap: wrap; gap: 10px; }
.frame-item {
display: flex; flex-direction: column; align-items: center; gap: 6px;
padding: 10px 12px; border-radius: 14px; cursor: pointer;
border: 2px solid transparent; transition: all 0.18s; min-width: 72px;
}
.frame-item:hover { background: rgba(155,93,229,0.04); }
.frame-item.selected { border-color: var(--violet); background: rgba(155,93,229,0.06); }
.frame-item.locked { opacity: 0.35; cursor: not-allowed; filter: grayscale(0.5); }
.frame-preview {
width: 44px; height: 44px; border-radius: 50%;
background: var(--grad-1);
display: flex; align-items: center; justify-content: center;
font-family: 'Unbounded', sans-serif; font-size: 0.7rem; font-weight: 800; color: #fff;
overflow: visible; box-sizing: border-box;
}
.frame-preview img { display: block; }
.frame-name { font-size: 0.62rem; font-weight: 700; color: var(--text-3); text-align: center; }
.frame-unlock-hint { font-size: 0.56rem; color: var(--text-3); text-align: center; max-width: 80px; }
/* ── Shop ── */
.shop-header {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 16px; flex-shrink: 0;
}
.shop-title {
font-family: 'Unbounded', sans-serif; font-size: 0.92rem; font-weight: 800;
color: var(--text); margin: 0;
}
.shop-balance {
display: inline-flex; align-items: center; gap: 6px;
padding: 7px 16px; border-radius: 99px;
background: rgba(255,209,102,0.1); border: 1.5px solid rgba(255,209,102,0.3);
font-family: 'Unbounded', sans-serif; font-size: 0.82rem; font-weight: 700;
color: #FFD166;
}
.shop-filters {
display: flex; gap: 2px; background: rgba(15,23,42,0.06);
border-radius: 13px; padding: 3px; margin-bottom: 18px;
flex-shrink: 0; width: fit-content;
}
.shop-filter {
padding: 6px 16px; border: none; border-radius: 10px;
background: transparent; font-family: 'Manrope', sans-serif;
font-size: 0.78rem; font-weight: 600; color: var(--text-3);
cursor: pointer; transition: all .2s; white-space: nowrap;
}
.shop-filter:hover:not(.active) { color: var(--text-2); background: rgba(15,23,42,0.04); }
.shop-filter.active {
background: #fff; color: var(--text);
box-shadow: 0 1px 6px rgba(15,23,42,0.10);
}
.shop-grid {
display: grid; grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));
gap: 14px; overflow-y: auto; flex: 1; padding-bottom: 12px;
align-content: start;
}
.shop-item {
background: #fff; border: 1.5px solid var(--border);
border-radius: 16px; padding: 16px 14px; text-align: center;
display: flex; flex-direction: column; align-items: center; gap: 6px;
transition: all .2s;
box-shadow: 0 2px 12px rgba(15,23,42,0.05);
}
.shop-item:hover { box-shadow: 0 4px 20px rgba(15,23,42,0.08); transform: translateY(-2px); }
/* Owned + active states keep an opaque card so animated backgrounds
don't bleed through the content. Color cue lives in the border
plus a tiny inner-shadow halo. */
.shop-item.owned {
border-color: rgba(34,197,94,0.5);
background: #fff;
box-shadow: 0 2px 12px rgba(15,23,42,0.05), inset 0 0 0 1px rgba(34,197,94,0.08);
}
.shop-item.disabled:not(.owned) { opacity: 0.5; }
.shop-item-icon { color: var(--violet); margin-bottom: 4px; }
.shop-frame-preview {
width: 56px; height: 56px; border-radius: 50%;
background: linear-gradient(135deg, #9B5DE5, #06D6E0);
display: flex; align-items: center; justify-content: center;
color: #fff; font-family: 'Unbounded', sans-serif;
font-size: 0.78rem; font-weight: 800;
margin-bottom: 8px; flex-shrink: 0;
border: 1.5px solid transparent;
box-sizing: border-box;
}
.shop-frame-preview img { display: block; }
.shop-frame-initials { line-height: 1; }
.shop-title-preview {
font-family: 'Unbounded', sans-serif; font-size: 0.78rem;
font-weight: 800; letter-spacing: 0.05em; text-transform: uppercase;
padding: 8px 14px; margin-bottom: 6px;
border: 1.5px dashed currentColor; border-radius: 99px;
}
/* Background preview swatch in shop items — uses the same .bg-<slug>
classes as the full-page background so what you see is what you
get. Compact size to match other previews. */
.shop-bg-preview {
width: 100%;
aspect-ratio: 16 / 10;
max-height: 90px;
border-radius: 10px;
margin-bottom: 8px;
border: 1.5px solid var(--border);
}
.shop-item-name {
font-family: 'Unbounded', sans-serif; font-size: 0.78rem; font-weight: 800;
color: var(--text); line-height: 1.3;
}
.shop-item-desc {
font-size: 0.7rem; color: var(--text-3); line-height: 1.4;
}
.shop-item-price {
display: inline-flex; align-items: center; gap: 4px;
font-family: 'Unbounded', sans-serif; font-size: 0.75rem; font-weight: 700;
color: #FFD166; margin-top: 4px;
}
.shop-buy-btn {
padding: 8px 20px; border: none; border-radius: 99px;
background: linear-gradient(135deg, #FFD166, #FF9F1C);
color: #fff; font-family: 'Manrope', sans-serif;
font-size: 0.78rem; font-weight: 700; cursor: pointer;
transition: all .2s; margin-top: 4px;
}
.shop-buy-btn:hover:not(:disabled) { opacity: 0.88; transform: translateY(-1px); box-shadow: 0 4px 14px rgba(255,209,102,0.4); }
.shop-buy-btn:disabled { opacity: 0.5; cursor: not-allowed; transform: none; box-shadow: none; }
.shop-buy-btn.owned {
background: linear-gradient(135deg, #22c55e, #16a34a);
cursor: default;
}
.shop-activate-btn {
background: linear-gradient(135deg, #9B5DE5, #7C3AED) !important;
cursor: pointer !important;
}
.shop-active-btn {
background: linear-gradient(135deg, #06D6E0, #0891B2) !important;
cursor: pointer !important;
position: relative;
}
.shop-active-btn::before {
content: ''; display: inline-block; width: 6px; height: 6px;
border-radius: 50%; background: #fff; margin-right: 6px;
animation: ls-dot-pulse 1.5s infinite;
}
@keyframes ls-dot-pulse { 0%,100%{opacity:1} 50%{opacity:0.3} }
.shop-item.active {
border-color: rgba(6,214,224,0.55);
background: #fff;
box-shadow: 0 4px 18px rgba(6,214,224,0.18), inset 0 0 0 1px rgba(6,214,224,0.10);
}
.shop-empty {
grid-column: 1 / -1; text-align: center; padding: 40px 20px;
font-size: 0.88rem; color: var(--text-3); font-weight: 600;
}
/* ── Mobile responsive ── */
@media (max-width: 768px) {
.profile-layout { flex-direction: column; overflow: visible; padding: 16px 14px 80px 14px; gap: 16px; }
.p-left { width: 100% !important; height: auto; position: static; }
.p-left-inner { padding: 24px 18px 20px !important; }
.p-stats { grid-template-columns: repeat(3, 1fr); }
.p-right { overflow: visible; }
.p-info-grid { grid-template-columns: 1fr; }
}
@media (max-width: 768px) {
.shop-grid { grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); }
.shop-header { flex-direction: column; gap: 10px; align-items: flex-start; }
.shop-filters { flex-wrap: wrap; }
}
@media (max-width: 480px) {
.p-stats { grid-template-columns: 1fr 1fr; }
.profile-layout { padding: 12px 12px 80px; }
.shop-grid { grid-template-columns: 1fr; }
}
/* ── Parent links ── */
.pl-list { display: flex; flex-direction: column; gap: 10px; }
.pl-row {
display: flex; align-items: center; gap: 12px;
padding: 12px 14px; background: rgba(15,23,42,0.02);
border: 1.5px solid var(--border); border-radius: 14px;
transition: all .2s;
}
.pl-row:hover { border-color: rgba(155,93,229,0.3); }
.pl-row.inactive { opacity: 0.5; }
.pl-icon {
width: 36px; height: 36px; border-radius: 10px; flex-shrink: 0;
display: flex; align-items: center; justify-content: center;
background: linear-gradient(135deg, rgba(155,93,229,0.12), rgba(6,214,224,0.08));
color: var(--violet);
}
.pl-info { flex: 1; min-width: 0; }
.pl-label { font-size: 0.85rem; font-weight: 700; color: var(--text); }
.pl-meta { font-size: 0.7rem; color: var(--text-3); margin-top: 2px; }
.pl-actions { display: flex; gap: 6px; flex-shrink: 0; }
.pl-btn {
width: 32px; height: 32px; border: 1.5px solid var(--border);
border-radius: 9px; background: #fff; cursor: pointer;
display: flex; align-items: center; justify-content: center;
transition: all .15s; color: var(--text-3);
}
.pl-btn:hover { border-color: var(--violet); color: var(--violet); }
.pl-btn.danger:hover { border-color: #ef4444; color: #ef4444; }
.pl-create-row {
display: flex; gap: 10px; align-items: center; margin-top: 6px;
}
.pl-create-row .form-input { flex: 1; margin: 0; }
.pl-url-box {
margin-top: 12px; padding: 12px 14px;
background: rgba(155,93,229,0.06); border: 1.5px dashed rgba(155,93,229,0.3);
border-radius: 12px; display: none;
}
.pl-url-text {
font-size: 0.75rem; color: var(--text-2); word-break: break-all;
font-family: monospace; line-height: 1.5;
}
.pl-limit { font-size: 0.72rem; color: var(--text-3); margin-top: 10px; }
/* ══════════ Avatar Modal ══════════ */
.av-ovl {
position: fixed; inset: 0; z-index: 900;
background: rgba(0,0,0,0.68); backdrop-filter: blur(6px);
display: flex; align-items: center; justify-content: center;
opacity: 0; pointer-events: none; transition: opacity .22s;
}
.av-ovl.open { opacity: 1; pointer-events: auto; }
.av-dlg {
width: 380px; max-width: calc(100vw - 32px);
background: linear-gradient(170deg, #0d0b28 0%, #1a1248 100%);
border: 1px solid rgba(155,93,229,0.3);
border-radius: 24px;
box-shadow: 0 40px 80px rgba(0,0,0,0.6), 0 0 0 1px rgba(155,93,229,0.12);
overflow: hidden;
transform: scale(.94) translateY(14px);
transition: transform .28s cubic-bezier(.34,1.56,.64,1);
}
.av-ovl.open .av-dlg { transform: scale(1) translateY(0); }
.av-hdr {
display: flex; align-items: center; justify-content: space-between;
padding: 18px 20px 16px;
border-bottom: 1px solid rgba(255,255,255,0.07);
background: linear-gradient(90deg, rgba(155,93,229,0.08), transparent);
}
.av-title {
font-family: 'Unbounded', sans-serif; font-size: 0.83rem; font-weight: 800; color: #fff;
display: flex; align-items: center; gap: 9px;
}
.av-title-dot {
width: 8px; height: 8px; border-radius: 50%;
background: var(--grad-1);
flex-shrink: 0;
}
.av-close {
width: 30px; height: 30px; border-radius: 8px; border: none;
background: rgba(255,255,255,0.06); color: rgba(255,255,255,0.45);
cursor: pointer; display: flex; align-items: center; justify-content: center;
transition: all .15s;
}
.av-close:hover { background: rgba(241,91,181,0.15); color: #F15BB5; }
.av-close svg { width: 14px; height: 14px; }
/* Steps */
.av-step { padding: 22px 20px; display: flex; flex-direction: column; align-items: center; gap: 16px; }
/* Current avatar preview */
.av-cur {
width: 96px; height: 96px; border-radius: 50%;
background: linear-gradient(135deg, rgba(155,93,229,0.6), rgba(6,214,224,0.4));
display: flex; align-items: center; justify-content: center;
font-family: 'Unbounded', sans-serif; font-size: 1.6rem; font-weight: 800; color: #fff;
overflow: hidden;
box-shadow: 0 0 0 2px rgba(155,93,229,0.5), 0 0 0 5px rgba(155,93,229,0.12), 0 8px 24px rgba(0,0,0,0.4);
}
.av-cur img { width: 100%; height: 100%; object-fit: cover; border-radius: 50%; }
/* Modal status */
.av-status-row {
font-size: 0.72rem; font-weight: 600; padding: 5px 14px;
border-radius: 99px; text-align: center; display: none;
}
.av-status-row.pending { display: block; background: rgba(255,200,0,0.14); color: #ffd166; }
.av-status-row.rejected { display: block; background: rgba(241,91,181,0.14); color: #F15BB5; }
/* Drop zone */
.av-drop {
width: 100%; padding: 22px 16px; box-sizing: border-box;
border: 2px dashed rgba(155,93,229,0.35); border-radius: 16px;
cursor: pointer; display: flex; flex-direction: column; align-items: center; gap: 6px;
transition: all .2s; background: rgba(155,93,229,0.04);
}
.av-drop:hover, .av-drop.drag-over {
border-color: var(--violet); background: rgba(155,93,229,0.10);
transform: translateY(-1px);
}
.av-drop-icon { width: 30px; height: 30px; color: rgba(155,93,229,0.65); margin-bottom: 2px; }
.av-drop-txt { font-size: 0.84rem; font-weight: 600; color: rgba(255,255,255,0.7); text-align: center; }
.av-drop-sub { font-size: 0.68rem; color: rgba(255,255,255,0.3); }
/* Delete button */
.av-del-btn {
display: none; align-items: center; gap: 7px;
padding: 7px 16px; border-radius: 99px;
border: 1.5px solid rgba(241,91,181,0.3);
background: rgba(241,91,181,0.06);
color: rgba(241,91,181,0.75); font-size: 0.77rem; font-weight: 600;
cursor: pointer; transition: all .15s; font-family: 'Manrope', sans-serif;
}
.av-del-btn.visible { display: flex; }
.av-del-btn:hover { border-color: #F15BB5; background: rgba(241,91,181,0.14); color: #F15BB5; }
.av-del-btn svg { width: 14px; height: 14px; flex-shrink: 0; }
/* Crop canvas */
#av-canvas {
border-radius: 50%; cursor: grab; display: block;
box-shadow: 0 4px 32px rgba(0,0,0,0.5), 0 0 0 2px rgba(155,93,229,0.3);
touch-action: none;
}
#av-canvas:active { cursor: grabbing; }
/* Zoom row */
.av-zoom-row { display: flex; align-items: center; gap: 10px; width: 100%; }
.av-zoom-row svg { flex-shrink: 0; color: rgba(255,255,255,0.4); }
#av-zoom {
flex: 1; -webkit-appearance: none; appearance: none; height: 4px;
border-radius: 99px; background: rgba(255,255,255,0.12); cursor: pointer; outline: none;
}
#av-zoom::-webkit-slider-thumb {
-webkit-appearance: none; width: 18px; height: 18px; border-radius: 50%;
background: var(--grad-1);
box-shadow: 0 1px 4px rgba(0,0,0,0.4); cursor: pointer;
}
.av-crop-hint { font-size: 0.67rem; color: rgba(255,255,255,0.28); text-align: center; }
/* Crop buttons */
.av-crop-btns { display: flex; gap: 10px; width: 100%; }
.av-btn-back {
flex: 1; padding: 10px 16px; border-radius: 12px;
border: 1.5px solid rgba(255,255,255,0.1);
background: transparent; color: rgba(255,255,255,0.5);
font-family: 'Manrope', sans-serif; font-size: 0.82rem; font-weight: 600;
cursor: pointer; transition: all .15s;
}
.av-btn-back:hover { border-color: rgba(255,255,255,0.25); color: rgba(255,255,255,0.8); }
.av-btn-send {
flex: 2; padding: 10px 16px; border-radius: 12px; border: none;
background: var(--grad-1);
color: #fff; font-family: 'Manrope', sans-serif; font-size: 0.82rem; font-weight: 700;
cursor: pointer; transition: opacity .15s;
}
.av-btn-send:hover { opacity: 0.88; }
.av-btn-send:disabled { opacity: 0.45; cursor: not-allowed; }
/* Preset gallery */
.av-preset-hd {
width: 100%; display: flex; align-items: center; gap: 10px;
font-size: 0.74rem; font-weight: 700; color: rgba(255,255,255,0.55);
letter-spacing: 0.04em; text-transform: uppercase;
}
.av-preset-hd::before, .av-preset-hd::after {
content: ''; flex: 1; height: 1px; background: rgba(255,255,255,0.08);
}
.av-preset-grid {
width: 100%;
display: grid; grid-template-columns: repeat(6, 1fr); gap: 8px;
max-height: 220px; overflow-y: auto;
padding: 4px; box-sizing: border-box;
scrollbar-width: thin; scrollbar-color: rgba(155,93,229,0.4) transparent;
}
.av-preset-grid::-webkit-scrollbar { width: 6px; }
.av-preset-grid::-webkit-scrollbar-thumb { background: rgba(155,93,229,0.4); border-radius: 99px; }
.av-preset {
aspect-ratio: 1/1; border-radius: 12px; overflow: hidden;
border: 2px solid transparent; cursor: pointer;
background: rgba(255,255,255,0.04);
transition: transform .15s, border-color .15s, box-shadow .15s;
padding: 0;
}
.av-preset img { width: 100%; height: 100%; object-fit: cover; display: block; }
.av-preset:hover {
transform: scale(1.06); border-color: rgba(155,93,229,0.5);
box-shadow: 0 4px 14px rgba(155,93,229,0.25);
}
.av-preset.active {
border-color: var(--violet);
box-shadow: 0 0 0 2px rgba(155,93,229,0.3), 0 4px 14px rgba(155,93,229,0.4);
}
.av-or {
width: 100%; display: flex; align-items: center; gap: 10px;
font-size: 0.7rem; font-weight: 600; color: rgba(255,255,255,0.35);
letter-spacing: 0.04em;
}
.av-or::before, .av-or::after {
content: ''; flex: 1; height: 1px; background: rgba(255,255,255,0.06);
}
/* ── Рейтинг (leaderboard) ── */
.lb-head { display: flex; justify-content: flex-end; margin: 4px 0 12px; }
.lb-tabs { display: inline-flex; gap: 4px; background: var(--bg); border: 1.5px solid var(--border); border-radius: 10px; padding: 3px; }
.lb-tab { padding: 5px 14px; border: none; background: none; border-radius: 7px; font-family: 'Manrope', sans-serif; font-size: 0.8rem; font-weight: 600; color: var(--text-3); cursor: pointer; transition: all 0.15s; }
.lb-tab:hover:not(.active) { color: var(--text-2); }
.lb-tab.active { background: var(--violet); color: #fff; }
.lb-list { display: flex; flex-direction: column; gap: 4px; }
.lb-row { display: flex; align-items: center; gap: 12px; padding: 9px 12px; border-radius: 12px; background: var(--bg); border: 1.5px solid var(--border); transition: all 0.15s; }
.lb-row:hover { border-color: rgba(155,93,229,0.25); }
.lb-row-me { background: rgba(155,93,229,0.08); border-color: rgba(155,93,229,0.3); }
.lb-rank { font-family: 'Unbounded', sans-serif; font-weight: 800; font-size: 0.95rem; width: 26px; text-align: center; flex-shrink: 0; }
.lb-avatar { width: 36px; height: 36px; border-radius: 50%; background: var(--violet); color: #fff; display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 0.85rem; overflow: hidden; flex-shrink: 0; }
.lb-avatar img { width: 100%; height: 100%; object-fit: cover; }
.lb-name { flex: 1; font-weight: 600; font-size: 0.9rem; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.lb-xp { font-family: 'Unbounded', sans-serif; font-weight: 700; font-size: 0.85rem; color: var(--violet); flex-shrink: 0; }
</style>
<script src="https://cdn.jsdelivr.net/npm/lucide@0.469.0/dist/umd/lucide.min.js"></script>
</head>
<body>
<div class="app-layout">
<aside class="sidebar" id="app-sidebar"></aside>
<div class="sb-content">
<div class="profile-layout">
<!-- ══ LEFT ══ -->
<div class="p-left">
<div class="p-left-blob1"></div>
<div class="p-left-blob2"></div>
<div class="p-left-dots"></div>
<div class="p-left-accent"></div>
<div class="p-left-inner">
<!-- Avatar -->
<div class="p-avatar-wrap" id="p-avatar-wrap">
<div class="p-avatar-ring"><div class="p-avatar-ring-inner"></div></div>
<div class="p-avatar" id="big-avatar">LS</div>
<!-- Edit button — shown only for students via JS -->
<button class="p-avatar-edit-btn" id="p-avatar-edit-btn" title="Изменить аватар" style="display:none"
onclick="avModalOpen()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
</svg>
</button>
</div>
<div class="p-avatar-status" id="p-avatar-status"></div>
<div class="p-name" id="profile-name"></div>
<div class="p-email" id="profile-email"></div>
<span class="p-badge"><span class="p-badge-dot"></span><span id="profile-role"></span></span>
<div class="p-since" id="profile-since"></div>
<div class="p-div"></div>
<!-- Student stats -->
<div class="p-stats" id="p-stats" style="display:none">
<div class="p-stat">
<div class="p-stat-val" id="ps-count"></div>
<div class="p-stat-lbl">Тестов</div>
</div>
<div class="p-stat">
<div class="p-stat-val grad" id="ps-avg"></div>
<div class="p-stat-lbl">Средний</div>
</div>
<div class="p-stat">
<div class="p-stat-val" id="ps-streak"></div>
<div class="p-stat-lbl">Дней</div>
</div>
</div>
<!-- Coins display -->
<div id="p-coins-row" style="display:none;margin-top:10px">
<div class="p-stat" style="grid-column:span 3;background:rgba(255,209,102,0.1);border-color:rgba(255,209,102,0.25)">
<div class="p-stat-val" style="color:#FFD166" id="ps-coins">0</div>
<div class="p-stat-lbl" style="color:rgba(255,209,102,0.7)">Монет</div>
</div>
</div>
<!-- Teacher stats -->
<div class="p-stats" id="t-stats" style="display:none">
<div class="p-stat">
<div class="p-stat-val" id="ts-classes"></div>
<div class="p-stat-lbl">Классов</div>
</div>
<div class="p-stat">
<div class="p-stat-val grad" id="ts-students"></div>
<div class="p-stat-lbl">Учеников</div>
</div>
<div class="p-stat">
<div class="p-stat-val" id="ts-assigns"></div>
<div class="p-stat-lbl">Заданий</div>
</div>
</div>
<div class="p-div"></div>
<!-- Student: subject bars -->
<div class="p-subjects" id="p-subjects" style="display:none"></div>
<!-- Teacher: class list -->
<div class="p-classes" id="p-classes" style="display:none"></div>
</div>
</div>
<!-- ══ RIGHT ══ -->
<div class="p-right">
<div class="p-page-title">Мой профиль</div>
<div class="p-tabs">
<button class="p-tab active" onclick="switchTab(this,'tab-account')">Аккаунт</button>
<button class="p-tab" id="tab-btn-achievements" onclick="switchTab(this,'tab-achievements')">Достижения</button>
<button class="p-tab" id="tab-btn-shop" onclick="switchTab(this,'tab-shop')">Магазин</button>
<button class="p-tab" onclick="switchTab(this,'tab-bookmarks')">Закладки</button>
<button class="p-tab" onclick="switchTab(this,'tab-prefs');loadPrefs()">Настройки</button>
<button class="p-tab" onclick="switchTab(this,'tab-security')">Безопасность</button>
</div>
<!-- Tab: Аккаунт -->
<div class="p-pane active" id="tab-account">
<!-- Info grid -->
<div class="p-card">
<div class="p-card-header">
<div class="p-card-icon"><i data-lucide="user" style="width:15px;height:15px"></i></div>
<div>
<div class="p-card-title">Информация</div>
<div class="p-card-sub">Ваши данные в системе</div>
</div>
</div>
<div class="p-info-grid">
<div class="p-info-item">
<div class="p-info-lbl">Имя</div>
<div class="p-info-val" id="info-name"></div>
</div>
<div class="p-info-item">
<div class="p-info-lbl">Роль</div>
<div class="p-info-val" id="info-role"></div>
</div>
<div class="p-info-item" style="grid-column: span 2">
<div class="p-info-lbl">Email</div>
<div class="p-info-val" id="info-email" style="font-size:.8rem"></div>
</div>
</div>
</div>
<!-- Change name -->
<div class="p-card">
<div class="p-card-header">
<div class="p-card-icon"><i data-lucide="pencil" style="width:15px;height:15px"></i></div>
<div>
<div class="p-card-title">Отображаемое имя</div>
<div class="p-card-sub">Изменить имя в профиле</div>
</div>
</div>
<div class="form-group">
<label class="form-label">Новое имя</label>
<input class="form-input" id="inp-name" type="text" placeholder="Ваше имя" />
</div>
<div class="form-footer">
<button class="btn-save" id="btn-save-name" onclick="saveName()">Сохранить</button>
<span class="form-msg" id="msg-name"></span>
</div>
</div>
<div class="p-spacer"></div>
<div class="p-logout-row">
<div class="p-logout-info">
<div class="p-logout-label">Выход из аккаунта</div>
<div class="p-logout-sub">Завершить текущую сессию</div>
</div>
<button class="btn-logout" onclick="LS.logout()">Выйти</button>
</div>
</div>
<!-- Tab: Достижения -->
<div class="p-pane" id="tab-achievements">
<div class="ach-xp-bar" id="ach-xp-bar" style="display:none">
<div class="ach-xp-lvl">
<div class="ach-xp-lvl-num" id="ach-lvl">1</div>
<div class="ach-xp-lvl-lbl">LVL</div>
</div>
<div class="ach-xp-info">
<div class="ach-xp-top">
<div class="ach-xp-rank" id="ach-rank">Новичок</div>
<div class="ach-xp-text" id="ach-xp-text">0 XP</div>
</div>
<div class="ach-xp-progress"><div class="ach-xp-fill" id="ach-xp-fill" style="width:0%"></div></div>
</div>
</div>
<div class="ach-summary" id="ach-summary"></div>
<!-- Avatar frames -->
<div class="frames-section" id="frames-section" style="display:none">
<div style="font-family:'Unbounded',sans-serif;font-size:0.82rem;font-weight:800;color:var(--text);margin-bottom:10px">Рамки аватара</div>
<div class="frames-grid" id="frames-grid"></div>
</div>
<div class="ach-grid" id="ach-grid"></div>
<!-- Рейтинг (перенесён с дашборда) -->
<div class="p-card" id="lb-section" style="display:none;margin-top:14px">
<div class="p-card-header">
<div class="p-card-icon"><i data-lucide="trophy" style="width:15px;height:15px"></i></div>
<div>
<div class="p-card-title">Рейтинг</div>
<div class="p-card-sub">Топ учеников по опыту</div>
</div>
</div>
<div class="lb-head">
<div class="lb-tabs">
<button class="lb-tab active" onclick="setLbPeriod('week',this)">Неделя</button>
<button class="lb-tab" onclick="setLbPeriod('all',this)">Всё время</button>
</div>
</div>
<div class="lb-list" id="lb-list"></div>
</div>
</div>
<!-- Tab: Магазин -->
<div class="p-pane" id="tab-shop">
<div class="shop-header">
<h3 class="shop-title">Магазин наград</h3>
<div class="shop-balance" id="shop-balance">
<span id="shop-coins">0</span> монет
</div>
</div>
<div class="shop-filters">
<button class="shop-filter active" data-type="all" onclick="shopFilter('all',this)">Все</button>
<button class="shop-filter" data-type="frame" onclick="shopFilter('frame',this)">Рамки</button>
<button class="shop-filter" data-type="background" onclick="shopFilter('background',this)">Фоны</button>
<button class="shop-filter" data-type="title" onclick="shopFilter('title',this)">Титулы</button>
<button class="shop-filter" data-type="effect" onclick="shopFilter('effect',this)">Эффекты</button>
</div>
<div class="shop-grid" id="shop-grid"></div>
</div>
<!-- Tab: Закладки -->
<div class="p-pane" id="tab-bookmarks">
<div class="p-card">
<div class="p-card-header">
<div class="p-card-icon"><i data-lucide="bookmark" style="width:15px;height:15px"></i></div>
<div>
<div class="p-card-title">Мои закладки</div>
<div class="p-card-sub">Сохранённые уроки, курсы и файлы</div>
</div>
</div>
<div class="bm-filters" style="display:flex;gap:6px;margin-bottom:16px;flex-wrap:wrap">
<button class="bm-filter active" onclick="filterBookmarks(null,this)">Все</button>
<button class="bm-filter" onclick="filterBookmarks('lesson',this)">Уроки</button>
<button class="bm-filter" onclick="filterBookmarks('course',this)">Курсы</button>
<button class="bm-filter" onclick="filterBookmarks('file',this)">Файлы</button>
</div>
<div id="bookmarks-list"></div>
</div>
</div>
<!-- Tab: Доступ для родителей (students only) -->
<div class="p-pane" id="tab-parent" style="overflow-y:auto">
<div class="p-card">
<div class="p-card-header">
<div class="p-card-icon"><i data-lucide="users" style="width:15px;height:15px"></i></div>
<div>
<div class="p-card-title">Доступ для родителей</div>
<div class="p-card-sub">Создайте ссылку, чтобы родители видели ваш прогресс</div>
</div>
</div>
<div class="pl-list" id="pl-list"></div>
<div class="pl-create-row" id="pl-create-row">
<input class="form-input" id="pl-label-input" type="text" placeholder="Имя (Мама, Папа...)" maxlength="50" />
<button class="btn-save" onclick="parentCreateLink()">Создать</button>
</div>
<div class="pl-url-box" id="pl-url-box">
<div style="font-size:0.72rem;font-weight:700;color:var(--text);margin-bottom:6px">Ссылка создана! Скопируйте и отправьте родителю:</div>
<div class="pl-url-text" id="pl-url-text"></div>
<button class="btn-save" style="margin-top:10px;font-size:0.78rem" onclick="parentCopyUrl()">Копировать ссылку</button>
</div>
<div class="pl-limit" id="pl-limit"></div>
</div>
</div>
<!-- Tab: Безопасность -->
<!-- Tab: Настройки -->
<div class="p-pane" id="tab-prefs">
<!-- Звуки -->
<div class="p-card">
<div class="p-card-header">
<div class="p-card-icon"><i data-lucide="volume-2" style="width:15px;height:15px"></i></div>
<div>
<div class="p-card-title">Звуки системы</div>
<div class="p-card-sub">Синтезированные — не требуют загрузки файлов</div>
</div>
</div>
<!-- Master toggle -->
<div class="pref-row">
<div class="pref-row-info">
<div class="pref-row-label">Звуки включены</div>
<div class="pref-row-desc">Глобальное управление всеми звуками</div>
</div>
<label class="pref-toggle">
<input type="checkbox" id="pref-sfx-enabled" onchange="prefSfxEnabled(this.checked)">
<span class="pref-toggle-track"></span>
</label>
</div>
<!-- Volume -->
<div class="pref-row">
<div class="pref-row-info">
<div class="pref-row-label">Громкость</div>
<div class="pref-row-desc">Применяется ко всем звукам платформы</div>
</div>
<div class="pref-volume-wrap">
<input type="range" id="pref-sfx-vol" min="0" max="100" value="75"
oninput="prefSfxVolume(this.value)">
<span class="pref-volume-val" id="pref-sfx-vol-val">75%</span>
</div>
</div>
<div class="pref-section-label" style="margin-top:12px">Категории</div>
<!-- UI sounds -->
<div class="pref-row">
<div class="pref-row-info">
<div class="pref-row-label">Интерфейс</div>
<div class="pref-row-desc">Клики, модальные окна, удаление, уведомления</div>
</div>
<label class="pref-toggle">
<input type="checkbox" id="pref-sfx-ui" onchange="prefSfxCat('ui',this.checked)">
<span class="pref-toggle-track"></span>
</label>
</div>
<!-- Navigation sounds -->
<div class="pref-row">
<div class="pref-row-info">
<div class="pref-row-label">Навигация</div>
<div class="pref-row-desc">Переходы между страницами и секциями</div>
</div>
<label class="pref-toggle">
<input type="checkbox" id="pref-sfx-navigation" onchange="prefSfxCat('navigation',this.checked)">
<span class="pref-toggle-track"></span>
</label>
</div>
<!-- Classroom sounds -->
<div class="pref-row">
<div class="pref-row-info">
<div class="pref-row-label">Classroom</div>
<div class="pref-row-desc">Участники, таймер, доска, файлы</div>
</div>
<label class="pref-toggle">
<input type="checkbox" id="pref-sfx-classroom" onchange="prefSfxCat('classroom',this.checked)">
<span class="pref-toggle-track"></span>
</label>
</div>
<!-- Lesson call melody -->
<div class="pref-row">
<div class="pref-row-info">
<div class="pref-row-label">Вызов на урок</div>
<div class="pref-row-desc">Мелодия-перезвон, когда учитель начал онлайн-урок</div>
</div>
<label class="pref-toggle">
<input type="checkbox" id="pref-lesson-call" onchange="prefLessonCall(this.checked)">
<span class="pref-toggle-track"></span>
</label>
</div>
<!-- Gamification sounds -->
<div class="pref-row">
<div class="pref-row-info">
<div class="pref-row-label">Геймификация</div>
<div class="pref-row-desc">XP, уровень, достижения, испытания, вход</div>
</div>
<label class="pref-toggle">
<input type="checkbox" id="pref-sfx-gamification" onchange="prefSfxCat('gamification',this.checked)">
<span class="pref-toggle-track"></span>
</label>
</div>
<!-- Quiz sounds -->
<div class="pref-row">
<div class="pref-row-info">
<div class="pref-row-label">Квизы</div>
<div class="pref-row-desc">Старт, правильно/неправильно, таймер, бонус</div>
</div>
<label class="pref-toggle">
<input type="checkbox" id="pref-sfx-quiz" onchange="prefSfxCat('quiz',this.checked)">
<span class="pref-toggle-track"></span>
</label>
</div>
<!-- Preview buttons -->
<div class="pref-section-label" style="margin-top:14px">Прослушать</div>
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-top:8px">
<button class="pref-test-btn" onclick="prefLessonTest()">
<i data-lucide="bell-ring" style="width:12px;height:12px;vertical-align:-2px"></i>
Вызов на урок
</button>
<button class="pref-test-btn" onclick="prefSfxTest('notification')">
<i data-lucide="bell" style="width:12px;height:12px;vertical-align:-2px"></i>
Уведомление
</button>
<button class="pref-test-btn" onclick="prefSfxTest('success')">
<i data-lucide="check-circle" style="width:12px;height:12px;vertical-align:-2px"></i>
Успех
</button>
<button class="pref-test-btn" onclick="prefSfxTest('error')">
<i data-lucide="x-circle" style="width:12px;height:12px;vertical-align:-2px"></i>
Ошибка
</button>
<button class="pref-test-btn" onclick="prefSfxTest('modal_open')">
<i data-lucide="layout" style="width:12px;height:12px;vertical-align:-2px"></i>
Модал
</button>
<button class="pref-test-btn" onclick="prefSfxTest('page_enter')">
<i data-lucide="arrow-right" style="width:12px;height:12px;vertical-align:-2px"></i>
Навигация
</button>
<button class="pref-test-btn" onclick="prefSfxTest('lesson_start')">
<i data-lucide="play" style="width:12px;height:12px;vertical-align:-2px"></i>
Урок
</button>
<button class="pref-test-btn" onclick="prefSfxTest('timer_warning')">
<i data-lucide="clock" style="width:12px;height:12px;vertical-align:-2px"></i>
Таймер
</button>
<button class="pref-test-btn" onclick="prefSfxTest('achievement')">
<i data-lucide="trophy" style="width:12px;height:12px;vertical-align:-2px"></i>
Ачивка
</button>
<button class="pref-test-btn" onclick="prefSfxTest('challenge_complete')">
<i data-lucide="target" style="width:12px;height:12px;vertical-align:-2px"></i>
Испытание
</button>
<button class="pref-test-btn" onclick="prefSfxTest('level_up')">
<i data-lucide="zap" style="width:12px;height:12px;vertical-align:-2px"></i>
Уровень
</button>
<button class="pref-test-btn" onclick="prefSfxTest('quiz_correct')">
<i data-lucide="check" style="width:12px;height:12px;vertical-align:-2px"></i>
Правильно
</button>
<button class="pref-test-btn" onclick="prefSfxTest('time_up')">
<i data-lucide="timer-off" style="width:12px;height:12px;vertical-align:-2px"></i>
Время вышло
</button>
</div>
</div>
<!-- Помощник Квантик -->
<div class="p-card">
<div class="p-card-header">
<div class="p-card-icon"><i data-lucide="sparkles" style="width:15px;height:15px"></i></div>
<div>
<div class="p-card-title">Помощник Квантик</div>
<div class="p-card-sub">Подсказки и напоминания по системе</div>
</div>
</div>
<div class="pref-row">
<div class="pref-row-info">
<div class="pref-row-label">Показывать помощника</div>
<div class="pref-row-desc">Плавающий Квантик с подсказками на страницах</div>
</div>
<label class="pref-toggle">
<input type="checkbox" id="pref-assistant" onchange="prefAssistant(this.checked)">
<span class="pref-toggle-track"></span>
</label>
</div>
</div>
<!-- Внешний вид -->
<div class="p-card">
<div class="p-card-header">
<div class="p-card-icon"><i data-lucide="monitor" style="width:15px;height:15px"></i></div>
<div>
<div class="p-card-title">Внешний вид</div>
<div class="p-card-sub">Анимации и визуальные настройки</div>
</div>
</div>
<div class="pref-row">
<div class="pref-row-info">
<div class="pref-row-label">Анимации интерфейса</div>
<div class="pref-row-desc">Плавные переходы между страницами</div>
</div>
<label class="pref-toggle">
<input type="checkbox" id="pref-anim" onchange="prefAnim(this.checked)" checked>
<span class="pref-toggle-track"></span>
</label>
</div>
<div class="pref-row">
<div class="pref-row-info">
<div class="pref-row-label">Уведомления на рабочем столе</div>
<div class="pref-row-desc">Push-уведомления браузера</div>
</div>
<label class="pref-toggle">
<input type="checkbox" id="pref-push" onchange="prefPush(this.checked)">
<span class="pref-toggle-track"></span>
</label>
</div>
</div>
</div>
<div class="p-pane" id="tab-security">
<div class="p-card">
<div class="p-card-header">
<div class="p-card-icon"><i data-lucide="lock" style="width:15px;height:15px"></i></div>
<div>
<div class="p-card-title">Смена пароля</div>
<div class="p-card-sub">Рекомендуем использовать надёжный пароль</div>
</div>
</div>
<div class="form-group">
<label class="form-label">Текущий пароль</label>
<input class="form-input" id="inp-cur-pwd" type="password" placeholder="Введите текущий пароль" />
</div>
<div class="form-group">
<label class="form-label">Новый пароль</label>
<input class="form-input" id="inp-new-pwd" type="password" placeholder="Минимум 6 символов" />
</div>
<div class="form-group">
<label class="form-label">Подтверждение</label>
<input class="form-input" id="inp-conf-pwd" type="password" placeholder="Повторите новый пароль" />
</div>
<div class="form-footer">
<button class="btn-save" id="btn-save-pwd" onclick="savePassword()">Сменить пароль</button>
<span class="form-msg" id="msg-pwd"></span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<script src="/js/api.js"></script>
<script src="/js/imggen.js"></script>
<script src="/js/sound.js"></script>
<script src="/js/sidebar.js"></script>
<script src="/js/notifications.js"></script>
<script>
const { user, isTeacher, isAdmin } = LS.initPage();
LS.showBoardIfAllowed();
document.addEventListener('keydown', e => {
if (e.key === 'Escape' && document.getElementById('av-modal').classList.contains('open')) avClose();
});
const ROLE_LABELS = { student:'Ученик', teacher:'Учитель', admin:'Администратор' };
const SUBJ_META = {
bio: { name:'Биология', color:'#22c55e' },
chem: { name:'Химия', color:'#f59e0b' },
math: { name:'Математика', color:'#3b82f6' },
phys: { name:'Физика', color:'#9B5DE5' },
};
/* ── Tabs ── */
function switchTab(btn, id) {
document.querySelectorAll('.p-tab').forEach(b => b.classList.remove('active'));
document.querySelectorAll('.p-pane').forEach(p => p.classList.remove('active'));
btn.classList.add('active');
document.getElementById(id).classList.add('active');
if (id === 'tab-shop') loadShop();
if (id === 'tab-bookmarks') loadBookmarks();
}
/* ── Load profile ── */
async function loadProfile() {
try {
const u = await LS.fetchMe();
const initials = (u.name||'LS').split(' ').slice(0,2).map(w=>w[0]?.toUpperCase()||'').join('')||'LS';
// Avatar: photo or initials
const avatarEl = document.getElementById('big-avatar');
if (u.avatar_url) {
avatarEl.innerHTML = `<img src="/avatars/${LS.escapeHtml(u.avatar_url)}" alt="Аватар">`;
} else {
avatarEl.textContent = initials;
}
// Show edit button for all roles
document.getElementById('p-avatar-edit-btn').style.display = 'flex';
// Upload-with-moderation only for students; admin/teacher use presets directly
window._lsRole = u.role;
if (u.role === 'student') loadAvatarStatus();
document.getElementById('profile-name').textContent = u.name||'—';
document.getElementById('profile-email').textContent= u.email||'—';
document.getElementById('profile-role').textContent = ROLE_LABELS[u.role]||u.role;
document.getElementById('inp-name').value = u.name||'';
// Info grid
document.getElementById('info-name').textContent = u.name||'—';
document.getElementById('info-email').textContent = u.email||'—';
document.getElementById('info-role').textContent = ROLE_LABELS[u.role]||u.role;
if (u.created_at) {
const d = parseDate(u.created_at);
document.getElementById('profile-since').textContent =
'В системе с ' + d.toLocaleDateString('ru', { day:'numeric', month:'short', year:'numeric' });
}
if (u.role === 'student') {
loadStudentStats();
loadCoinsDisplay();
} else loadTeacherStats();
} catch {}
}
/* ── Avatar upload ────────────────────────────────────────────────────── */
async function loadAvatarStatus() {
try {
const data = await LS.get('/api/avatar/my-status');
const el = document.getElementById('p-avatar-status');
if (!data.request) { el.className = 'p-avatar-status'; return; }
const r = data.request;
if (r.status === 'pending') {
el.className = 'p-avatar-status pending';
el.textContent = 'На проверке';
} else if (r.status === 'rejected') {
el.className = 'p-avatar-status rejected';
el.textContent = r.reject_msg ? 'Отклонено: ' + r.reject_msg : 'Отклонено';
} else if (r.status === 'approved') {
el.className = 'p-avatar-status approved';
el.textContent = 'Одобрено';
// Auto-hide after 3s
setTimeout(() => { el.className = 'p-avatar-status'; }, 3000);
}
} catch {}
}
/* ── Student ── */
async function loadStudentStats() {
try {
const data = await LS.getHistory(1, 100);
const rows = data.rows || [];
const done = rows.filter(r => r.score !== null && r.total > 0);
const avg = done.length ? Math.round(done.reduce((s,r) => s + r.score/r.total*100, 0) / done.length) : 0;
const dayKeys = new Set(rows.map(r => {
const d = parseDate(r.started_at); d.setHours(0,0,0,0);
return d.toISOString().slice(0,10);
}));
let streak = 0;
const now = new Date();
for (let i = 0; i <= 60; i++) {
const d = new Date(now); d.setDate(d.getDate() - i);
if (dayKeys.has(d.toISOString().slice(0,10))) streak++;
else if (i > 0) break;
}
document.getElementById('ps-count').textContent = data.total || 0;
document.getElementById('ps-avg').textContent = avg + '%';
document.getElementById('ps-streak').textContent = streak;
document.getElementById('p-stats').style.display = '';
const bySubj = {};
done.forEach(r => {
if (!r.subject_slug) return;
if (!bySubj[r.subject_slug]) bySubj[r.subject_slug] = { sum:0, cnt:0 };
bySubj[r.subject_slug].sum += Math.round(r.score/r.total*100);
bySubj[r.subject_slug].cnt++;
});
const entries = Object.entries(bySubj);
if (entries.length) {
const max = Math.max(...entries.map(([,v]) => Math.round(v.sum/v.cnt)), 1);
document.getElementById('p-subjects').style.display = '';
document.getElementById('p-subjects').innerHTML = entries.map(([sl, v]) => {
const pct = Math.round(v.sum/v.cnt);
const m = SUBJ_META[sl] || { name:sl, color:'#9B5DE5' };
return `<div class="p-subj-row">
<div class="p-subj-top">
<div class="p-subj-name">${m.name}</div>
<div class="p-subj-pct">${pct}%</div>
</div>
<div class="p-subj-bar"><div class="p-subj-fill" style="width:${Math.round(pct/max*100)}%;background:${m.color}"></div></div>
</div>`;
}).join('');
}
} catch {}
}
/* ── Teacher ── */
async function loadTeacherStats() {
try {
const classes = await LS.getClasses();
const totalS = classes.reduce((s,c) => s + (c.member_count||0), 0);
const totalA = classes.reduce((s,c) => s + (c.assignment_count||0), 0);
document.getElementById('ts-classes').textContent = classes.length;
document.getElementById('ts-students').textContent = totalS;
document.getElementById('ts-assigns').textContent = totalA;
document.getElementById('t-stats').style.display = '';
if (classes.length) {
document.getElementById('p-classes').style.display = '';
document.getElementById('p-classes').innerHTML = classes.map(c => `
<div class="p-class-row">
<div class="p-class-name">${esc(c.name)}</div>
<div class="p-class-meta">${c.member_count} учеников · ${c.assignment_count} заданий</div>
</div>`).join('');
}
} catch {}
}
/* ── Coins in sidebar ── */
async function loadCoinsDisplay() {
try {
const data = await LS.getCoins();
const coins = data.coins || 0;
document.getElementById('ps-coins').textContent = coins;
document.getElementById('p-coins-row').style.display = '';
} catch {}
}
/* ── Save name ── */
async function saveName() {
const name = document.getElementById('inp-name').value.trim();
const btn = document.getElementById('btn-save-name');
const msg = document.getElementById('msg-name');
if (!name) { showMsg(msg,'Введите имя','err'); return; }
btn.disabled = true;
try {
await LS.updateProfile({ name });
showMsg(msg,'Сохранено <svg class="ic" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg>','ok');
document.getElementById('profile-name').textContent = name;
document.getElementById('info-name').textContent = name;
document.getElementById('nav-user').textContent = name;
const ini = name.split(' ').slice(0,2).map(w=>w[0]?.toUpperCase()||'').join('')||'LS';
document.getElementById('big-avatar').textContent = ini;
LS.renderNavAvatar(document.getElementById('nav-avatar'), { ...LS.getUser(), name });
} catch(e) { showMsg(msg, LS.esc(e.message||'Ошибка'),'err'); }
finally { btn.disabled = false; }
}
/* ── Save password ── */
async function savePassword() {
const cur = document.getElementById('inp-cur-pwd').value;
const nw = document.getElementById('inp-new-pwd').value;
const conf = document.getElementById('inp-conf-pwd').value;
const btn = document.getElementById('btn-save-pwd');
const msg = document.getElementById('msg-pwd');
if (!cur||!nw) { showMsg(msg,'Заполните все поля','err'); return; }
if (nw!==conf) { showMsg(msg,'Пароли не совпадают','err'); return; }
if (nw.length<6) { showMsg(msg,'Минимум 6 символов','err'); return; }
btn.disabled = true;
try {
await LS.updateProfile({ currentPassword:cur, newPassword:nw });
showMsg(msg,'Пароль изменён <svg class="ic" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg>','ok');
['inp-cur-pwd','inp-new-pwd','inp-conf-pwd'].forEach(id => document.getElementById(id).value='');
} catch(e) { showMsg(msg, LS.esc(e.message||'Ошибка'),'err'); }
finally { btn.disabled = false; }
}
function showMsg(el, text, type) {
el.innerHTML = text; el.className = 'form-msg ' + type;
setTimeout(() => { if (el.innerHTML===text) { el.innerHTML=''; el.className='form-msg'; } }, 3500);
}
/* ── Shop ── */
let _shopItems = [];
let _shopCoins = 0;
let _shopLoaded = false;
let _activeCosmetics = {}; // {frame, title, theme, effect} from server
async function loadShop() {
if (_shopLoaded) return;
try {
const [data, active] = await Promise.all([
LS.getShopItems(),
LS.getMyActiveCosmetics().catch(() => ({}))
]);
_shopItems = data.items || [];
_shopCoins = data.coins || 0;
_activeCosmetics = active || {};
document.getElementById('shop-coins').textContent = _shopCoins;
renderShopGrid('all');
_shopLoaded = true;
} catch (e) {
document.getElementById('shop-grid').innerHTML = '<div class="shop-empty">Не удалось загрузить магазин</div>';
}
}
function shopFilter(type, el) {
document.querySelectorAll('.shop-filter').forEach(b => b.classList.remove('active'));
el.classList.add('active');
renderShopGrid(type);
}
function _isItemActive(item) {
if (item.type === 'background' && _activeCosmetics.background) {
try {
const data = JSON.parse(item.data || '{}');
return data.slug === _activeCosmetics.background.slug;
} catch { return false; }
}
if (item.type === 'title' && _activeCosmetics.title) return true;
if (item.type === 'effect' && _activeCosmetics.effect) return true;
if (item.type === 'frame' && _activeCosmetics.frame) return true;
// Check by item id stored in active_* columns
try {
if (item.type === 'title') return _activeCosmetics.title && JSON.stringify(_activeCosmetics.title) === item.data;
if (item.type === 'effect') return _activeCosmetics.effect && JSON.stringify(_activeCosmetics.effect) === item.data;
} catch {}
return false;
}
function renderShopGrid(filter) {
const items = filter === 'all' ? _shopItems : _shopItems.filter(i => i.type === filter);
const grid = document.getElementById('shop-grid');
if (!items.length) {
grid.innerHTML = '<div class="shop-empty">Нет товаров в этой категории</div>';
return;
}
grid.innerHTML = items.map(item => {
// Free items (price === 0) are auto-owned for everyone: backgrounds
// and any future freebies don't need a user_purchases row.
const owned = !!item.owned || item.price === 0;
const canBuy = _shopCoins >= item.price && !owned;
const active = owned && _isItemActive(item);
let btn = '';
if (owned && active) {
btn = `<button class="shop-buy-btn shop-active-btn" onclick="deactivateItem('${item.type}')">Активно</button>`;
} else if (owned) {
btn = `<button class="shop-buy-btn shop-activate-btn" onclick="activateItem(${item.id},'${item.type}')">Применить</button>`;
} else {
btn = `<button class="shop-buy-btn ${canBuy ? '' : 'disabled'}" onclick="buyItem(${item.id})" ${canBuy ? '' : 'disabled'}>Купить</button>`;
}
const preview = _renderItemPreview(item);
return `<div class="shop-item ${owned ? 'owned' : ''} ${active ? 'active' : ''} ${!canBuy && !owned ? 'disabled' : ''}">
${preview}
<div class="shop-item-name">${esc(item.name)}</div>
<div class="shop-item-desc">${esc(item.description || '')}</div>
<div class="shop-item-price">${LS.icon('coins', 14)} ${item.price}</div>
${btn}
</div>`;
}).join('');
}
/* Build the visual hero of a shop item. For frames we render an actual
mini-avatar with the frame CSS applied so buyers see *exactly* what
they're paying for, not a generic lucide icon. */
function _renderItemPreview(item) {
if (item.type === 'frame') {
let css = '';
try { css = (JSON.parse(item.data || '{}').css) || ''; } catch {}
const u = LS.getUser?.() || {};
const inner = u.avatar_url
? `<img src="/avatars/${esc(u.avatar_url)}" alt="" style="width:100%;height:100%;object-fit:cover;border-radius:50%">`
: `<span class="shop-frame-initials">${esc((u.name || 'LS').split(' ').slice(0,2).map(w => w[0]?.toUpperCase() || '').join('') || 'LS')}</span>`;
return `<div class="shop-frame-preview" style="${esc(css)}">${inner}</div>`;
}
if (item.type === 'title') {
let titleData = {};
try { titleData = JSON.parse(item.data || '{}'); } catch {}
const color = titleData.color || '#9B5DE5';
const text = titleData.text || item.name;
return `<div class="shop-title-preview" style="color:${esc(color)}">${esc(text)}</div>`;
}
if (item.type === 'background') {
let bg = {};
try { bg = JSON.parse(item.data || '{}'); } catch {}
const slug = (bg.slug || 'none').replace(/[^a-z0-9_-]/gi, '');
// 56px swatch with the same CSS rules as the full-screen bg.
return `<div class="shop-bg-preview bg-preview bg-${slug}"></div>`;
}
// effects / other — fall back to the lucide icon
return `<div class="shop-item-icon">${LS.icon(item.icon || 'star', 28)}</div>`;
}
async function buyItem(id) {
if (!await LS.confirm('Купить этот предмет?', { title: 'Покупка', confirmText: 'Купить', danger: false })) return;
try {
const res = await LS.purchaseItem(id);
_shopCoins = res.coins;
document.getElementById('shop-coins').textContent = _shopCoins;
const psCoins = document.getElementById('ps-coins');
if (psCoins) psCoins.textContent = _shopCoins;
const item = _shopItems.find(i => i.id === id);
if (item) item.owned = 1;
_reRenderShop();
LS.toast('Покупка успешна!', 'success');
} catch (e) {
LS.toast(e.message || 'Ошибка покупки', 'error');
}
}
async function activateItem(id, type) {
try {
const res = await LS.activateShopItem(id, type);
if (res.data) _activeCosmetics[type] = res.data;
_reRenderShop();
LS.toast('Применено!', 'success');
LS.applyCosmetics(); // re-apply visuals live
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
}
async function deactivateItem(type) {
try {
await LS.activateShopItem(null, type);
_activeCosmetics[type] = null;
_reRenderShop();
LS.toast('Снято', 'info');
location.reload(); // clean up effects
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
}
function _reRenderShop() {
const activeFilter = document.querySelector('.shop-filter.active');
renderShopGrid(activeFilter ? (activeFilter.dataset.type || 'all') : 'all');
}
/* ── Achievements ── */
async function loadAchievements() {
try {
const [achs, gam] = await Promise.all([LS.getGamAchievements(), LS.getGamificationMe()]);
// XP bar
if (gam) {
document.getElementById('ach-xp-bar').style.display = '';
document.getElementById('ach-lvl').textContent = gam.level || 1;
document.getElementById('ach-rank').textContent = gam.rank || 'Новичок';
const xp = gam.xp || 0, min = gam.levelMin || 0, max = gam.levelMax || 100;
const pct = max > min ? Math.round((xp - min) / (max - min) * 100) : 0;
document.getElementById('ach-xp-text').textContent = `${xp} XP`;
document.getElementById('ach-xp-fill').style.width = Math.min(100, pct) + '%';
}
// Summary chips
const unlocked = achs.filter(a => a.unlocked).length;
const total = achs.length;
document.getElementById('ach-summary').innerHTML =
`<div class="ach-sum-chip">${unlocked} / ${total} получено</div>` +
(gam?.streak ? `<div class="ach-sum-chip">${lsIcon('flame', 14)} ${gam.streak} дн. стрик</div>` : '');
// Grouped render: sort once by sort_order, partition by group_slug,
// render a section heading + grid per group. Order of groups is fixed
// in ACH_GROUPS (matches backend ACHIEVEMENT_GROUPS).
const sorted = achs.slice().sort((a, b) =>
(a.sort_order || 0) - (b.sort_order || 0));
const byGroup = new Map();
for (const a of sorted) {
const key = a.group_slug || 'other';
if (!byGroup.has(key)) byGroup.set(key, []);
byGroup.get(key).push(a);
}
const renderItem = a => {
const cls = a.unlocked ? 'unlocked' : 'locked';
const dateStr = a.unlocked_at
? `<div class="ach-date">${parseDate(a.unlocked_at).toLocaleDateString('ru', { day:'numeric', month:'short' })}</div>`
: '';
const tierBadge = a.tier && a.tier > 1
? `<div class="ach-tier" title="Уровень ${a.tier}">${'★'.repeat(a.tier)}</div>` : '';
return `<div class="ach-item ${cls}" data-cat="${a.category || 'start'}" data-group="${a.group_slug || ''}">
<div class="ach-icon">${(lsIcon(a.icon || 'star', 22) || lsIcon('star', 22)).replace('fill="none"','fill="currentColor"').replace('stroke-width="2"','stroke-width="0"')}</div>
<div class="ach-body">
<div class="ach-title">${esc(a.title)}${tierBadge}</div>
<div class="ach-desc">${esc(a.description)}</div>
${dateStr}
</div>
</div>`;
};
const html = [];
for (const g of ACH_GROUPS) {
const items = byGroup.get(g.slug);
if (!items || !items.length) continue;
const got = items.filter(i => i.unlocked).length;
html.push(`
<div class="ach-group" data-group="${g.slug}">
<div class="ach-group-head">
<span class="ach-group-icon">${lsIcon(g.icon, 18)}</span>
<span class="ach-group-title">${esc(g.title)}</span>
<span class="ach-group-count">${got} / ${items.length}</span>
</div>
<div class="ach-group-grid">${items.map(renderItem).join('')}</div>
</div>`);
byGroup.delete(g.slug);
}
// Any leftover groups (e.g. server added a new one client doesn't
// know about) get rendered at the end so nothing silently vanishes.
for (const [slug, items] of byGroup) {
const got = items.filter(i => i.unlocked).length;
html.push(`
<div class="ach-group" data-group="${slug}">
<div class="ach-group-head">
<span class="ach-group-title">${esc(slug)}</span>
<span class="ach-group-count">${got} / ${items.length}</span>
</div>
<div class="ach-group-grid">${items.map(renderItem).join('')}</div>
</div>`);
}
document.getElementById('ach-grid').innerHTML = html.join('');
} catch {}
}
/* Display metadata mirrored from backend ACHIEVEMENT_GROUPS.
Order = render order. Keep in sync with gamification/_shared.js. */
const ACH_GROUPS = [
{ slug: 'onboarding', title: 'Старт', icon: 'flag' },
{ slug: 'volume', title: 'Объём', icon: 'bar-chart-2' },
{ slug: 'mastery', title: 'Качество', icon: 'award' },
{ slug: 'consistency', title: 'Постоянство', icon: 'flame' },
{ slug: 'exam', title: 'Экзамен 9', icon: 'clipboard-list' },
{ slug: 'exploration', title: 'Исследование', icon: 'compass' },
{ slug: 'social', title: 'Социальное', icon: 'users' },
];
/* ── Avatar Frames ── */
async function loadFrames() {
try {
const data = await LS.getFrames();
if (!data || !data.frames) return;
const grid = document.getElementById('frames-grid');
const u = LS.getUser?.() || {};
const inner = u.avatar_url
? `<img src="/avatars/${esc(u.avatar_url)}" alt="" style="width:100%;height:100%;object-fit:cover;border-radius:50%">`
: esc((u.name || 'LS').split(' ').slice(0,2).map(w => w[0]?.toUpperCase() || '').join('') || 'LS');
grid.innerHTML = data.frames.map(f => {
const cls = f.selected ? 'selected' : (!f.unlocked ? 'locked' : '');
const style = f.css ? `style="${f.css}"` : '';
return `<div class="frame-item ${cls}" onclick="selectFrame('${f.id}',${f.unlocked},this)" title="${f.name}${f.unlock ? ' ('+f.unlock+')' : ''}">
<div class="frame-preview" ${style}>${inner}</div>
<div class="frame-name">${esc(f.name)}</div>
${!f.unlocked ? '<div class="frame-unlock-hint">' + lsIcon('lock', 12) + '</div>' : ''}
</div>`;
}).join('');
document.getElementById('frames-section').style.display = '';
} catch {}
}
async function selectFrame(id, unlocked, el) {
if (!unlocked) { LS.toast('Рамка ещё не разблокирована', 'warn'); return; }
try {
await LS.setFrame(id);
document.querySelectorAll('.frame-item').forEach(f => f.classList.remove('selected'));
el.classList.add('selected');
LS.toast('Рамка установлена!', 'success', 2000);
} catch (e) { LS.toast(e.message, 'error'); }
}
/* ── bookmarks ── */
let _allBookmarks = [];
let _bmFilter = null;
async function loadBookmarks() {
const list = document.getElementById('bookmarks-list');
if (!list) return;
list.innerHTML = LS.skeleton(3, 'row');
try {
_allBookmarks = await LS.getBookmarks();
renderBookmarks();
} catch {
list.innerHTML = '<div style="color:var(--text-3);font-size:.85rem;padding:12px">Не удалось загрузить закладки</div>';
}
}
function filterBookmarks(type, btn) {
_bmFilter = type;
document.querySelectorAll('.bm-filter').forEach(b => b.classList.remove('active'));
if (btn) btn.classList.add('active');
renderBookmarks();
}
function renderBookmarks() {
const list = document.getElementById('bookmarks-list');
const filtered = _bmFilter ? _allBookmarks.filter(b => b.entity_type === _bmFilter) : _allBookmarks;
if (!filtered.length) {
list.innerHTML = '<div style="text-align:center;padding:32px 0;color:var(--text-3);font-size:.85rem">Закладок пока нет</div>';
return;
}
const ICONS = { lesson: 'book-open', course: 'graduation-cap', file: 'file-text', question: 'help-circle' };
const LABELS = { lesson: 'Урок', course: 'Курс', file: 'Файл', question: 'Вопрос' };
list.innerHTML = filtered.map(b => {
const iconName = ICONS[b.entity_type] || 'bookmark';
const sub = b.entity_type === 'lesson' && b.courseTitle ? b.courseTitle : LABELS[b.entity_type] || '';
const href = b.entity_type === 'lesson' ? `/lesson?id=${b.entity_id}`
: b.entity_type === 'course' ? `/course?id=${b.entity_id}`
: b.entity_type === 'file' ? `/library`
: '#';
return `<div class="bm-item" onclick="location.href='${href}'">
<div class="bm-item-icon bm-item-icon-${b.entity_type}">
<i data-lucide="${iconName}" style="width:16px;height:16px"></i>
</div>
<div class="bm-item-body">
<div class="bm-item-title">${esc(b.title)}</div>
<div class="bm-item-sub">${esc(sub)}</div>
</div>
<button class="bm-item-del" onclick="event.stopPropagation();removeBookmark(${b.id})" title="Убрать">
<i data-lucide="x" style="width:14px;height:14px"></i>
</button>
</div>`;
}).join('');
lucide.createIcons();
}
async function removeBookmark(id) {
try {
await LS.removeBookmark(id);
_allBookmarks = _allBookmarks.filter(b => b.id !== id);
renderBookmarks();
LS.toast('Убрано из закладок', 'info');
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
}
/* ── Parent links (student only) ── */
if (user?.role === 'student' || user?.role === 'free_student') {
// Insert tab button before "Безопасность"
const secBtn = document.querySelector('.p-tabs').lastElementChild;
const parentBtn = document.createElement('button');
parentBtn.className = 'p-tab';
parentBtn.textContent = 'Родители';
parentBtn.onclick = function() { switchTab(this, 'tab-parent'); loadParentLinks(); };
secBtn.parentNode.insertBefore(parentBtn, secBtn);
}
let _parentLinksLoaded = false;
let _parentLinksData = null;
async function loadParentLinks() {
if (_parentLinksLoaded) return;
_parentLinksLoaded = true;
const list = document.getElementById('pl-list');
list.innerHTML = LS.skeleton(2, 'row');
try {
const links = await LS.parentGetLinks();
_parentLinksData = links;
renderParentLinks(links);
} catch (e) {
list.innerHTML = '<div style="color:var(--text-3);font-size:.85rem;padding:12px">Не удалось загрузить</div>';
}
}
function renderParentLinks(links) {
const list = document.getElementById('pl-list');
if (!links.length) {
list.innerHTML = '<div style="text-align:center;padding:24px 0;color:var(--text-3);font-size:.85rem">Ссылок пока нет. Создайте первую!</div>';
} else {
list.innerHTML = links.map(l => {
const active = l.is_active ? '' : ' inactive';
const lastUsed = l.last_used ? 'Посещено ' + LS.fmtRelTime(l.last_used) : 'Ещё не посещали';
const toggleIcon = l.is_active ? 'toggle-right' : 'toggle-left';
const toggleColor = l.is_active ? 'color:#22c55e' : '';
const linkUrl = `${location.origin}/parent?t=${l.token}`;
return `<div class="pl-row${active}" data-id="${l.id}" data-url="${esc(linkUrl)}">
<div class="pl-icon"><i data-lucide="user-check" style="width:16px;height:16px"></i></div>
<div class="pl-info">
<div class="pl-label">${esc(l.label || 'Родитель')}</div>
<div class="pl-meta">${lastUsed}</div>
</div>
<div class="pl-actions">
<button class="pl-btn" title="Копировать ссылку" onclick="parentCopyLink(this)">
<i data-lucide="copy" style="width:14px;height:14px"></i>
</button>
<button class="pl-btn" title="${l.is_active ? 'Отключить' : 'Включить'}" onclick="parentToggle(${l.id},${l.is_active?0:1})">
<i data-lucide="${toggleIcon}" style="width:16px;height:16px;${toggleColor}"></i>
</button>
<button class="pl-btn danger" title="Удалить" onclick="parentDelete(${l.id})">
<i data-lucide="trash-2" style="width:14px;height:14px"></i>
</button>
</div>
</div>`;
}).join('');
}
const cnt = links.length;
document.getElementById('pl-limit').textContent = `${cnt} из 3 ссылок`;
document.getElementById('pl-create-row').style.display = cnt >= 3 ? 'none' : '';
lucide.createIcons();
}
async function parentCreateLink() {
const inp = document.getElementById('pl-label-input');
const label = inp.value.trim() || 'Родитель';
try {
const r = await LS.parentCreateLink(label);
inp.value = '';
// Build URL on client side (more reliable than server-side req.protocol)
const url = r.url || `${location.origin}/parent?t=${r.token}`;
// Show URL
const box = document.getElementById('pl-url-box');
document.getElementById('pl-url-text').textContent = url;
box.style.display = '';
box.dataset.url = url;
_parentLinksLoaded = false;
loadParentLinks();
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
}
function parentCopyUrl() {
const url = document.getElementById('pl-url-box').dataset.url;
navigator.clipboard.writeText(url).then(() => LS.toast('Скопировано!', 'success', 1500));
}
function parentCopyLink(btn) {
const row = btn.closest('.pl-row');
const url = row?.dataset?.url;
if (!url) { LS.toast('Ссылка не найдена', 'error'); return; }
navigator.clipboard.writeText(url).then(() => LS.toast('Ссылка скопирована!', 'success', 1500));
}
async function parentToggle(id, newActive) {
try {
await LS.parentUpdateLink(id, { is_active: !!newActive });
_parentLinksLoaded = false;
loadParentLinks();
LS.toast(newActive ? 'Ссылка включена' : 'Ссылка отключена', 'info', 1500);
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
}
async function parentDelete(id) {
if (!await LS.confirm('Удалить ссылку? Родитель потеряет доступ.')) return;
try {
await LS.parentDeleteLink(id);
_parentLinksLoaded = false;
loadParentLinks();
document.getElementById('pl-url-box').style.display = 'none';
LS.toast('Ссылка удалена', 'info', 1500);
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
}
loadAchievements();
loadFrames();
loadBookmarks();
/* ── Рейтинг (leaderboard) ── */
const _lbEsc = s => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' }[c]));
let _lbPeriod = 'week';
function setLbPeriod(p, btn) {
_lbPeriod = p;
document.querySelectorAll('.lb-tab').forEach(t => t.classList.remove('active'));
if (btn) btn.classList.add('active');
loadLeaderboard();
}
window.setLbPeriod = setLbPeriod;
async function loadLeaderboard() {
const section = document.getElementById('lb-section');
const list = document.getElementById('lb-list');
if (!section || !list) return;
section.style.display = ''; // карточка видна всегда
try {
const data = await LS.api('/api/gamification/leaderboard?period=' + encodeURIComponent(_lbPeriod));
if (!data || !data.length) {
list.innerHTML = '<div style="text-align:center;color:var(--text-3);font-size:0.82rem;padding:18px 0">Пока нет данных рейтинга. Проходи тесты и набирай XP!</div>';
if (window.lucide) lucide.createIcons();
return;
}
list.innerHTML = data.map((u, i) => {
const rank = i + 1;
const medal = rank === 1 ? '#FFD700' : rank === 2 ? '#C0C0C0' : rank === 3 ? '#CD7F32' : 'transparent';
const meClass = u.is_me ? ' lb-row-me' : '';
return `
<div class="lb-row${meClass}">
<div class="lb-rank" style="color:${medal !== 'transparent' ? medal : 'var(--text-3)'}">${rank}</div>
<div class="lb-avatar">${u.avatar ? `<img src="${_lbEsc(u.avatar)}">` : _lbEsc((u.name || '?')[0])}</div>
<div class="lb-name">${_lbEsc(u.name || 'Ученик')}</div>
<div class="lb-xp">${u.sort_xp || u.xp || 0} XP</div>
</div>`;
}).join('');
section.style.display = '';
if (window.lucide) lucide.createIcons();
} catch { section.style.display = 'none'; }
}
loadProfile();
loadLeaderboard();
if (window.lucide) lucide.createIcons();
/* ── Настройки (prefs tab) ── */
function loadPrefs() {
// Ассистент Квантик (независимо от наличия LS.sfx)
const asstEl = document.getElementById('pref-assistant');
if (asstEl && window.LS && LS.assistantContext) {
LS.assistantContext().then(c => { asstEl.checked = !(c && c.enabled === false); }).catch(() => {});
}
if (!window.LS || !LS.sfx) return;
const sfx = LS.sfx;
const setChk = (id, v) => { const el = document.getElementById(id); if (el) el.checked = v; };
setChk('pref-sfx-enabled', sfx.enabled);
setChk('pref-sfx-ui', sfx.prefs.ui);
setChk('pref-sfx-navigation', sfx.prefs.navigation !== false);
setChk('pref-sfx-classroom', sfx.prefs.classroom);
setChk('pref-sfx-gamification', sfx.prefs.gamification);
setChk('pref-sfx-quiz', sfx.prefs.quiz);
setChk('pref-lesson-call', sfx.lessonCall !== false);
const vol = Math.round(sfx.volume * 100);
const volEl = document.getElementById('pref-sfx-vol');
const volVal = document.getElementById('pref-sfx-vol-val');
if (volEl) volEl.value = vol;
if (volVal) volVal.textContent = vol + '%';
// Push notifications
const pushEl = document.getElementById('pref-push');
if (pushEl) pushEl.checked = (Notification.permission === 'granted');
// Animations
const animEl = document.getElementById('pref-anim');
if (animEl) animEl.checked = localStorage.getItem('ls_anim') !== 'off';
if (window.lucide) lucide.createIcons();
}
function prefSfxEnabled(v) {
if (!window.LS || !LS.sfx) return;
LS.sfx.setEnabled(v);
if (v) setTimeout(() => LS.sfx.play('success'), 100);
}
function prefAssistant(v) {
if (!window.LS || !LS.assistantSettings) return;
LS.assistantSettings({ enabled: !!v })
.then(() => { if (LS.toast) LS.toast(v ? 'Помощник включён' : 'Помощник отключён', 'success'); })
.catch(() => { if (LS.toast) LS.toast('Не удалось сохранить', 'error'); });
}
window.prefAssistant = prefAssistant;
function prefSfxVolume(v) {
if (!window.LS || !LS.sfx) return;
LS.sfx.setVolume(v / 100);
const valEl = document.getElementById('pref-sfx-vol-val');
if (valEl) valEl.textContent = v + '%';
LS.sfx.play('click');
}
function prefSfxCat(cat, v) {
if (!window.LS || !LS.sfx) return;
LS.sfx.setPref(cat, v);
if (v) LS.sfx.play('click');
}
function prefSfxTest(name) {
if (!window.LS || !LS.sfx) return;
const wasEnabled = LS.sfx.enabled;
LS.sfx.enabled = true;
LS.sfx.play(name);
LS.sfx.enabled = wasEnabled;
}
function prefLessonCall(v) {
if (!window.LS || !LS.sfx) return;
LS.sfx.setLessonCall(v);
if (v) LS.sfx.play('click'); // короткое подтверждение, не вся мелодия
}
function prefLessonTest() {
if (!window.LS || !LS.sfx) return;
LS.sfx.preview('lesson_start'); // прослушать в обход тумблеров
}
function prefAnim(v) {
localStorage.setItem('ls_anim', v ? 'on' : 'off');
LS.toast(v ? 'Анимации включены' : 'Анимации отключены', 'success');
}
function prefPush(v) {
if (v) {
Notification.requestPermission().then(perm => {
const el = document.getElementById('pref-push');
if (el) el.checked = perm === 'granted';
if (perm === 'granted') LS.toast('Push-уведомления разрешены', 'success');
else LS.toast('Браузер запретил уведомления', 'warn');
});
}
}
/* ══════════════════════════════════════════════════
Avatar Modal
══════════════════════════════════════════════════ */
let _avImg = null, _avZoom = 1, _avOffX = 0, _avOffY = 0;
let _avDrag = false, _avDragStartX = 0, _avDragStartY = 0;
let _avLastTouchDist = 0;
function avModalOpen() {
const modal = document.getElementById('av-modal');
// Mirror current avatar into modal preview
const bigAv = document.getElementById('big-avatar');
const preEl = document.getElementById('av-modal-cur');
const img = bigAv.querySelector('img');
if (img) {
preEl.innerHTML = `<img src="${img.src}" alt="Аватар">`;
} else {
preEl.innerHTML = '';
preEl.textContent = bigAv.textContent || 'LS';
}
// Show delete btn only if has actual avatar
document.getElementById('av-del-btn').classList.toggle('visible', !!img);
// Show upload section only for students (moderation)
document.getElementById('av-upload-block').style.display =
(window._lsRole === 'student') ? 'flex' : 'none';
avLoadPresets();
if (window._lsRole === 'student') avLoadStatus();
modal.classList.add('open');
if (LS.sfx) LS.sfx.play('modal_open');
}
/* ── Load preset grid ── */
let _avPresetsCache = null;
async function avLoadPresets() {
const grid = document.getElementById('av-preset-grid');
if (!_avPresetsCache) {
try {
const data = await LS.get('/api/avatar/presets');
_avPresetsCache = data.presets || [];
} catch { _avPresetsCache = []; }
}
// Find current avatar filename to mark as active
const bigAv = document.getElementById('big-avatar');
const img = bigAv.querySelector('img');
const curSrc = img ? img.src : '';
const curFile = curSrc.split('/').pop().split('?')[0];
grid.innerHTML = _avPresetsCache.map(f => `
<button class="av-preset${f === curFile ? ' active' : ''}" data-file="${LS.escapeHtml(f)}"
onclick="avPickPreset('${LS.escapeHtml(f)}', this)" title="Выбрать аватар">
<img src="/avatars/${LS.escapeHtml(f)}" alt="" loading="lazy">
</button>
`).join('');
}
/* ── Pick preset ── */
async function avPickPreset(filename, btn) {
if (btn.disabled) return;
btn.disabled = true;
try {
await LS.post('/api/avatar/preset', { filename });
const url = '/avatars/' + filename + '?t=' + Date.now();
// Update left-panel avatar
document.getElementById('big-avatar').innerHTML =
`<img src="${url}" alt="Аватар">`;
// Reset student status badge
document.getElementById('p-avatar-status').className = 'p-avatar-status';
// Update modal preview
document.getElementById('av-modal-cur').innerHTML =
`<img src="${url}" alt="Аватар">`;
document.getElementById('av-modal-status').className = 'av-status-row';
document.getElementById('av-del-btn').classList.add('visible');
// Mark active
document.querySelectorAll('.av-preset').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
// Persist avatar_url to local user cache + repaint sidebar.
const u = LS.getUser?.() || {};
LS.setUser?.({ ...u, avatar_url: filename });
LS.refreshNavAvatar?.();
LS.toast('Аватар обновлён', 'success');
if (LS.sfx) LS.sfx.play('success');
} catch (e) {
LS.toast(e.message || 'Ошибка', 'error');
} finally {
btn.disabled = false;
}
}
function avClose() {
document.getElementById('av-modal').classList.remove('open');
avBack();
if (LS.sfx) LS.sfx.play('modal_close');
}
function avModalClickOutside(e) {
if (e.target === document.getElementById('av-modal')) avClose();
}
/* ── Step 1: Drag & drop ── */
function avDragOver(e) { e.preventDefault(); document.getElementById('av-drop').classList.add('drag-over'); }
function avDragLeave() { document.getElementById('av-drop').classList.remove('drag-over'); }
function avDrop(e) {
e.preventDefault();
document.getElementById('av-drop').classList.remove('drag-over');
const f = e.dataTransfer.files[0];
if (f) avLoadFile(f);
}
function avFileChosen(inp) { if (inp.files[0]) avLoadFile(inp.files[0]); inp.value = ''; }
function avLoadFile(file) {
if (file.size > 2 * 1024 * 1024) { LS.toast('Файл слишком большой (макс. 2 МБ)', 'error'); return; }
if (!['image/png','image/jpeg','image/webp'].includes(file.type)) { LS.toast('Формат не поддерживается', 'error'); return; }
const reader = new FileReader();
reader.onload = ev => {
const img = new Image();
img.onload = () => {
_avImg = img;
_avZoom = 1;
_avOffX = 0;
_avOffY = 0;
document.getElementById('av-zoom').value = 100;
document.getElementById('av-s1').style.display = 'none';
document.getElementById('av-s2').style.display = 'flex';
avDraw();
avBindCanvas();
};
img.src = ev.target.result;
};
reader.readAsDataURL(file);
}
function avBack() {
document.getElementById('av-s1').style.display = 'flex';
document.getElementById('av-s2').style.display = 'none';
_avImg = null;
_avDrag = false;
}
/* Загрузить изображение в шаг кадрирования (из URL — для ИИ-генерации) */
function avLoadFromUrl(src) {
const img = new Image();
img.onload = () => {
_avImg = img; _avZoom = 1; _avOffX = 0; _avOffY = 0;
document.getElementById('av-zoom').value = 100;
document.getElementById('av-s1').style.display = 'none';
document.getElementById('av-s2').style.display = 'flex';
avDraw(); avBindCanvas();
};
img.onerror = () => LS.toast('Не удалось загрузить картинку', 'error');
img.src = src;
}
/* Сгенерировать аватар через ИИ → кадрирование → отправка на проверку */
function avGenerate() {
if (!LS.imagePromptModal) { LS.toast('Модуль генерации не загружен'); return; }
LS.imagePromptModal({
title: 'Сгенерировать аватар',
placeholder: 'Аватар: «дружелюбный лис в наушниках, плоский стиль, по центру»',
useLabel: 'Кадрировать',
onUse: (url) => avLoadFromUrl(url),
});
}
/* ── Step 2: Canvas crop ── */
function avDraw() {
const c = document.getElementById('av-canvas');
const ctx = c.getContext('2d');
const S = c.width; // 280
const r = S / 2 - 5;
if (!_avImg) return;
const minScale = S / Math.min(_avImg.naturalWidth, _avImg.naturalHeight);
const scale = minScale * _avZoom;
const iw = _avImg.naturalWidth * scale;
const ih = _avImg.naturalHeight * scale;
const ix = S / 2 - iw / 2 + _avOffX;
const iy = S / 2 - ih / 2 + _avOffY;
ctx.clearRect(0, 0, S, S);
// Dark background
ctx.fillStyle = '#1a1248';
ctx.fillRect(0, 0, S, S);
// Dimmed full image (outside circle)
ctx.globalAlpha = 0.22;
ctx.drawImage(_avImg, ix, iy, iw, ih);
ctx.globalAlpha = 1;
// Full-brightness image clipped to circle
ctx.save();
ctx.beginPath();
ctx.arc(S / 2, S / 2, r, 0, Math.PI * 2);
ctx.clip();
ctx.drawImage(_avImg, ix, iy, iw, ih);
ctx.restore();
// Dashed circle border
ctx.strokeStyle = 'rgba(155,93,229,0.8)';
ctx.lineWidth = 2;
ctx.setLineDash([6, 4]);
ctx.beginPath();
ctx.arc(S / 2, S / 2, r, 0, Math.PI * 2);
ctx.stroke();
ctx.setLineDash([]);
}
function avZoomChange(v) {
_avZoom = v / 100;
avDraw();
}
function avBindCanvas() {
const c = document.getElementById('av-canvas');
c.onmousedown = e => {
_avDrag = true;
_avDragStartX = e.clientX - _avOffX;
_avDragStartY = e.clientY - _avOffY;
e.preventDefault();
};
c.onmousemove = e => {
if (!_avDrag) return;
_avOffX = e.clientX - _avDragStartX;
_avOffY = e.clientY - _avDragStartY;
avDraw();
};
c.onmouseup = () => { _avDrag = false; };
c.onmouseleave = () => { _avDrag = false; };
c.onwheel = e => {
e.preventDefault();
_avZoom = Math.max(0.5, Math.min(3.5, _avZoom - e.deltaY * 0.0012));
document.getElementById('av-zoom').value = _avZoom * 100;
avDraw();
};
// Touch: drag + pinch-to-zoom
c.ontouchstart = e => {
e.preventDefault();
if (e.touches.length === 1) {
_avDrag = true;
_avDragStartX = e.touches[0].clientX - _avOffX;
_avDragStartY = e.touches[0].clientY - _avOffY;
} else if (e.touches.length === 2) {
_avDrag = false;
_avLastTouchDist = Math.hypot(
e.touches[0].clientX - e.touches[1].clientX,
e.touches[0].clientY - e.touches[1].clientY
);
}
};
c.ontouchmove = e => {
e.preventDefault();
if (e.touches.length === 1 && _avDrag) {
_avOffX = e.touches[0].clientX - _avDragStartX;
_avOffY = e.touches[0].clientY - _avDragStartY;
avDraw();
} else if (e.touches.length === 2) {
const d = Math.hypot(
e.touches[0].clientX - e.touches[1].clientX,
e.touches[0].clientY - e.touches[1].clientY
);
if (_avLastTouchDist) {
_avZoom = Math.max(0.5, Math.min(3.5, _avZoom * (d / _avLastTouchDist)));
document.getElementById('av-zoom').value = _avZoom * 100;
avDraw();
}
_avLastTouchDist = d;
}
};
c.ontouchend = () => { _avDrag = false; _avLastTouchDist = 0; };
}
/* ── Crop, render to 400×400, upload ── */
async function avSubmit() {
const btn = document.getElementById('av-btn-send');
btn.disabled = true;
btn.textContent = 'Отправка…';
try {
const S = 280;
const OUT = 400;
const out = document.createElement('canvas');
out.width = out.height = OUT;
const oc = out.getContext('2d');
const minScale = S / Math.min(_avImg.naturalWidth, _avImg.naturalHeight);
const ratio = OUT / S;
const scale = minScale * _avZoom * ratio;
const iw = _avImg.naturalWidth * scale;
const ih = _avImg.naturalHeight * scale;
const ix = OUT / 2 - iw / 2 + _avOffX * ratio;
const iy = OUT / 2 - ih / 2 + _avOffY * ratio;
oc.beginPath();
oc.arc(OUT / 2, OUT / 2, OUT / 2, 0, Math.PI * 2);
oc.clip();
oc.drawImage(_avImg, ix, iy, iw, ih);
out.toBlob(async blob => {
try {
const fd = new FormData();
fd.append('avatar', blob, 'avatar.jpg');
const token = LS.getToken();
const res = await fetch('/api/avatar/request', {
method: 'POST',
headers: token ? { Authorization: 'Bearer ' + token } : {},
body: fd,
});
if (!res.ok) throw new Error();
const blobUrl = URL.createObjectURL(blob);
// Update left-panel avatar (pending opacity)
document.getElementById('big-avatar').innerHTML =
`<img src="${blobUrl}" alt="Аватар" style="opacity:.55">`;
const pSt = document.getElementById('p-avatar-status');
pSt.className = 'p-avatar-status pending';
pSt.textContent = 'На проверке';
// Update modal preview
const preEl = document.getElementById('av-modal-cur');
preEl.innerHTML = `<img src="${blobUrl}" alt="Аватар" style="opacity:.55">`;
document.getElementById('av-del-btn').classList.add('visible');
LS.toast('Аватар отправлен на проверку', 'success');
if (LS.sfx) LS.sfx.play('success');
avBack();
avLoadStatus();
} catch {
LS.toast('Ошибка загрузки', 'error');
} finally {
btn.disabled = false;
btn.textContent = 'Отправить на проверку';
}
}, 'image/jpeg', 0.92);
} catch {
LS.toast('Ошибка обработки изображения', 'error');
btn.disabled = false;
btn.textContent = 'Отправить на проверку';
}
}
/* ── Delete avatar ── */
async function avDelete() {
if (!confirm('Удалить аватар?')) return;
try {
const token = LS.getToken();
const res = await fetch('/api/avatar/me', {
method: 'DELETE',
headers: token ? { Authorization: 'Bearer ' + token } : {},
});
if (!res.ok) throw new Error();
const initials = (document.getElementById('profile-name').textContent || 'LS')
.split(' ').slice(0, 2).map(w => w[0]?.toUpperCase() || '').join('') || 'LS';
const bigAv = document.getElementById('big-avatar');
bigAv.innerHTML = '';
bigAv.textContent = initials;
const preEl = document.getElementById('av-modal-cur');
preEl.innerHTML = '';
preEl.textContent = initials;
document.getElementById('p-avatar-status').className = 'p-avatar-status';
document.getElementById('av-del-btn').classList.remove('visible');
document.getElementById('av-modal-status').className = 'av-status-row';
// Reset cached avatar_url + repaint sidebar to initials.
const u = LS.getUser?.() || {};
LS.setUser?.({ ...u, avatar_url: null });
LS.refreshNavAvatar?.();
LS.toast('Аватар удалён', 'success');
avClose();
} catch {
LS.toast('Ошибка удаления', 'error');
}
}
/* ── Load moderation status into modal ── */
async function avLoadStatus() {
try {
const data = await LS.get('/api/avatar/my-status');
const el = document.getElementById('av-modal-status');
if (!data.request || data.request.status === 'approved') {
el.className = 'av-status-row'; return;
}
const r = data.request;
if (r.status === 'pending') {
el.className = 'av-status-row pending';
el.textContent = 'Фото отправлено — ожидает проверки';
} else if (r.status === 'rejected') {
el.className = 'av-status-row rejected';
el.textContent = r.reject_msg ? 'Отклонено: ' + r.reject_msg : 'Фото отклонено';
}
} catch {}
}
</script>
<script src="/js/search.js"></script>
<script src="/js/mobile.js"></script>
<!-- ══ Avatar Modal ══ -->
<div class="av-ovl" id="av-modal" onclick="avModalClickOutside(event)">
<div class="av-dlg" onclick="event.stopPropagation()">
<!-- Header -->
<div class="av-hdr">
<span class="av-title">
<span class="av-title-dot"></span>
Аватар профиля
</span>
<button class="av-close" onclick="avClose()" aria-label="Закрыть">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<!-- Step 1: Select / Upload -->
<div class="av-step" id="av-s1">
<!-- Current avatar (large) -->
<div class="av-cur" id="av-modal-cur">LS</div>
<!-- Status (pending / rejected) -->
<div class="av-status-row" id="av-modal-status"></div>
<!-- Preset gallery -->
<div class="av-preset-hd">Готовые аватары</div>
<div class="av-preset-grid" id="av-preset-grid"></div>
<!-- Custom upload (student only) -->
<div id="av-upload-block" style="width:100%;display:none;flex-direction:column;align-items:center;gap:14px">
<div class="av-or">или загрузите своё</div>
<div class="av-drop" id="av-drop"
ondragover="avDragOver(event)" ondragleave="avDragLeave(event)" ondrop="avDrop(event)"
onclick="document.getElementById('av-file-inp').click()" role="button" tabindex="0"
onkeydown="if(event.key==='Enter'||event.key===' ')this.click()">
<svg class="av-drop-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="17 8 12 3 7 8"/>
<line x1="12" y1="3" x2="12" y2="15"/>
</svg>
<div class="av-drop-txt">Перетащите фото или нажмите для выбора</div>
<div class="av-drop-sub">PNG, JPG, WebP · до 2 МБ · с модерацией</div>
<input type="file" id="av-file-inp" accept="image/png,image/jpeg,image/webp"
style="display:none" onchange="avFileChosen(this)">
</div>
<div class="av-or">или нарисуйте ИИ</div>
<button class="av-btn-send" type="button" onclick="avGenerate()" style="width:100%">Сгенерировать аватар (ИИ)</button>
</div>
<!-- Delete current avatar -->
<button class="av-del-btn" id="av-del-btn" onclick="avDelete()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="3 6 5 6 21 6"/>
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/>
<path d="M10 11v6"/><path d="M14 11v6"/>
</svg>
Удалить аватар
</button>
</div>
<!-- Step 2: Crop / Zoom -->
<div class="av-step" id="av-s2" style="display:none">
<canvas id="av-canvas" width="280" height="280"></canvas>
<div class="av-zoom-row">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:15px;height:15px">
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/><line x1="8" y1="11" x2="14" y2="11"/>
</svg>
<input type="range" id="av-zoom" min="100" max="350" value="100" oninput="avZoomChange(this.value)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:15px;height:15px">
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/><line x1="8" y1="11" x2="14" y2="11"/><line x1="11" y1="8" x2="11" y2="14"/>
</svg>
</div>
<div class="av-crop-hint">Перетащите для позиционирования · колёсико / слайдер для масштаба</div>
<div class="av-crop-btns">
<button class="av-btn-back" onclick="avBack()">Назад</button>
<button class="av-btn-send" id="av-btn-send" onclick="avSubmit()">Отправить на проверку</button>
</div>
</div>
</div>
</div>
</body>
</html>