Files
WhatIDo/student-profile.html
T
2026-09-19 13:06:21 +03:00

1394 lines
80 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" data-theme="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Профиль ученика — Академия</title>
<meta name="description" content="Страница профиля ученика: статистика, образование, опыт, навыки, проекты и пройденные уроки." />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@500&display=swap" rel="stylesheet" />
<!--
============================================================================
СТРАНИЦА ПРОФИЛЯ УЧЕНИКА (Student Profile)
============================================================================
Всё в одном файле: HTML + CSS + JS.
Каждый крупный блок помечен атрибутом data-component="<ИмяКомпонента>".
Эти имена = имена будущих компонентов для переноса в проект на Vue:
data-component="AppTopbar" -> components/layout/AppTopbar.vue
props: user, nav[], theme, onToggleTheme
data-component="ProfileHero" -> components/profile/ProfileHero.vue
props: student
data-component="AuthorStats" -> components/profile/AuthorStats.vue
props: stats[] (v-for по stats)
data-component="EducationTimeline" -> components/profile/EducationTimeline.vue
props: modules[]
data-component="WorkExperience" -> components/profile/WorkExperience.vue
props: experience[]
data-component="SkillsCloud" -> components/profile/SkillsCloud.vue
props: skillGroups[]
data-component="ProjectsRail" -> components/profile/ProjectsRail.vue
props: projects[]
data-component="CompletedLessons" -> components/profile/CompletedLessons.vue
props: tasks[]
data-component="AboutAuthor" -> components/profile/AboutAuthor.vue
props: about
data-component="AppFooter" -> components/layout/AppFooter.vue
Все данные страницы лежат в одном объекте PROFILE_DATA (см. конец файла).
Для переноса: разметку блока берём как template, массив из PROFILE_DATA —
как props и разворачиваем через v-for. Иконки <i data-lucide="name"> уже
в формате Lucide (в Vue: lucide-vue-next / <component :is="icon" />).
Кнопка "Компоненты" в топбаре включает режим подсветки блоков —
видно границы и имена компонентов для дальнейшего внедрения.
============================================================================
-->
<style>
/* ==========================================================================
1. ТОКЕНЫ ДИЗАЙН-СИСТЕМЫ (в Vue вынести в assets/styles/tokens.css)
========================================================================== */
:root{
--bg:#080c1a;
--bg-soft:#0d1424;
--surface:rgba(255,255,255,.045);
--surface-2:rgba(255,255,255,.03);
--surface-hover:rgba(255,255,255,.08);
--stroke:rgba(255,255,255,.09);
--stroke-strong:rgba(255,255,255,.22);
--text:#e9eeff;
--muted:#96a3c6;
--brand:#6d8cff;
--brand-2:#a855f7;
--accent:#22d3ee;
--ok:#34d399;
--warn:#fbbf24;
--danger:#fb7185;
--topbar-bg:rgba(8,12,26,.78);
--radius:22px;
--radius-sm:14px;
--radius-xs:10px;
--gap:18px;
--max:1440px;
--shadow:0 22px 60px rgba(3,7,22,.5);
--shadow-soft:0 10px 30px rgba(3,7,22,.35);
}
[data-theme="light"]{
--bg:#f2f5ff;
--bg-soft:#ffffff;
--surface:rgba(255,255,255,.92);
--surface-2:rgba(15,23,50,.035);
--surface-hover:#eef2ff;
--stroke:rgba(20,30,70,.10);
--stroke-strong:rgba(20,30,70,.24);
--text:#101733;
--muted:#5a678b;
--brand:#3f5df0;
--brand-2:#8b3ff0;
--accent:#0891b2;
--ok:#059669;
--warn:#b45309;
--danger:#e11d48;
--topbar-bg:rgba(255,255,255,.82);
--shadow:0 22px 55px rgba(30,45,95,.14);
--shadow-soft:0 10px 26px rgba(30,45,95,.10);
}
/* ==========================================================================
2. БАЗА / СБРОС
========================================================================== */
*,*::before,*::after{box-sizing:border-box}
*{margin:0;padding:0}
html{scroll-behavior:smooth;scroll-padding-top:96px}
body{
min-height:100vh;
font-family:'Inter',system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;
font-size:16px;
line-height:1.55;
color:var(--text);
background-color:var(--bg);
background-image:
radial-gradient(1100px 620px at 12% -8%,rgba(109,140,255,.24),transparent 62%),
radial-gradient(900px 520px at 92% -4%,rgba(168,85,247,.20),transparent 58%),
radial-gradient(800px 600px at 60% 108%,rgba(34,211,238,.12),transparent 60%);
background-attachment:fixed;
-webkit-font-smoothing:antialiased;
text-rendering:optimizeLegibility;
}
a{color:inherit;text-decoration:none}
button{font:inherit;color:inherit;background:none;border:0;cursor:pointer}
ul,ol{list-style:none}
svg{display:block;flex:none}
::selection{background:rgba(109,140,255,.35)}
:focus-visible{outline:2px solid var(--accent);outline-offset:3px;border-radius:6px}
::-webkit-scrollbar{width:10px;height:10px}
::-webkit-scrollbar-track{background:transparent}
::-webkit-scrollbar-thumb{background:var(--stroke-strong);border-radius:999px}
::-webkit-scrollbar-thumb:hover{background:var(--brand)}
@media (prefers-reduced-motion:reduce){
*{animation-duration:.001ms !important;transition-duration:.001ms !important;scroll-behavior:auto !important}
}
/* ==========================================================================
3. ЛЕЙАУТ
========================================================================== */
.wrap{width:100%;max-width:var(--max);margin:0 auto;padding:0 24px}
@media (max-width:640px){.wrap{padding:0 14px}}
.page{padding-top:clamp(20px,3vw,38px);padding-bottom:90px;display:flex;flex-direction:column;gap:clamp(26px,3.4vw,52px)}
.section{display:flex;flex-direction:column;gap:18px;scroll-margin-top:96px;min-width:0}
.section__head{display:flex;align-items:flex-end;justify-content:space-between;gap:16px;flex-wrap:wrap}
.section__head>*{min-width:0}
.section__title{display:flex;align-items:center;gap:14px;font-size:clamp(1.3rem,2.2vw,1.95rem);font-weight:800;letter-spacing:-.02em;line-height:1.15;flex-wrap:wrap;min-width:0}
.section__hint{color:var(--muted);font-size:.95rem;max-width:70ch}
.icon-badge{
width:46px;height:46px;flex:none;border-radius:var(--radius-sm);
display:grid;place-items:center;
background:linear-gradient(140deg,rgba(109,140,255,.26),rgba(168,85,247,.22));
border:1px solid var(--stroke);color:var(--text);
box-shadow:inset 0 1px 0 rgba(255,255,255,.08);
}
.icon-badge svg{width:22px;height:22px;stroke-width:2}
.card{
background:var(--surface);
border:1px solid var(--stroke);
border-radius:var(--radius);
box-shadow:var(--shadow);
backdrop-filter:blur(16px);
-webkit-backdrop-filter:blur(16px);
}
/* ==========================================================================
4. ТОПБАР (AppTopbar)
========================================================================== */
.topbar{
position:sticky;top:0;z-index:60;
background:var(--topbar-bg);
border-bottom:1px solid var(--stroke);
backdrop-filter:blur(20px) saturate(160%);
-webkit-backdrop-filter:blur(20px) saturate(160%);
}
.topbar__inner{max-width:var(--max);margin:0 auto;padding:12px 24px;display:flex;align-items:center;gap:14px}
@media (max-width:640px){.topbar__inner{padding:10px 14px}}
.brand{display:flex;align-items:center;gap:12px;font-weight:800;letter-spacing:-.02em}
.brand__logo{
width:44px;height:44px;border-radius:14px;display:grid;place-items:center;
background:linear-gradient(135deg,var(--brand),var(--brand-2));color:#fff;
box-shadow:0 10px 24px rgba(109,140,255,.35);
}
.brand__logo svg{width:22px;height:22px;stroke-width:2.2}
.brand__text small{display:block;font-size:.72rem;font-weight:600;color:var(--muted);letter-spacing:.06em;text-transform:uppercase}
@media (max-width:540px){.brand__text{display:none}}
.topbar__nav{display:flex;align-items:center;gap:4px;flex:1;justify-content:center}
.nav-link{
display:flex;align-items:center;gap:8px;padding:10px 15px;border-radius:999px;
font-size:.94rem;font-weight:600;color:var(--muted);white-space:nowrap;
transition:background .2s ease,color .2s ease;
}
.nav-link svg{width:17px;height:17px;stroke-width:2}
.nav-link:hover{background:var(--surface-hover);color:var(--text)}
.nav-link[aria-current="true"]{
background:linear-gradient(120deg,rgba(109,140,255,.22),rgba(168,85,247,.18));
color:var(--text);box-shadow:inset 0 0 0 1px var(--stroke);
}
.topbar__actions{display:flex;align-items:center;gap:8px;margin-left:auto}
.icon-btn{
width:44px;height:44px;border-radius:14px;display:grid;place-items:center;
background:var(--surface);border:1px solid var(--stroke);color:var(--text);
transition:background .2s ease,transform .2s ease,border-color .2s ease;
}
.icon-btn svg{width:19px;height:19px;stroke-width:2}
.icon-btn:hover{background:var(--surface-hover);border-color:var(--stroke-strong);transform:translateY(-2px)}
.icon-btn[aria-pressed="true"]{background:linear-gradient(120deg,rgba(109,140,255,.3),rgba(168,85,247,.26));border-color:var(--stroke-strong)}
.user-chip{
display:flex;align-items:center;gap:10px;padding:6px 14px 6px 6px;border-radius:999px;
background:var(--surface);border:1px solid var(--stroke);font-weight:700;font-size:.9rem;
}
.user-chip__ph{
width:34px;height:34px;border-radius:50%;display:grid;place-items:center;flex:none;
background:linear-gradient(135deg,rgba(109,140,255,.3),rgba(168,85,247,.28));
border:1px dashed var(--stroke-strong);color:var(--muted);
}
.user-chip__ph svg{width:16px;height:16px}
@media (max-width:1000px){.user-chip__name{display:none}}
.topbar__burger{display:none}
@media (max-width:1080px){
.topbar__nav{
position:absolute;top:100%;left:0;right:0;flex-direction:column;align-items:stretch;
padding:10px 16px 18px;gap:4px;display:none;
background:var(--bg-soft);border-bottom:1px solid var(--stroke);box-shadow:var(--shadow);
}
.topbar__nav.is-open{display:flex}
.nav-link{padding:14px 16px;border-radius:var(--radius-sm);font-size:1rem}
.topbar__burger{display:grid}
}
/* ==========================================================================
5. ОБЩИЕ ЭЛЕМЕНТЫ: кнопки, чипы, бейджи, плейсхолдеры
========================================================================== */
.btn{
display:inline-flex;align-items:center;justify-content:center;gap:10px;
padding:13px 20px;border-radius:var(--radius-sm);font-weight:700;font-size:.95rem;
border:1px solid var(--stroke);background:var(--surface);color:var(--text);
transition:background .2s ease,transform .2s ease,border-color .2s ease,box-shadow .2s ease;
}
.btn svg{width:18px;height:18px;stroke-width:2}
.btn:hover{background:var(--surface-hover);border-color:var(--stroke-strong);transform:translateY(-2px)}
.btn--primary{
background:linear-gradient(120deg,var(--brand),var(--brand-2));color:#fff;border-color:transparent;
box-shadow:0 14px 30px rgba(109,140,255,.32);
}
.btn--primary:hover{box-shadow:0 18px 40px rgba(109,140,255,.42)}
.btn--ghost{background:transparent}
.btn--sm{padding:9px 14px;font-size:.85rem;border-radius:var(--radius-xs)}
.btn--sm svg{width:16px;height:16px}
.btn[disabled]{opacity:.45;cursor:not-allowed;transform:none}
.chip{
display:inline-flex;align-items:center;gap:7px;padding:7px 13px;border-radius:999px;
background:var(--surface-2);border:1px solid var(--stroke);color:var(--muted);
font-size:.82rem;font-weight:600;white-space:nowrap;
}
.chip svg{width:14px;height:14px;stroke-width:2.2}
.chip--brand{color:var(--text);background:linear-gradient(120deg,rgba(109,140,255,.18),rgba(168,85,247,.14))}
.badge{
display:inline-flex;align-items:center;gap:6px;padding:5px 11px;border-radius:999px;
font-size:.74rem;font-weight:800;letter-spacing:.02em;text-transform:uppercase;
border:1px solid var(--stroke);
}
.badge svg{width:13px;height:13px;stroke-width:2.4}
.badge--ok{color:var(--ok);background:rgba(52,211,153,.14);border-color:rgba(52,211,153,.36)}
.badge--progress{color:var(--warn);background:rgba(251,191,36,.14);border-color:rgba(251,191,36,.36)}
.badge--info{color:var(--accent);background:rgba(34,211,238,.14);border-color:rgba(34,211,238,.36)}
.badge--muted{color:var(--muted);background:var(--surface-2)}
.meta{display:flex;align-items:center;gap:8px;color:var(--muted);font-size:.84rem;font-weight:600;flex-wrap:wrap}
.meta svg{width:15px;height:15px;stroke-width:2}
.meta__dot{width:4px;height:4px;border-radius:50%;background:var(--stroke-strong);flex:none}
/* Плейсхолдер вместо фотографии (пустышка ровно в размер будущего кадра) */
.ph{
position:relative;display:grid;place-items:center;gap:8px;overflow:hidden;
background-color:var(--surface-2);
background-image:
repeating-linear-gradient(45deg,transparent 0 12px,rgba(127,150,255,.07) 12px 24px),
linear-gradient(135deg,rgba(109,140,255,.16),rgba(168,85,247,.14));
border:1px dashed var(--stroke-strong);color:var(--muted);text-align:center;padding:14px;
}
.ph__icon{width:clamp(26px,3.4vw,42px);height:clamp(26px,3.4vw,42px);stroke-width:1.6;opacity:.85}
.ph__label{font-size:.8rem;font-weight:700;letter-spacing:.05em;text-transform:uppercase}
.ph__size{
position:absolute;right:12px;bottom:10px;padding:3px 9px;border-radius:999px;
font-family:'JetBrains Mono',ui-monospace,monospace;font-size:.7rem;font-weight:500;
background:rgba(6,10,24,.55);color:#e4eaff;border:1px solid var(--stroke);
}
[data-theme="light"] .ph__size{background:rgba(255,255,255,.86);color:#31386a}
.ph--square{aspect-ratio:1/1}
.ph--16x9{aspect-ratio:16/9}
.ph--4x3{aspect-ratio:4/3}
.ph--wide{aspect-ratio:21/9}
/* ==========================================================================
6. HERO: фото + имя (ProfileHero)
========================================================================== */
.hero{padding:clamp(20px,2.6vw,34px);overflow:hidden;position:relative}
.hero__cover{border-radius:var(--radius-sm);margin-bottom:clamp(-72px,-5vw,-48px)}
.hero__grid{
display:grid;grid-template-columns:minmax(240px,380px) minmax(0,1fr);
gap:clamp(18px,2.6vw,34px);align-items:end;
}
@media (max-width:900px){
.hero__grid{grid-template-columns:1fr;align-items:start}
.hero__cover{display:none}
}
.hero__media{position:relative;padding-top:clamp(10px,2vw,26px)}
.hero__avatar{border-radius:26px;box-shadow:var(--shadow)}
.hero__status{
position:absolute;left:14px;bottom:14px;padding:7px 12px;border-radius:999px;
background:rgba(6,10,24,.6);backdrop-filter:blur(8px);border:1px solid var(--stroke);
color:#eaf0ff;font-size:.78rem;font-weight:800;display:inline-flex;align-items:center;gap:7px;
}
.hero__status svg{width:14px;height:14px;color:var(--ok);stroke-width:2.6}
.hero__pulse{width:8px;height:8px;border-radius:50%;background:var(--ok);box-shadow:0 0 0 0 rgba(52,211,153,.7);animation:pulse 2s infinite}
@keyframes pulse{70%{box-shadow:0 0 0 12px rgba(52,211,153,0)}100%{box-shadow:0 0 0 0 rgba(52,211,153,0)}}
.hero__info{display:flex;flex-direction:column;gap:14px;min-width:0}
.hero__eyebrow{display:flex;align-items:center;gap:10px;color:var(--muted);font-size:.82rem;font-weight:700;letter-spacing:.12em;text-transform:uppercase}
.hero__name{font-size:clamp(1.9rem,4.4vw,3.4rem);font-weight:800;letter-spacing:-.035em;line-height:1.05}
.hero__role{font-size:clamp(1rem,1.7vw,1.3rem);font-weight:600;color:var(--muted);max-width:60ch}
.hero__tags{display:flex;flex-wrap:wrap;gap:8px}
.hero__actions{display:flex;flex-wrap:wrap;gap:10px;margin-top:4px}
.hero__facts{display:grid;grid-template-columns:repeat(auto-fit,minmax(190px,1fr));gap:12px;margin-top:8px}
@media (max-width:640px){.hero__facts{grid-template-columns:repeat(2,minmax(0,1fr))}}
.fact{display:flex;align-items:center;gap:10px;padding:12px 14px;border-radius:var(--radius-sm);background:var(--surface-2);border:1px solid var(--stroke);min-width:0}
.fact svg{width:18px;height:18px;color:var(--brand);flex:none}
.fact span{font-weight:600;font-size:.9rem;color:var(--muted);overflow-wrap:anywhere}
/* ==========================================================================
7. СТАТИСТИКА АВТОРА (AuthorStats) — минимум 2 карточки в ряд
========================================================================== */
.stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(210px,1fr));gap:var(--gap)}
@media (max-width:620px){.stats{grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}}
.stat{padding:clamp(14px,1.6vw,20px);display:flex;flex-direction:column;gap:10px;min-width:0}
.stat__top{display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap}
.stat__icon{
width:46px;height:46px;border-radius:var(--radius-sm);display:grid;place-items:center;flex:none;
background:linear-gradient(140deg,rgba(109,140,255,.22),rgba(168,85,247,.18));
border:1px solid var(--stroke);color:var(--brand);
}
@media (max-width:620px){.stat__icon{width:38px;height:38px}}
.stat__icon svg{width:21px;height:21px;stroke-width:2}
.stat__value{font-size:clamp(1.7rem,3.2vw,2.6rem);font-weight:800;letter-spacing:-.04em;line-height:1}
.stat__value small{font-size:.5em;font-weight:700;color:var(--muted);letter-spacing:0}
.stat__label{font-size:.92rem;font-weight:700}
.stat__hint{font-size:.78rem;color:var(--muted);font-weight:600}
.stat__delta{display:inline-flex;align-items:center;gap:5px;font-size:.78rem;font-weight:800;color:var(--ok)}
.stat__delta svg{width:14px;height:14px;stroke-width:2.4}
/* ==========================================================================
8. ОБРАЗОВАНИЕ / МОДУЛИ (EducationTimeline)
========================================================================== */
.modules{display:grid;grid-template-columns:repeat(auto-fit,minmax(320px,1fr));gap:var(--gap)}
@media (max-width:720px){.modules{grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}}
.module{padding:clamp(14px,1.7vw,20px);display:flex;flex-direction:column;gap:12px;min-width:0}
.module__head{display:flex;align-items:flex-start;gap:12px;flex-wrap:wrap}
.module__head>div{min-width:0;flex:1 1 55%}
.module__icon{
width:46px;height:46px;border-radius:var(--radius-sm);display:grid;place-items:center;flex:none;
background:var(--surface-2);border:1px solid var(--stroke);color:var(--accent);
}
@media (max-width:720px){.module__icon{width:38px;height:38px}.module__icon svg{width:18px;height:18px}}
.module__icon svg{width:22px;height:22px;stroke-width:2}
.module__title{font-size:1.05rem;font-weight:800;letter-spacing:-.01em;overflow-wrap:anywhere}
.module__sub{font-size:.8rem;color:var(--muted);font-weight:600;overflow-wrap:anywhere}
.module__code{font-family:'JetBrains Mono',ui-monospace,monospace;font-size:.72rem;color:var(--muted)}
.module__foot{display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap}
.module__grade{font-size:.8rem;font-weight:800;color:var(--ok)}
.bar{height:8px;border-radius:999px;background:var(--surface-2);border:1px solid var(--stroke);overflow:hidden}
.bar>i{display:block;height:100%;width:0;border-radius:inherit;background:linear-gradient(90deg,var(--brand),var(--accent));transition:width 1.1s cubic-bezier(.2,.8,.2,1)}
.bar--warn>i{background:linear-gradient(90deg,var(--warn),var(--danger))}
.module__progress{display:flex;align-items:center;gap:10px}
.module__progress b{font-size:.8rem;font-weight:800;white-space:nowrap}
/* ==========================================================================
9. ГОРИЗОНТАЛЬНЫЕ ЛЕНТЫ (WorkExperience, ProjectsRail)
========================================================================== */
.rail-wrap{position:relative}
.rail{
display:flex;gap:var(--gap);
overflow-x:auto;overscroll-behavior-x:contain;
scroll-snap-type:x mandatory;scroll-behavior:smooth;
padding:4px 4px 16px;scrollbar-width:thin;
}
.rail>*{scroll-snap-align:start;min-width:0}
/* по 2 блока в ряд на всю ширину страницы */
.rail--half>*{flex:0 0 calc(50% - var(--gap)/2)}
/* по 3 блока в ряд на всю ширину страницы */
.rail--third>*{flex:0 0 calc((100% - var(--gap) * 2) / 3)}
@media (max-width:1100px){.rail--third>*{flex:0 0 calc(50% - var(--gap)/2)}}
@media (max-width:720px){
.rail{gap:12px;padding-bottom:12px}
.rail--half>*,.rail--third>*{flex:0 0 calc(50% - 6px)}
}
.rail-controls{display:flex;align-items:center;gap:8px}
.rail-progress{height:6px;border-radius:999px;background:var(--surface-2);border:1px solid var(--stroke);overflow:hidden;width:min(260px,40vw)}
.rail-progress>i{display:block;height:100%;width:20%;border-radius:inherit;background:linear-gradient(90deg,var(--brand),var(--accent));transition:transform .25s ease,width .25s ease}
/* Карточка опыта работы: КАРТИНКА + ТЕМА + ДАТА */
.exp-card{display:flex;flex-direction:column;overflow:hidden}
.exp-card__media{position:relative}
.exp-card__logo{
position:absolute;left:14px;bottom:-22px;width:56px;height:56px;border-radius:16px;
display:grid;place-items:center;background:var(--bg-soft);border:1px solid var(--stroke);
box-shadow:var(--shadow-soft);color:var(--brand);
}
.exp-card__logo svg{width:24px;height:24px;stroke-width:2}
.exp-card__body{padding:32px clamp(14px,1.6vw,20px) clamp(14px,1.6vw,20px);display:flex;flex-direction:column;gap:10px;flex:1;min-width:0}
.exp-card__title{font-size:clamp(1rem,1.5vw,1.2rem);font-weight:800;letter-spacing:-.02em;overflow-wrap:anywhere}
.exp-card__company{font-size:.88rem;font-weight:700;color:var(--muted)}
.exp-card__text{font-size:.88rem;color:var(--muted);display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}
.exp-card__foot{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-top:auto;flex-wrap:wrap}
/* Карточка проекта */
.prj-card{display:flex;flex-direction:column;overflow:hidden;transition:transform .25s ease,box-shadow .25s ease}
.prj-card:hover{transform:translateY(-6px)}
.prj-card__body{padding:clamp(14px,1.6vw,18px);display:flex;flex-direction:column;gap:10px;flex:1;min-width:0}
.prj-card__title{font-size:clamp(.98rem,1.4vw,1.15rem);font-weight:800;overflow-wrap:anywhere}
.prj-card__text{font-size:.85rem;color:var(--muted);display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
.prj-card__tags{display:flex;flex-wrap:wrap;gap:6px}
.prj-card__foot{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-top:auto;flex-wrap:wrap}
.prj-card__stats{display:flex;align-items:center;gap:12px;color:var(--muted);font-size:.8rem;font-weight:700}
.prj-card__stats span{display:inline-flex;align-items:center;gap:5px}
.prj-card__stats svg{width:15px;height:15px}
/* ==========================================================================
10. НАВЫКИ (SkillsCloud)
========================================================================== */
.skills{display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:var(--gap)}
@media (max-width:720px){.skills{grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}}
.skill-group{padding:clamp(14px,1.6vw,20px);display:flex;flex-direction:column;gap:14px;min-width:0}
.skill-group__head{display:flex;align-items:center;gap:10px;font-weight:800;font-size:1rem}
.skill-group__head svg{width:19px;height:19px;color:var(--brand);flex:none}
.skill-group__count{margin-left:auto;font-size:.76rem;font-weight:800;color:var(--muted);background:var(--surface-2);border:1px solid var(--stroke);padding:3px 9px;border-radius:999px}
.skill-list{display:flex;flex-direction:column;gap:11px}
.skill{display:flex;flex-direction:column;gap:6px;min-width:0}
.skill__top{display:flex;align-items:center;justify-content:space-between;gap:8px}
.skill__name{font-size:.86rem;font-weight:700;overflow-wrap:anywhere}
.skill__val{font-size:.74rem;font-weight:800;color:var(--muted);white-space:nowrap}
/* ==========================================================================
11. ЗАДАНИЯ / УРОКИ (CompletedLessons)
========================================================================== */
.tasks{display:grid;grid-template-columns:repeat(auto-fit,minmax(340px,1fr));gap:12px}
@media (max-width:760px){.tasks{grid-template-columns:1fr}}
.task{display:grid;grid-template-columns:auto minmax(0,1fr) auto;gap:14px;align-items:center;padding:14px 16px;transition:background .2s ease,border-color .2s ease}
.task:hover{background:var(--surface-hover);border-color:var(--stroke-strong)}
.task__check{
width:42px;height:42px;border-radius:12px;display:grid;place-items:center;flex:none;
background:rgba(52,211,153,.14);border:1px solid rgba(52,211,153,.32);color:var(--ok);
}
.task__check svg{width:20px;height:20px;stroke-width:2.4}
.task__check--progress{background:rgba(251,191,36,.14);border-color:rgba(251,191,36,.32);color:var(--warn)}
.task__main{min-width:0}
.task__title{font-size:.95rem;font-weight:800;overflow-wrap:anywhere}
.task__sub{font-size:.79rem;color:var(--muted);font-weight:600;overflow-wrap:anywhere}
.task__right{display:flex;flex-direction:column;align-items:flex-end;gap:6px}
.task__score{font-size:.82rem;font-weight:800;color:var(--ok);white-space:nowrap}
.task__score--muted{color:var(--muted)}
@media (max-width:520px){.task{grid-template-columns:auto minmax(0,1fr)}.task__right{grid-column:2;align-items:flex-start}}
/* ==========================================================================
12. О СЕБЕ (AboutAuthor) + футер
========================================================================== */
.about{display:grid;grid-template-columns:minmax(0,1.5fr) minmax(0,1fr);gap:var(--gap)}
@media (max-width:960px){.about{grid-template-columns:1fr}}
.about__bio{padding:clamp(18px,2vw,26px);display:flex;flex-direction:column;gap:14px}
.about__text{font-size:clamp(.95rem,1.2vw,1.05rem);color:var(--muted)}
.about__text b{color:var(--text)}
.about__quote{margin-top:auto;padding-left:16px;border-left:3px solid var(--brand);font-style:italic;font-weight:600}
.about__side{display:grid;gap:var(--gap);grid-template-columns:1fr}
@media (max-width:960px){.about__side{grid-template-columns:repeat(2,minmax(0,1fr))}}
@media (max-width:640px){.about__side{grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}}
.about__card{padding:clamp(14px,1.6vw,20px);display:flex;flex-direction:column;gap:12px;min-width:0}
.about__card h3{font-size:.95rem;font-weight:800;display:flex;align-items:center;gap:9px}
.about__card h3 svg{width:18px;height:18px;color:var(--brand);flex:none}
.contacts{display:flex;flex-wrap:wrap;gap:8px}
.contact{
display:inline-flex;align-items:center;gap:9px;padding:10px 13px;border-radius:var(--radius-xs);
background:var(--surface-2);border:1px solid var(--stroke);font-size:.85rem;font-weight:700;
transition:background .2s ease,transform .2s ease;overflow-wrap:anywhere;
}
.contact:hover{background:var(--surface-hover);transform:translateY(-2px)}
.contact svg{width:16px;height:16px;color:var(--brand);flex:none}
.about__list{display:flex;flex-direction:column;gap:10px}
.about__list li{display:flex;gap:10px;align-items:flex-start;font-size:.86rem;font-weight:600;color:var(--muted)}
.about__list svg{width:17px;height:17px;color:var(--ok);margin-top:2px;flex:none}
.footer{margin-top:10px;padding:26px 0 0;border-top:1px solid var(--stroke);display:flex;align-items:center;justify-content:space-between;gap:16px;flex-wrap:wrap;color:var(--muted);font-size:.85rem;font-weight:600}
.footer__links{display:flex;gap:16px;flex-wrap:wrap}
.footer__links a:hover{color:var(--text)}
/* ==========================================================================
13.1 АДАПТАЦИЯ ПОД УЗКИЕ ЭКРАНЫ (карточки остаются минимум по 2 в ряд)
========================================================================== */
@media (max-width:620px){
.badge{font-size:.68rem;padding:4px 8px;gap:4px}
.chip{font-size:.76rem;padding:6px 10px}
.stat__delta{font-size:.72rem}
.stat__hint{font-size:.74rem}
.stat__label{font-size:.85rem}
.module__title{font-size:.95rem}
.module__sub,.exp-card__company{font-size:.76rem}
.skill__name{font-size:.8rem}
.icon-badge{width:40px;height:40px}
.fact span{font-size:.82rem}
.btn{padding:11px 16px;font-size:.9rem}
.rail-progress{width:min(150px,34vw)}
}
@media (max-width:400px){
.stats{grid-template-columns:1fr}
.about__side{grid-template-columns:1fr}
}
/* ==========================================================================
13.2 РЕЖИМ РАЗМЕТКИ КОМПОНЕНТОВ (кнопка "Компоненты" в топбаре)
========================================================================== */
[data-component]{position:relative}
.component-tag{
display:none;position:absolute;top:-12px;left:14px;z-index:5;
align-items:center;gap:6px;padding:3px 10px;border-radius:999px;
font-family:'JetBrains Mono',ui-monospace,monospace;
font-size:.72rem;font-weight:700;letter-spacing:-.02em;white-space:nowrap;
background:linear-gradient(120deg,var(--accent),var(--brand));color:#04121c;
}
body.is-outline [data-component]{outline:2px dashed rgba(34,211,238,.55);outline-offset:8px}
body.is-outline .component-tag{display:inline-flex}
.toast{
position:fixed;left:50%;bottom:26px;z-index:80;padding:12px 20px;border-radius:999px;
background:var(--bg-soft);border:1px solid var(--stroke);box-shadow:var(--shadow);
font-weight:700;font-size:.88rem;opacity:0;transform:translate(-50%,140%);
transition:transform .3s cubic-bezier(.2,.8,.2,1),opacity .3s ease;pointer-events:none;
}
.toast.is-show{transform:translate(-50%,0);opacity:1}
.visually-hidden{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
.reveal{opacity:0;transform:translateY(18px);transition:opacity .6s ease,transform .6s cubic-bezier(.2,.8,.2,1)}
.reveal.is-in{opacity:1;transform:none}
/* Плейсхолдер как «окно под фото» внутри карточек: без боковых рамок */
.exp-card__media .ph,.prj-card .ph{border-left:0;border-right:0;border-top:0;border-radius:0}
.hero__cover .ph{border-radius:inherit}
/* Fallback, если Lucide не загрузился: иконки превращаются в аккуратные заглушки */
html.no-icons i[data-lucide]{
display:inline-block;width:1em;height:1em;border-radius:4px;
background:currentColor;opacity:.35;flex:none;
}
html.no-icons .ph__icon{width:32px;height:32px}
</style>
</head>
<body>
<!-- ========================================================================
КОМПОНЕНТ: AppTopbar → components/layout/AppTopbar.vue
props: user | nav[] | theme ; emits: toggle-theme, toggle-outline, toggle-menu
======================================================================== -->
<header class="topbar" data-component="AppTopbar" id="top">
<span class="component-tag">&lt;AppTopbar /&gt;</span>
<div class="topbar__inner">
<a class="brand" href="#top">
<span class="brand__logo"><i data-lucide="graduation-cap"></i></span>
<span class="brand__text">Академия<small>Student Profile</small></span>
</a>
<nav class="topbar__nav" id="mainNav" aria-label="Меню профиля">
<a class="nav-link" href="#profile" data-spy="profile"><i data-lucide="user-round"></i><span>Профиль</span></a>
<a class="nav-link" href="#education" data-spy="education"><i data-lucide="book-open"></i><span>Образование</span></a>
<a class="nav-link" href="#experience" data-spy="experience"><i data-lucide="briefcase"></i><span>Опыт</span></a>
<a class="nav-link" href="#skills" data-spy="skills"><i data-lucide="sparkles"></i><span>Навыки</span></a>
<a class="nav-link" href="#projects" data-spy="projects"><i data-lucide="folder-git-2"></i><span>Проекты</span></a>
<a class="nav-link" href="#lessons" data-spy="lessons"><i data-lucide="list-checks"></i><span>Уроки</span></a>
<a class="nav-link" href="#about" data-spy="about"><i data-lucide="info"></i><span>О себе</span></a>
</nav>
<div class="topbar__actions">
<button class="icon-btn" id="outlineToggle" type="button" aria-pressed="false"
title="Показать границы компонентов (для внедрения в проект)">
<i data-lucide="layout-grid"></i>
</button>
<button class="icon-btn" id="themeToggle" type="button" title="Светлая / тёмная тема">
<i data-lucide="moon"></i>
</button>
<button class="icon-btn" type="button" title="Уведомления" aria-label="Уведомления">
<i data-lucide="bell"></i>
<span class="visually-hidden">Уведомления</span>
</button>
<div class="user-chip" title="Текущий пользователь">
<span class="user-chip__ph"><i data-lucide="user-round"></i></span>
<span class="user-chip__name" id="chipName">—</span>
</div>
<button class="icon-btn topbar__burger" id="burger" type="button" aria-expanded="false"
aria-controls="mainNav" aria-label="Открыть меню">
<i data-lucide="menu"></i>
</button>
</div>
</div>
</header>
<main class="wrap page">
<!-- ======================================================================
КОМПОНЕНТ: ProfileHero → components/profile/ProfileHero.vue
props: student { name, role, city, status, joined, avatar:{size}, tags[] }
Фотография — плейсхолдер-пустышка ровно в размер кадра.
====================================================================== -->
<section class="section" data-component="ProfileHero" id="profile">
<span class="component-tag">&lt;ProfileHero /&gt;</span>
<div id="heroRoot"></div>
</section>
<!-- ======================================================================
КОМПОНЕНТ: AuthorStats → components/profile/AuthorStats.vue
props: stats[] ; каждый элемент: { icon, value, suffix, label, hint, delta }
====================================================================== -->
<section class="section" data-component="AuthorStats" id="stats">
<span class="component-tag">&lt;AuthorStats /&gt;</span>
<div class="section__head">
<h2 class="section__title"><span class="icon-badge"><i data-lucide="activity"></i></span>Информация об авторе</h2>
<p class="section__hint">Сколько учится, сколько проектов сделал, сколько занятий посетил и другие показатели.</p>
</div>
<div class="stats" id="statsRoot"></div>
</section>
<!-- ======================================================================
КОМПОНЕНТ: EducationTimeline → components/profile/EducationTimeline.vue
props: modules[] ; { icon, code, title, teacher, period, progress, status, grade }
Блок "Образование": какие модули ученик прошёл.
====================================================================== -->
<section class="section" data-component="EducationTimeline" id="education">
<span class="component-tag">&lt;EducationTimeline /&gt;</span>
<div class="section__head">
<h2 class="section__title"><span class="icon-badge"><i data-lucide="book-open"></i></span>Образование</h2>
<p class="section__hint">Пройденные модули программы, преподаватели и итоговый прогресс по каждому модулю.</p>
</div>
<div class="modules" id="modulesRoot"></div>
</section>
<!-- ======================================================================
КОМПОНЕНТ: WorkExperience → components/profile/WorkExperience.vue
props: experience[] ; { image:{size}, logoIcon, title, company, period, date,
description, tags[], badge }
Горизонтальная лента, по 2 блока в ряд. Каждый блок: картинка + тема + дата.
====================================================================== -->
<section class="section" data-component="WorkExperience" id="experience">
<span class="component-tag">&lt;WorkExperience /&gt;</span>
<div class="section__head">
<div>
<h2 class="section__title"><span class="icon-badge"><i data-lucide="briefcase"></i></span>Опыт работы</h2>
<p class="section__hint">Стажировки, фриланс и командные проекты академии — ленту можно крутить вбок.</p>
</div>
<div class="rail-controls">
<div class="rail-progress" aria-hidden="true"><i data-rail-progress="experienceRail"></i></div>
<button class="icon-btn" type="button" data-rail-prev="experienceRail" aria-label="Назад"><i data-lucide="arrow-left"></i></button>
<button class="icon-btn" type="button" data-rail-next="experienceRail" aria-label="Вперёд"><i data-lucide="arrow-right"></i></button>
</div>
</div>
<div class="rail-wrap">
<div class="rail rail--half" id="experienceRail" data-rail="experienceRail" tabindex="0"
aria-label="Лента опыта работы"></div>
</div>
</section>
<!-- ======================================================================
КОМПОНЕНТ: SkillsCloud → components/profile/SkillsCloud.vue
props: skillGroups[] ; { icon, title, items:[{ name, level, value }] }
Краткий перечень модулей-навыков, которые ученик проходил.
====================================================================== -->
<section class="section" data-component="SkillsCloud" id="skills">
<span class="component-tag">&lt;SkillsCloud /&gt;</span>
<div class="section__head">
<h2 class="section__title"><span class="icon-badge"><i data-lucide="sparkles"></i></span>Навыки</h2>
<p class="section__hint">Краткий список модулей и технологий, которые ученик уже проходил.</p>
</div>
<div class="skills" id="skillsRoot"></div>
</section>
<!-- ======================================================================
КОМПОНЕНТ: ProjectsRail → components/profile/ProjectsRail.vue
props: projects[] ; { image:{size}, title, description, tags[], date,
views, likes, status }
Проекты ученика горизонтальной лентой.
====================================================================== -->
<section class="section" data-component="ProjectsRail" id="projects">
<span class="component-tag">&lt;ProjectsRail /&gt;</span>
<div class="section__head">
<div>
<h2 class="section__title"><span class="icon-badge"><i data-lucide="folder-git-2"></i></span>Проекты</h2>
<p class="section__hint">Работы, которые ученик защитил на курсе. Лента прокручивается вбок.</p>
</div>
<div class="rail-controls">
<div class="rail-progress" aria-hidden="true"><i data-rail-progress="projectsRail"></i></div>
<button class="icon-btn" type="button" data-rail-prev="projectsRail" aria-label="Назад"><i data-lucide="arrow-left"></i></button>
<button class="icon-btn" type="button" data-rail-next="projectsRail" aria-label="Вперёд"><i data-lucide="arrow-right"></i></button>
</div>
</div>
<div class="rail-wrap">
<div class="rail rail--third" id="projectsRail" data-rail="projectsRail" tabindex="0"
aria-label="Лента проектов"></div>
</div>
</section>
<!-- ======================================================================
КОМПОНЕНТ: CompletedLessons → components/profile/CompletedLessons.vue
props: tasks[] ; { icon, title, module, date, score, status }
Список заданий и уроков, которые ученик уже прошёл.
====================================================================== -->
<section class="section" data-component="CompletedLessons" id="lessons">
<span class="component-tag">&lt;CompletedLessons /&gt;</span>
<div class="section__head">
<h2 class="section__title"><span class="icon-badge"><i data-lucide="list-checks"></i></span>Пройденные задания и уроки</h2>
<p class="section__hint">Последние сданные задания, оценки преподавателя и статус проверки.</p>
</div>
<div class="tasks" id="tasksRoot"></div>
</section>
<!-- ======================================================================
КОМПОНЕНТ: AboutAuthor → components/profile/AboutAuthor.vue
props: about { text, quote, contacts[], achievements[], photo:{size} }
Короткая информация о себе + контакты и достижения.
====================================================================== -->
<section class="section" data-component="AboutAuthor" id="about">
<span class="component-tag">&lt;AboutAuthor /&gt;</span>
<div class="section__head">
<h2 class="section__title"><span class="icon-badge"><i data-lucide="info"></i></span>Немного о себе</h2>
<p class="section__hint">Пара строк от автора профиля, контакты и главные достижения.</p>
</div>
<div class="about" id="aboutRoot"></div>
</section>
<!-- ======================================================================
КОМПОНЕНТ: AppFooter → components/layout/AppFooter.vue
====================================================================== -->
<footer class="footer" data-component="AppFooter">
<span class="component-tag">&lt;AppFooter /&gt;</span>
<p>© <span id="year"></span> Академия · профиль ученика <span id="footerStudent"></span></p>
<nav class="footer__links" aria-label="Футер">
<a href="#profile">Наверх</a>
<a href="#education">Образование</a>
<a href="#projects">Проекты</a>
<a href="#about">Контакты</a>
</nav>
</footer>
</main>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<script>
/* ==========================================================================
ДАННЫЕ СТРАНИЦЫ
В Vue: этот объект вынести в store/composable (useStudentProfile.js)
и раздавать блокам через props. Поля камelCase = props компонентов.
========================================================================== */
const PROFILE_DATA = {
/* --- ProfileHero --- */
student: {
name: 'Мария Актаева',
role: 'Ученица направления «Frontend-разработка» · Vue 3',
status: 'Активно учится',
statusNote: 'учится с сентября 2022',
joined: 'В академии с сентября 2022',
city: 'Алматы, Казахстан',
group: 'Группа FE-2022-09',
mentor: 'Наставник: Артём Северов',
avatar: { width: 480, height: 480 },
cover: { width: 1440, height: 480 },
tags: ['Vue 3', 'JavaScript ES6+', 'SCSS', 'Pinia', 'Vite', 'Figma'],
bio: 'Делаю интерфейсы для веба: от вёрстки макета до готового SPA на Vue.'
},
/* --- AuthorStats: информация об авторе (сколько учится / проектов / посещений) --- */
stats: [
{ icon: 'calendar-clock', value: '3,5', suffix: 'года', label: 'Опыт обучения',
hint: 'с сентября 2022 по сейчас', delta: '+1 модуль в этом месяце' },
{ icon: 'folder-git-2', value: '24', suffix: 'шт.', label: 'Проектов сделано',
hint: 'из них 6 командных', delta: '+3 за месяц' },
{ icon: 'video', value: '412', suffix: 'зан.', label: 'Посещено занятий',
hint: 'посещаемость 96%', delta: '+16 за месяц' },
{ icon: 'book-open-check', value: '14', suffix: '/18', label: 'Пройдено модулей',
hint: 'программа Frontend-разработка', delta: 'осталось 4' },
{ icon: 'clipboard-check', value: '186', suffix: 'дз', label: 'Сдано заданий',
hint: 'принято преподавателем', delta: '+11 за месяц' },
{ icon: 'star', value: '4,8', suffix: 'из 5', label: 'Средний балл',
hint: 'за последние 3 модуля', delta: '+0,2' },
{ icon: 'clock', value: '1240', suffix: 'ч.', label: 'Часов практики',
hint: 'по трекеру учебной платформы', delta: '+48 ч. за месяц' },
{ icon: 'award', value: '6', suffix: 'шт.', label: 'Получено сертификатов',
hint: 'по завершённым модулям', delta: '+2 за полгода' }
],
/* --- EducationTimeline: какие модули прошёл --- */
modules: [
{ icon: 'file-code-2', code: 'MOD-01', title: 'Основы вёрстки: HTML и CSS',
teacher: 'Преподаватель: Анна Ковалёва', period: 'Сен 2022 — Янв 2023',
progress: 100, status: 'passed', grade: 'Оценка 5,0 · сертификат' },
{ icon: 'braces', code: 'MOD-04', title: 'JavaScript: основы языка',
teacher: 'Преподаватель: Игорь Дёмин', period: 'Янв 2023 — Май 2023',
progress: 100, status: 'passed', grade: 'Оценка 4,7 · сертификат' },
{ icon: 'component', code: 'MOD-08', title: 'Vue 3: компоненты и реактивность',
teacher: 'Преподаватель: Артём Северов', period: 'Сен 2023 — Янв 2024',
progress: 100, status: 'passed', grade: 'Оценка 5,0 · сертификат' },
{ icon: 'route', code: 'MOD-11', title: 'Vue Router и Pinia: SPA-архитектура',
teacher: 'Преподаватель: Артём Северов', period: 'Фев 2024 — сейчас',
progress: 78, status: 'progress', grade: 'Текущий модуль' },
{ icon: 'file-type-2', code: 'MOD-13', title: 'TypeScript в Vue-проекте',
teacher: 'Преподаватель: Лейла Ахметова', period: 'Апр 2024 — сейчас',
progress: 45, status: 'progress', grade: 'Текущий модуль' },
{ icon: 'figma', code: 'MOD-06', title: 'UI/UX-дизайн и работа с макетами',
teacher: 'Преподаватель: Ника Волкова', period: 'Июн 2023 — Авг 2023',
progress: 100, status: 'passed', grade: 'Оценка 4,9 · сертификат' }
],
/* --- WorkExperience: горизонтальная лента, 2 блока в ряд ---
image = { width, height } — реальный размер будущего фото (16:9 = 1280×720) */
experience: [
{ image: { width: 1280, height: 720, ratio: '16x9' }, logoIcon: 'building-2',
title: 'Стажировка: Frontend-разработчик', company: 'Студия «Пиксель»',
period: 'Июнь 2023 — Август 2023', date: '06.2023 — 08.2023',
badge: 'Стажировка', statusClass: 'badge--info',
description: 'Собирала лендинги на Vue 3: адаптивная вёрстка, компоненты форм, интеграция с REST API.',
tags: ['Vue 3', 'SCSS', 'REST API'] },
{ image: { width: 1280, height: 720, ratio: '16x9' }, logoIcon: 'coffee',
title: 'Фриланс: сайт кофейни «Оракул»', company: 'Личный заказ',
period: 'Сентябрь 2023 — Октябрь 2023', date: '09.2023 — 10.2023',
badge: 'Фриланс', statusClass: 'badge--ok',
description: 'Полный цикл: макет в Figma, вёрстка, меню и корзина на JS, деплой на Vercel.',
tags: ['Figma', 'JavaScript', 'Адаптив'] },
{ image: { width: 1280, height: 720, ratio: '16x9' }, logoIcon: 'layout-dashboard',
title: 'Командный проект: админ-панель школы', company: 'Академия · команда из 4 человек',
period: 'Февраль 2024 — Апрель 2024', date: '02.2024 — 04.2024',
badge: 'Командный', statusClass: 'badge--info',
description: 'Отвечала за раздел учеников и профиль: Vue Router, Pinia, переиспользуемые таблицы.',
tags: ['Vue Router', 'Pinia', 'Команда'] },
{ image: { width: 1280, height: 720, ratio: '16x9' }, logoIcon: 'rocket',
title: 'Хакатон Digital City: кейс «Городские сервисы»', company: 'Хакатон · 48 часов',
period: 'Март 2024', date: '03.2024',
badge: 'Хакатон', statusClass: 'badge--progress',
description: 'За 48 часов собрали прототип карты городских заявок. Забрали 2-е место в треке Frontend.',
tags: ['Vue 3', 'Leaflet', 'Прототип'] }
],
/* --- SkillsCloud: краткий перечень пройденных модулей и технологий --- */
skillGroups: [
{ icon: 'layout-panel-left', title: 'Вёрстка и стили',
items: [
{ name: 'HTML5 · семантика', level: 'Пройден', value: 95 },
{ name: 'CSS3 · Flex и Grid', level: 'Пройден', value: 92 },
{ name: 'SCSS · БЭМ-компоненты', level: 'Пройден', value: 85 },
{ name: 'Адаптив и медиазапросы', level: 'Пройден', value: 90 }
] },
{ icon: 'braces', title: 'JavaScript',
items: [
{ name: 'ES6+ · синтаксис', level: 'Пройден', value: 88 },
{ name: 'DOM и события', level: 'Пройден', value: 85 },
{ name: 'Асинхронность · Promise, async/await', level: 'Пройден', value: 80 },
{ name: 'Fetch и работа с API', level: 'Пройден', value: 78 }
] },
{ icon: 'component', title: 'Vue 3',
items: [
{ name: 'Компоненты и props/emits', level: 'Пройден', value: 86 },
{ name: 'Reactivity · ref, computed, watch', level: 'Пройден', value: 82 },
{ name: 'Vue Router', level: 'В процессе', value: 70 },
{ name: 'Pinia · сторы', level: 'В процессе', value: 62 }
] },
{ icon: 'wrench', title: 'Инструменты',
items: [
{ name: 'Git и GitHub', level: 'Пройден', value: 84 },
{ name: 'Vite · сборка проекта', level: 'Пройден', value: 76 },
{ name: 'Figma · работа с макетом', level: 'Пройден', value: 74 },
{ name: 'DevTools и отладка', level: 'Пройден', value: 72 }
] }
],
/* --- ProjectsRail: горизонтальная лента проектов (фото 16:9 = 1280×720) --- */
projects: [
{ image: { width: 1280, height: 720, ratio: '16x9' }, icon: 'trophy',
title: 'City Wish — карта городских заявок', tags: ['Vue 3', 'Leaflet', 'Pinia'],
description: 'Хакатон-проект: подача заявки на карте города и статус её обработки.',
date: 'Март 2024', views: 312, likes: 48, status: 'Защищён · 2 место' },
{ image: { width: 1280, height: 720, ratio: '16x9' }, icon: 'layout-dashboard',
title: 'School Admin Panel', tags: ['Vue Router', 'Pinia', 'SCSS'],
description: 'Админ-панель школы: список учеников, фильтры, карточка профиля ученика.',
date: 'Апрель 2024', views: 268, likes: 39, status: 'Защищён · отлично' },
{ image: { width: 1280, height: 720, ratio: '16x9' }, icon: 'shopping-cart',
title: 'Coffee Oracul — сайт кофейни', tags: ['JavaScript', 'Figma'],
description: 'Лендинг с меню и корзиной, полностью адаптивный под мобильные устройства.',
date: 'Октябрь 2023', views: 421, likes: 57, status: 'Опубликован' },
{ image: { width: 1280, height: 720, ratio: '16x9' }, icon: 'kanban',
title: 'Task Flow — трекер задач', tags: ['Vue 3', 'localStorage'],
description: 'Канбан-доска с drag and drop на три колонки и сохранением в localStorage.',
date: 'Январь 2024', views: 197, likes: 31, status: 'Учебный' },
{ image: { width: 1280, height: 720, ratio: '16x9' }, icon: 'cloud-sun',
title: 'Weather Now — погода по геолокации', tags: ['API', 'CSS Grid'],
description: 'Погодное приложение: запрос к открытому API, графики и поиск городов.',
date: 'Декабрь 2023', views: 174, likes: 26, status: 'Учебный' },
{ image: { width: 1280, height: 720, ratio: '16x9' }, icon: 'file-text',
title: 'CV Builder — конструктор резюме', tags: ['Vue 3', 'SCSS'],
description: 'Форма с живым предпросмотром резюме и экспортом в PDF через печать.',
date: 'Февраль 2024', views: 233, likes: 44, status: 'В работе · 70%' }
],
/* --- CompletedLessons: задания и уроки, которые прошёл ученик --- */
tasks: [
{ icon: 'check', done: true, title: 'Урок 12. Компоненты и props в Vue 3',
module: 'MOD-08 · Vue 3: компоненты', date: '14 января 2024', score: '5,0' },
{ icon: 'check', done: true, title: 'Задание 8. Переиспользуемая карточка проекта',
module: 'MOD-08 · Vue 3: компоненты', date: '19 января 2024', score: '4,8' },
{ icon: 'check', done: true, title: 'Урок 15. Reactivity: ref, computed, watch',
module: 'MOD-08 · Vue 3: реактивность', date: '26 января 2024', score: '5,0' },
{ icon: 'check', done: true, title: 'Проект модуля. Каталог книг с фильтрами',
module: 'MOD-08 · Vue 3: компоненты', date: '3 февраля 2024', score: '4,9' },
{ icon: 'check', done: true, title: 'Урок 3. Vue Router: динамические маршруты',
module: 'MOD-11 · Vue Router и Pinia', date: '17 февраля 2024', score: '4,7' },
{ icon: 'check', done: true, title: 'Задание 4. Стор ученика в Pinia',
module: 'MOD-11 · Vue Router и Pinia', date: '24 февраля 2024', score: '4,6' },
{ icon: 'loader', done: false, title: 'Урок 7. Типизация props и emits в TypeScript',
module: 'MOD-13 · TypeScript в Vue', date: 'в процессе', score: '72%' },
{ icon: 'loader', done: false, title: 'Задание 2. Guard-роуты и проверка прав',
module: 'MOD-11 · Vue Router и Pinia', date: 'в процессе', score: '58%' }
],
/* --- AboutAuthor: небольшой блок о себе (фото 4:3 = 800×600) --- */
about: {
photo: { width: 800, height: 600, ratio: '4x3' },
text: 'Привет! Я <b>Мария Актаева</b>, учусь на направлении «Frontend-разработка» с сентября 2022 года. ' +
'Начинала с вёрстки макетов, сейчас собираю SPA на Vue 3: компоненты, роутинг, состояние через Pinia. ' +
'Люблю аккуратный дизайн и чистый код, веду свои проекты в GitHub и помогаю одногруппникам с домашками.',
quote: 'Мой принцип: сначала понять, потом написать — и обязательно проверить на реальном макете.',
smallFacts: [
{ icon: 'map-pin', text: 'Алматы, Казахстан' },
{ icon: 'languages', text: 'Русский, казахский, английский (B1)' },
{ icon: 'timer', text: 'Учусь 5 дней в неделю, 2 часа в день' },
{ icon: 'heart', text: 'Помогаю одногруппникам с кодом' }
],
contacts: [
{ icon: 'mail', label: 'maria.aktaeva@academy.dev', href: 'mailto:maria.aktaeva@academy.dev' },
{ icon: 'send', label: '@maria_dev', href: '#' },
{ icon: 'github', label: 'github.com/maria-aktaeva', href: '#' },
{ icon: 'phone', label: '+7 (700) 000-00-00', href: 'tel:+77000000000' }
],
achievements: [
'2 место на хакатоне Digital City в треке Frontend',
'6 сертификатов по завершённым модулям',
'Средний балл 4,8 из 5 за последние 3 модуля',
'Помощник преподавателя на воркшопе по Vue 3'
]
}
};
/* ==========================================================================
ХЕЛПЕРЫ РЕНДЕРА
--------------------------------------------------------------------------
ph() — плейсхолдер вместо фото. Размер задаётся РЕАЛЬНЫЙ (w×h), поэтому
aspect-ratio блока всегда совпадает с размером будущего файла.
В Vue: <ImagePlaceholder :width="480" :height="480" label="Фото ученика" />
========================================================================== */
function ph({ width, height, label = 'Фото', radius = 0, icon = 'image' } = {}) {
const style = `aspect-ratio:${width}/${height}` + (radius ? `;border-radius:${radius}px` : '');
return `<div class="ph ph--${ratioClass(width, height)}" style="${style}"
role="img" aria-label="${label} — место под фото ${width}×${height}">
<i class="ph__icon" data-lucide="${icon}"></i>
<span class="ph__label">${label}</span>
<span class="ph__size">${width} × ${height} px</span>
</div>`;
}
function ratioClass(w, h) {
if (w === h) return 'square';
const r = w / h;
if (Math.abs(r - 16 / 9) < 0.02) return '16x9';
if (Math.abs(r - 4 / 3) < 0.02) return '4x3';
return 'wide';
}
const icon = (name, cls = '') => `<i ${cls ? `class="${cls}" ` : ''}data-lucide="${name}"></i>`;
const esc = (s) => String(s ?? '');
const statusBadge = (status, extra = '') =>
status === 'passed'
? `<span class="badge badge--ok">${icon('badge-check')}Пройден${extra}</span>`
: `<span class="badge badge--progress">${icon('loader')}В процессе${extra}</span>`;
/* ==========================================================================
ProfileHero → components/profile/ProfileHero.vue
========================================================================== */
function renderHero() {
const s = PROFILE_DATA.student;
document.getElementById('heroRoot').innerHTML = `
<article class="card hero">
<div class="hero__cover">${ph({ ...s.cover, label: 'Обложка профиля', radius: 14, icon: 'wallpaper' })}</div>
<div class="hero__grid">
<div class="hero__media">
<div class="hero__avatar">${ph({ ...s.avatar, label: 'Фото ученика', radius: 26, icon: 'user-round' })}</div>
<span class="hero__status"><span class="hero__pulse"></span>${esc(s.status)} · ${esc(s.statusNote)}</span>
</div>
<div class="hero__info">
<span class="hero__eyebrow">${icon('id-card')}Профиль ученика · ID FE-2022-09-14</span>
<h1 class="hero__name">${esc(s.name)}</h1>
<p class="hero__role">${esc(s.role)}</p>
<p class="hero__role" style="font-size:1rem">${esc(s.bio)}</p>
<div class="hero__tags">
${s.tags.map(t => `<span class="chip chip--brand">${icon('tag')}${esc(t)}</span>`).join('')}
</div>
<div class="hero__facts">
<span class="fact">${icon('map-pin')}<span>${esc(s.city)}</span></span>
<span class="fact">${icon('users-round')}<span>${esc(s.group)}</span></span>
<span class="fact">${icon('calendar-check')}<span>${esc(s.joined)}</span></span>
<span class="fact">${icon('user-check')}<span>${esc(s.mentor)}</span></span>
</div>
<div class="hero__actions">
<button class="btn btn--primary" type="button" data-toast="Скоро: личные сообщения ученику">${icon('send')}Написать</button>
<button class="btn" type="button" data-toast="Скоро: экспорт резюме в PDF">${icon('download')}Резюме</button>
<button class="btn btn--ghost" type="button" data-toast="Ссылка на профиль скопирована">${icon('share-2')}Поделиться</button>
</div>
</div>
</div>
</article>`;
document.getElementById('chipName').textContent = s.name.split(' ')[0] + ' ' + s.name.split(' ')[1][0] + '.';
document.getElementById('footerStudent').textContent = s.name;
}
/* ==========================================================================
AuthorStats → components/profile/AuthorStats.vue
props: stats[] (Vue: v-for="s in stats")
========================================================================== */
function renderStats() {
document.getElementById('statsRoot').innerHTML = PROFILE_DATA.stats.map(s => `
<article class="card stat reveal">
<div class="stat__top">
<span class="stat__icon">${icon(s.icon)}</span>
<span class="stat__delta">${icon('trending-up')}${esc(s.delta)}</span>
</div>
<p class="stat__value" data-count="${esc(s.value)}">${esc(s.value)} <small>${esc(s.suffix)}</small></p>
<p class="stat__label">${esc(s.label)}</p>
<p class="stat__hint">${esc(s.hint)}</p>
</article>`).join('');
}
/* ==========================================================================
EducationTimeline → components/profile/EducationTimeline.vue
props: modules[] (Vue: v-for="m in modules")
========================================================================== */
function renderModules() {
document.getElementById('modulesRoot').innerHTML = PROFILE_DATA.modules.map(m => `
<article class="card module reveal">
<div class="module__head">
<span class="module__icon">${icon(m.icon)}</span>
<div style="display:flex;flex-direction:column;gap:4px">
<h3 class="module__title">${esc(m.title)}</h3>
<p class="module__sub">${esc(m.teacher)}</p>
<p class="module__code">${esc(m.code)} · ${esc(m.period)}</p>
</div>
</div>
<div class="module__progress">
<div class="bar${m.status === 'progress' ? ' bar--warn' : ''}" style="flex:1">
<i data-progress="${m.progress}"></i>
</div>
<b>${m.progress}%</b>
</div>
<div class="module__foot">
${statusBadge(m.status)}
<span class="module__grade">${icon('award')} ${esc(m.grade)}</span>
<button class="btn btn--sm" type="button" data-toast="Скоро: страница модуля «${esc(m.code)}»">${icon('arrow-up-right')}Модуль</button>
</div>
</article>`).join('');
}
/* ==========================================================================
WorkExperience → components/profile/WorkExperience.vue
props: experience[] — горизонтальная лента, по 2 блока в ряд.
В карточке: картинка (плейсхолдер реального размера) + тема + дата.
========================================================================== */
function renderExperience() {
document.getElementById('experienceRail').innerHTML = PROFILE_DATA.experience.map(e => `
<article class="card exp-card">
<div class="exp-card__media">
${ph({ ...e.image, label: 'Фото кейса', radius: 0 })}
<span class="exp-card__logo">${icon(e.logoIcon)}</span>
</div>
<div class="exp-card__body">
<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">
<span class="badge ${e.statusClass}">${icon('briefcase')}${esc(e.badge)}</span>
<span class="meta">${icon('calendar-days')}${esc(e.date)}</span>
</div>
<h3 class="exp-card__title">${esc(e.title)}</h3>
<p class="exp-card__company">${esc(e.company)} · ${esc(e.period)}</p>
<p class="exp-card__text">${esc(e.description)}</p>
<div class="hero__tags">${e.tags.map(t => `<span class="chip">${icon('tag')}${esc(t)}</span>`).join('')}</div>
<div class="exp-card__foot">
<span class="meta">${icon('map-pin')}${esc(e.company)}</span>
<button class="btn btn--sm" type="button" data-toast="Скоро: подробности опыта «${esc(e.title)}»">${icon('arrow-up-right')}Подробнее</button>
</div>
</div>
</article>`).join('');
}
/* ==========================================================================
SkillsCloud → components/profile/SkillsCloud.vue
props: skillGroups[] — краткий перечень пройденных модулей-навыков
========================================================================== */
function renderSkills() {
document.getElementById('skillsRoot').innerHTML = PROFILE_DATA.skillGroups.map(g => `
<article class="card skill-group reveal">
<div class="skill-group__head">
${icon(g.icon)}<span>${esc(g.title)}</span>
<span class="skill-group__count">${g.items.length} модуля</span>
</div>
<div class="skill-list">
${g.items.map(it => `
<div class="skill">
<div class="skill__top">
<span class="skill__name">${esc(it.name)}</span>
<span class="skill__val">${esc(it.level)} · ${it.value}%</span>
</div>
<div class="bar"><i data-progress="${it.value}"></i></div>
</div>`).join('')}
</div>
</article>`).join('');
}
/* ==========================================================================
ProjectsRail → components/profile/ProjectsRail.vue
props: projects[] — горизонтальная лента проектов
========================================================================== */
function renderProjects() {
document.getElementById('projectsRail').innerHTML = PROFILE_DATA.projects.map(p => `
<article class="card prj-card">
${ph({ ...p.image, label: 'Превью проекта', radius: 0 })}
<div class="prj-card__body">
<span class="badge badge--info">${icon(p.icon)}${esc(p.status)}</span>
<h3 class="prj-card__title">${esc(p.title)}</h3>
<p class="prj-card__text">${esc(p.description)}</p>
<div class="prj-card__tags">${p.tags.map(t => `<span class="chip">${esc(t)}</span>`).join('')}</div>
<div class="prj-card__foot">
<span class="prj-card__stats">
<span>${icon('calendar-days')}${esc(p.date)}</span>
<span>${icon('eye')}${p.views}</span>
<span>${icon('heart')}${p.likes}</span>
</span>
<button class="btn btn--sm" type="button" data-toast="Скоро: карточка проекта «${esc(p.title)}»">${icon('external-link')}Открыть</button>
</div>
</div>
</article>`).join('');
}
/* ==========================================================================
CompletedLessons → components/profile/CompletedLessons.vue
props: tasks[] — список пройденных заданий и уроков
========================================================================== */
function renderTasks() {
document.getElementById('tasksRoot').innerHTML = PROFILE_DATA.tasks.map(t => `
<article class="card task reveal">
<span class="task__check${t.done ? '' : ' task__check--progress'}">${icon(t.icon)}</span>
<div class="task__main">
<p class="task__title">${esc(t.title)}</p>
<p class="task__sub">${esc(t.module)} · ${esc(t.date)}</p>
</div>
<div class="task__right">
<span class="task__score${t.done ? '' : ' task__score--muted'}">${esc(t.score)}</span>
<span class="badge ${t.done ? 'badge--ok' : 'badge--progress'}">${t.done ? 'Зачтено' : 'В проверке'}</span>
</div>
</article>`).join('');
}
/* ==========================================================================
AboutAuthor → components/profile/AboutAuthor.vue
props: about { text, quote, smallFacts[], contacts[], achievements[] }
========================================================================== */
function renderAbout() {
const a = PROFILE_DATA.about;
document.getElementById('aboutRoot').innerHTML = `
<article class="card about__bio reveal">
<h3 class="section__title" style="font-size:1.15rem">${icon('user-round')}Коротко о себе</h3>
<p class="about__text">${a.text}</p>
<p class="about__quote">«${esc(a.quote)}»</p>
<div class="hero__facts">
${a.smallFacts.map(f => `<span class="fact">${icon(f.icon)}<span>${esc(f.text)}</span></span>`).join('')}
</div>
</article>
<div class="about__side">
<article class="card about__card reveal">
<h3>${icon('image')}Фото автора</h3>
${ph({ ...a.photo, label: 'Фото автора', radius: 14 })}
<p class="meta">${icon('info')}Место под реальное фото автора профиля</p>
</article>
<article class="card about__card reveal">
<h3>${icon('at-sign')}Контакты</h3>
<div class="contacts">
${a.contacts.map(c => `<a class="contact" href="${esc(c.href)}">${icon(c.icon)}${esc(c.label)}</a>`).join('')}
</div>
</article>
<article class="card about__card reveal">
<h3>${icon('trophy')}Достижения</h3>
<ul class="about__list">
${a.achievements.map(x => `<li>${icon('circle-check-big')}<span>${esc(x)}</span></li>`).join('')}
</ul>
</article>
</div>`;
}
/* ==========================================================================
ИКОНКИ LUCIDE
Основной источник — CDN, при недоступности включается режим no-icons
(иконки заменяются аккуратными заглушками, вёрстка не ломается).
========================================================================== */
const LUCIDE_CDN = [
'https://cdn.jsdelivr.net/npm/lucide@0.469.0/dist/umd/lucide.min.js',
'https://unpkg.com/lucide@0.469.0/dist/umd/lucide.min.js'
];
function loadLucide(i = 0) {
if (i >= LUCIDE_CDN.length) { document.documentElement.classList.add('no-icons'); return; }
const s = document.createElement('script');
s.src = LUCIDE_CDN[i];
s.async = true;
s.onload = () => refreshIcons();
s.onerror = () => loadLucide(i + 1);
document.head.appendChild(s);
}
/* Вызывать после каждой перерисовки разметки */
function refreshIcons() {
if (window.lucide && typeof window.lucide.createIcons === 'function') window.lucide.createIcons();
}
/* ==========================================================================
ГОРИЗОНТАЛЬНЫЕ ЛЕНТЫ (общая логика WorkExperience / ProjectsRail)
В Vue выносится в composable useHorizontalRail(railRef)
========================================================================== */
function initRails() {
document.querySelectorAll('[data-rail]').forEach(rail => {
const id = rail.dataset.rail;
const bar = document.querySelector(`[data-rail-progress="${id}"]`);
const step = () => {
const card = rail.firstElementChild;
return card ? card.getBoundingClientRect().width + 18 : rail.clientWidth * 0.5;
};
const sync = () => {
if (!bar) return;
const max = rail.scrollWidth - rail.clientWidth;
const visible = Math.min(rail.clientWidth / rail.scrollWidth, 1);
const ratio = max > 0 ? rail.scrollLeft / max : 0;
bar.style.width = Math.max(visible * 100, 10) + '%';
bar.style.transform = `translateX(${ratio * (100 / Math.max(visible * 100, 10) - 1) * 100}%)`;
};
rail.addEventListener('scroll', sync, { passive: true });
window.addEventListener('resize', sync);
sync();
document.querySelector(`[data-rail-prev="${id}"]`)
?.addEventListener('click', () => rail.scrollBy({ left: -step(), behavior: 'smooth' }));
document.querySelector(`[data-rail-next="${id}"]`)
?.addEventListener('click', () => rail.scrollBy({ left: step(), behavior: 'smooth' }));
rail.addEventListener('keydown', (e) => {
if (e.key === 'ArrowRight') { e.preventDefault(); rail.scrollBy({ left: step(), behavior: 'smooth' }); }
if (e.key === 'ArrowLeft') { e.preventDefault(); rail.scrollBy({ left: -step(), behavior: 'smooth' }); }
});
});
}
/* ==========================================================================
ИНТЕРФЕЙС: тема, мобильное меню, режим разметки компонентов, тосты
========================================================================== */
function initUi() {
const root = document.documentElement;
const themeBtn = document.getElementById('themeToggle');
const savedTheme = localStorage.getItem('academy-theme');
if (savedTheme) root.dataset.theme = savedTheme;
const paintThemeIcon = () => {
themeBtn.innerHTML = `<i data-lucide="${root.dataset.theme === 'light' ? 'sun' : 'moon'}"></i>`;
refreshIcons();
};
paintThemeIcon();
themeBtn.addEventListener('click', () => {
root.dataset.theme = root.dataset.theme === 'light' ? 'dark' : 'light';
localStorage.setItem('academy-theme', root.dataset.theme);
paintThemeIcon();
});
const nav = document.getElementById('mainNav');
const burger = document.getElementById('burger');
const closeNav = () => {
nav.classList.remove('is-open');
burger.setAttribute('aria-expanded', 'false');
burger.innerHTML = '<i data-lucide="menu"></i>';
refreshIcons();
};
burger.addEventListener('click', () => {
const open = nav.classList.toggle('is-open');
burger.setAttribute('aria-expanded', String(open));
burger.innerHTML = `<i data-lucide="${open ? 'x' : 'menu'}"></i>`;
refreshIcons();
});
nav.addEventListener('click', (e) => {
if (e.target.closest('.nav-link') && nav.classList.contains('is-open')) closeNav();
});
const outlineBtn = document.getElementById('outlineToggle');
outlineBtn.addEventListener('click', () => {
const on = document.body.classList.toggle('is-outline');
outlineBtn.setAttribute('aria-pressed', String(on));
showToast(on
? 'Режим разметки включён: видно границы и имена компонентов'
: 'Режим разметки выключен');
});
document.addEventListener('click', (e) => {
const btn = e.target.closest('[data-toast]');
if (btn) showToast(btn.dataset.toast);
});
document.getElementById('year').textContent = new Date().getFullYear();
}
let toastTimer;
function showToast(text) {
const toast = document.getElementById('toast');
toast.textContent = text;
toast.classList.add('is-show');
clearTimeout(toastTimer);
toastTimer = setTimeout(() => toast.classList.remove('is-show'), 2600);
}
/* ==========================================================================
АНИМАЦИИ: появление блоков, прогресс-бары, счётчики статистики
========================================================================== */
function initMotion() {
setTimeout(() => {
document.querySelectorAll('[data-progress]').forEach(el => { el.style.width = el.dataset.progress + '%'; });
}, 150);
const reveal = (el) => {
el.classList.add('is-in');
el.querySelectorAll('.bar > i[data-progress]').forEach(x => { x.style.width = x.dataset.progress + '%'; });
const counter = el.querySelector('[data-count]');
if (counter) animateValue(counter);
};
const targets = [...document.querySelectorAll('.reveal')];
/* Если IntersectionObserver недоступен — просто показываем всё */
if (typeof IntersectionObserver !== 'function') {
targets.forEach(reveal);
return;
}
/* Первый экран показываем сразу, не дожидаясь скролла */
targets.filter(el => el.getBoundingClientRect().top < window.innerHeight * 0.92).forEach(reveal);
const io = new IntersectionObserver((entries) => {
entries.forEach(en => {
if (!en.isIntersecting) return;
reveal(en.target);
io.unobserve(en.target);
});
}, { rootMargin: '0px 0px -5% 0px', threshold: 0.05 });
targets.filter(el => !el.classList.contains('is-in')).forEach(el => io.observe(el));
const spy = new IntersectionObserver((entries) => {
entries.forEach(en => {
if (!en.isIntersecting) return;
document.querySelectorAll('.nav-link').forEach(l =>
l.setAttribute('aria-current', String(l.dataset.spy === en.target.id)));
});
}, { rootMargin: '-45% 0px -50% 0px' });
document.querySelectorAll('[data-spy]').forEach(link => {
const sec = document.getElementById(link.dataset.spy);
if (sec) spy.observe(sec);
});
}
function animateValue(el) {
const raw = String(el.dataset.count);
const target = parseFloat(raw.replace(',', '.'));
if (Number.isNaN(target)) return;
const decimals = raw.includes(',') ? 1 : 0;
const suffix = el.querySelector('small') ? el.querySelector('small').outerHTML : '';
const start = performance.now();
const dur = 1100;
const tick = (now) => {
const p = Math.min((now - start) / dur, 1);
const eased = 1 - Math.pow(1 - p, 3);
const val = (target * eased).toFixed(decimals).replace('.', ',');
el.innerHTML = `${val} ${suffix}`;
if (p < 1) requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
}
/* ==========================================================================
ИНИЦИАЛИЗАЦИЯ СТРАНИЦЫ
В Vue это делает App.vue: каждый render*() = отдельный компонент,
данные приходят через props из PROFILE_DATA (useStudentProfile).
========================================================================== */
function initProfilePage() {
renderHero(); /* ProfileHero */
renderStats(); /* AuthorStats */
renderModules(); /* EducationTimeline */
renderExperience(); /* WorkExperience */
renderSkills(); /* SkillsCloud */
renderProjects(); /* ProjectsRail */
renderTasks(); /* CompletedLessons */
renderAbout(); /* AboutAuthor */
refreshIcons();
initRails();
initUi();
initMotion();
}
const boot = () => {
initProfilePage();
loadLucide();
};
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot);
else boot();
</script>
</body>
</html>