6fcdafed50
Питомец: кастомный фон (миграция 068 pet_bg_custom, POST /api/pet/bg/custom, карточка «Свой фон (ИИ)» в гардеробной, применение картинкой). Курсы: обложка-картинка (миграция 069 cover_image, генерация в модалке редактирования, рендер вместо эмодзи). Аватар: кнопка «Сгенерировать (ИИ)» в загрузке → кадрирование → модерация. Доска (classroom): кнопка-инструмент «Сгенерировать картинку (ИИ)». Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2689 lines
120 KiB
HTML
2689 lines
120 KiB
HTML
<!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 => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[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>
|