Files
Learn_System/frontend/profile.html
T
Maxim Dolgolyov edb4c211a0 feat: universal sidebar via sidebar.js + stale ID cleanup
- 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>
2026-04-13 21:22:21 +03:00

1344 lines
60 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Профиль — LearnSpace</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link href="https://fonts.googleapis.com/css2?family=Unbounded:wght@400;700;800&family=Manrope:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/css/ls.css" />
<style>
html, body { height: 100%; overflow: hidden; }
.app-layout { height: 100vh; overflow: hidden; }
.sb-content { height: 100vh; overflow: hidden; display: flex; align-items: stretch; }
.profile-layout {
display: flex; flex: 1; gap: 0; overflow: hidden;
padding: 20px 20px 20px 0;
}
/* ══════════ LEFT PANEL ══════════ */
.p-left {
width: 290px; flex-shrink: 0;
background: linear-gradient(170deg, #0d0b28 0%, #1a1248 45%, #0f1635 100%);
border-radius: 22px;
display: flex; flex-direction: column;
position: relative; overflow: hidden;
}
/* decorative blobs */
.p-left-blob1 {
position: absolute; width: 300px; height: 300px; border-radius: 50%;
background: radial-gradient(circle, rgba(155,93,229,0.35) 0%, transparent 70%);
top: -80px; right: -80px; pointer-events: none;
}
.p-left-blob2 {
position: absolute; width: 200px; height: 200px; border-radius: 50%;
background: radial-gradient(circle, rgba(6,214,224,0.15) 0%, transparent 70%);
bottom: 40px; left: -40px; pointer-events: none;
}
.p-left-dots {
position: absolute; inset: 0; pointer-events: none;
background-image: radial-gradient(circle, rgba(255,255,255,0.04) 1px, transparent 1px);
background-size: 18px 18px;
}
/* top accent line */
.p-left-accent {
position: absolute; top: 0; left: 0; right: 0; height: 3px;
background: linear-gradient(90deg, #9B5DE5, #06D6E0);
border-radius: 22px 22px 0 0;
}
/* inner scroll area */
.p-left-inner {
position: relative; z-index: 1;
display: flex; flex-direction: column;
padding: 28px 22px 22px; flex: 1; overflow: hidden;
}
/* ── Avatar ── */
.p-avatar-wrap {
position: relative; width: 80px; height: 80px; flex-shrink: 0; margin-bottom: 16px;
}
.p-avatar-ring {
position: absolute; inset: -3px; border-radius: 50%;
background: 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>