Files
Learn_System/frontend/profile.html
T
Maxim Dolgolyov be4d43105e LearnSpace: full-stack educational whiteboard platform
Node.js/Express backend + vanilla JS frontend.
Features: real-time collaborative whiteboard (SSE), multi-page support,
LaTeX formulas, shapes/connectors, coordinate systems, number lines,
compass, zoom/pan, Catmull-Rom pencil smoothing, ruler/protractor with
rotation & resize controls, minimap navigation overlay, auto-measurements,
multi-page thumbnails sidebar, PNG export, page templates.
Student/teacher workflows: classes, assignments, library, dashboard.
Mobile responsive. SQLite (better-sqlite3).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 10:10:37 +03:00

1382 lines
64 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">
<div class="sb-brand">
<a href="/dashboard" class="sb-logo"><span class="sb-lbl">Learn<span>Space</span></span></a>
<button class="sb-toggle" title="Свернуть меню"><i data-lucide="chevron-left" class="sb-icon"></i></button>
</div>
<nav class="sb-nav">
<button class="sb-link" onclick="lsSearchOpen()" title="Ctrl+K"><i data-lucide="search" class="sb-icon"></i><span class="sb-lbl">Поиск</span></button>
<a href="/dashboard" class="sb-link"><i data-lucide="home" class="sb-icon"></i><span class="sb-lbl">Дашборд</span></a>
<a href="/board" class="sb-link" id="sbl-board" style="display:none"><i data-lucide="layout-dashboard" class="sb-icon"></i><span class="sb-lbl">Доска</span></a>
<a href="/classes" class="sb-link" id="sbl-classes" style="display:none"><i data-lucide="graduation-cap" class="sb-icon"></i><span class="sb-lbl">Классы</span></a>
<a href="/library" class="sb-link"><i data-lucide="book-open" class="sb-icon"></i><span class="sb-lbl">Библиотека</span></a>
<a href="/theory" class="sb-link"><i data-lucide="brain" class="sb-icon"></i><span class="sb-lbl">Теория</span></a>
<a href="/lab" class="sb-link"><i data-lucide="atom" class="sb-icon"></i><span class="sb-lbl">Лаборатория</span></a>
<a href="/biochem" class="sb-link"><i data-lucide="flask-conical" class="sb-icon"></i><span class="sb-lbl">Биохимия</span></a>
<a href="/hangman" class="sb-link"><i data-lucide="gamepad-2" class="sb-icon"></i><span class="sb-lbl">Виселица</span></a>
<a href="/crossword" class="sb-link"><i data-lucide="grid-3x3" class="sb-icon"></i><span class="sb-lbl">Кроссворд</span></a>
<a href="/pet" class="sb-link"><i data-lucide="heart" class="sb-icon"></i><span class="sb-lbl">Питомец</span></a>
<a href="/collection" class="sb-link"><i data-lucide="layers" class="sb-icon"></i><span class="sb-lbl">Коллекция</span></a>
<a href="/knowledge-map" class="sb-link"><i data-lucide="share-2" class="sb-icon"></i><span class="sb-lbl">Карта знаний</span></a>
<a href="/red-book.html" class="sb-link"><i data-lucide="leaf" class="sb-icon"></i><span class="sb-lbl">Красная книга</span></a>
<a href="/classroom" class="sb-link"><i data-lucide="presentation" class="sb-icon"></i><span class="sb-lbl">Онлайн-урок</span></a>
<div class="sb-divider"></div>
<a href="/analytics" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="bar-chart-2" class="sb-icon"></i><span class="sb-lbl">Аналитика</span></a>
<a href="/question-bank" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="database" class="sb-icon"></i><span class="sb-lbl">Банк вопросов</span></a>
<a href="/live-quiz" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="radio" class="sb-icon"></i><span class="sb-lbl">Live-квиз</span></a>
<a href="/gradebook" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="table" class="sb-icon"></i><span class="sb-lbl">Журнал</span></a>
<a href="/admin" class="sb-link" id="sbl-admin" style="display:none"><i data-lucide="settings" class="sb-icon"></i><span class="sb-lbl">Управление</span></a>
</nav>
<div class="sb-foot">
<a href="/profile" class="sb-user-row" style="text-decoration:none">
<div class="sb-avatar" id="nav-avatar">?</div>
<div class="sb-user-info">
<div class="sb-user-name" id="nav-user"></div>
<span class="sb-logout" style="pointer-events:none">Мой профиль</span>
</div>
</a>
</div>
</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>
const { user, isTeacher, isAdmin } = LS.initPage();
LS.showBoardIfAllowed();
if (isTeacher) {
document.getElementById('sbl-classes').style.display = '';
if (isAdmin) document.getElementById('sbl-admin').style.display = '';
}
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>