edb4c211a0
- Add js/sidebar.js: generates full sidebar HTML into #app-sidebar, handles role-based visibility, active link (with prefix matching), toggle wiring, collapsed state, board/features/notif init - Replace <aside class="sidebar">...</aside> with <aside id="app-sidebar"> across all 35 standard-layout pages via scripts/apply-sidebar.js - Add notifications.js to 5 pages that were missing it - Fix api.js initPage(): skip toggle re-wiring if data-sb-wired set, fix active link selector .sb-item → .sb-link - Remove stale sbl-*/nav-admin/btn-upload-nav getElementById calls that crashed after sidebar replacement (lab, classes, collection, crossword, hangman, knowledge-map, library, pet, profile) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1344 lines
60 KiB
HTML
1344 lines
60 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: linear-gradient(135deg, #9B5DE5, #06D6E0);
|
||
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;
|
||
}
|
||
|
||
.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: linear-gradient(135deg, #9B5DE5, #06D6E0);
|
||
}
|
||
.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: linear-gradient(135deg, #9B5DE5, #06D6E0);
|
||
-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; }
|
||
|
||
/* ── 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: #8898AA; 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: #9B5DE5; }
|
||
.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: #06D6E0; }
|
||
.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: linear-gradient(135deg, #9B5DE5, #06D6E0);
|
||
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 ── */
|
||
.ach-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 12px; }
|
||
.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: linear-gradient(135deg, #9B5DE5, #06D6E0);
|
||
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: #9B5DE5; 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: linear-gradient(135deg, #9B5DE5, #06D6E0);
|
||
display: flex; align-items: center; justify-content: center;
|
||
font-family: 'Unbounded', sans-serif; font-size: 0.7rem; font-weight: 800; color: #fff;
|
||
}
|
||
.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); }
|
||
.shop-item.owned { border-color: rgba(34,197,94,0.4); background: rgba(34,197,94,0.03); }
|
||
.shop-item.disabled:not(.owned) { opacity: 0.5; }
|
||
.shop-item-icon { color: var(--violet); margin-bottom: 4px; }
|
||
.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.4); background: rgba(6,214,224,0.04); }
|
||
.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; }
|
||
</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">
|
||
<div class="p-avatar-ring"><div class="p-avatar-ring-inner"></div></div>
|
||
<div class="p-avatar" id="big-avatar">LS</div>
|
||
</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-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:#0F172A;margin-bottom:10px">Рамки аватара</div>
|
||
<div class="frames-grid" id="frames-grid"></div>
|
||
</div>
|
||
<div class="ach-grid" id="ach-grid"></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="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: Безопасность -->
|
||
<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/sidebar.js"></script>
|
||
<script src="/js/notifications.js"></script>
|
||
<script>
|
||
const { user, isTeacher, isAdmin } = LS.initPage();
|
||
LS.showBoardIfAllowed();
|
||
|
||
|
||
|
||
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';
|
||
document.getElementById('big-avatar').textContent = initials;
|
||
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 {}
|
||
}
|
||
|
||
/* ── 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;
|
||
document.getElementById('nav-avatar').textContent = ini;
|
||
} catch(e) { showMsg(msg, 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, 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 === '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 => {
|
||
const owned = !!item.owned;
|
||
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>`;
|
||
}
|
||
return `<div class="shop-item ${owned ? 'owned' : ''} ${active ? 'active' : ''} ${!canBuy && !owned ? 'disabled' : ''}">
|
||
<div class="shop-item-icon">${LS.icon(item.icon || 'star', 28)}</div>
|
||
<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('');
|
||
}
|
||
|
||
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>` : '');
|
||
// Grid
|
||
document.getElementById('ach-grid').innerHTML = achs.map(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>`
|
||
: '';
|
||
return `<div class="ach-item ${cls}" data-cat="${a.category || 'start'}">
|
||
<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)}</div>
|
||
<div class="ach-desc">${esc(a.description)}</div>
|
||
${dateStr}
|
||
</div>
|
||
</div>`;
|
||
}).join('');
|
||
} catch {}
|
||
}
|
||
|
||
/* ── Avatar Frames ── */
|
||
async function loadFrames() {
|
||
try {
|
||
const data = await LS.getFrames();
|
||
if (!data || !data.frames) return;
|
||
const grid = document.getElementById('frames-grid');
|
||
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}>LS</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:#8898AA;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:#8898AA;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:#8898AA;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();
|
||
|
||
loadProfile();
|
||
if (window.lucide) lucide.createIcons();
|
||
</script>
|
||
<script src="/js/search.js"></script>
|
||
<script src="/js/mobile.js"></script>
|
||
</body>
|
||
</html>
|