952a54f97c
## P0 - admin.html:2608, red-book-ecosystem.html:489-495 — XSS: u.name/node.name_ru/description обернуты в LS.esc() - classController.js getAnnouncements — добавлена проверка teacher_id (B14: учитель A не может читать объявления класса B) ## P1 — auth & validation - authController.js — минимум пароля 6→8 символов (register + change password + login.html) - gamificationController adminAward — валидация max XP/coins (1M), Number coercion - shopController adminAwardCoins — валидация max + проверка changes>0 ## P1 — race conditions - petController.buyBg — atomic UPDATE WHERE coins>=? (race-safe) - shopController.purchaseItem — atomic conditional UPDATE - liveController — добавлен question_id в live_answers (миграция с пересозданием таблицы), история ответов сохраняется при смене вопроса учителем - ws-server: invalidateDrawCache экспортирован, classroomController grant/revoke вызывают его → permission revoke применяется мгновенно (раньше до 10s stale) ## P1 — rate limits & retry - rateLimit middleware: новый параметр byUser=true (использует req.user.id вместо IP — не блокирует пользователей за NAT) - routes/classroom.js: reactionLimiter (15/5s) на /chat/:msgId/react, handLimiter (5/5s) на raise/lower hand - api.js sendAnswer — retry 3x с exp backoff (300/1200/2700ms), не повторяет на 4xx (F5) ## P1 — performance - classroomController.getStrokes — LIMIT 5000 + флаг hasMore (защита от OOM на 10K+ strokes) - whiteboard.js _liveStrokes — TTL 1.5s на каждый live preview (auto-cleanup при крашe ремоут юзера) ## Infrastructure - config.js: TURN_URL/USER/PASS env vars - server.js: GET /api/ice-servers возвращает STUN + опциональный TURN из env - classroom-rtc.js: фетчит /api/ice-servers вместо хардкода (поддержка TURN для NAT/CGNAT школьных сетей) - .env.example: документация TURN - db.js: PRAGMA synchronous=NORMAL (5x быстрее с WAL), cache_size 16MB, temp_store=MEMORY - ws-server.js closeAll() + server.js shutdown — graceful WS shutdown при SIGTERM ## False positives (не баги, агенты ошиблись) - assignmentController FK на tests — на самом деле users (migrate.js:317-318) - .env в git — gitignore корректно исключает - admin.html без requireAuth — есть LS.initPage() который вызывает requireAuth - submissionsController IDOR — обе ручки уже проверяют teacher_id - screenSender = null inside try/catch — на самом деле снаружи - SSE без backoff — есть exponential 2s→30s - sessionController NOT IN на пустом массиве — есть guard usedIds.length>0 - getChat без LIMIT — есть LIMIT 100/200 - trust proxy — установлен на server.js:105 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1017 lines
44 KiB
HTML
1017 lines
44 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="ru">
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||
<title>Вход — LearnSpace</title>
|
||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||
<link href="https://fonts.googleapis.com/css2?family=Unbounded:wght@400;700;800&family=Manrope:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||
<link rel="stylesheet" href="/css/ls.css" />
|
||
<style>
|
||
/* ── Layout ── */
|
||
.login-layout { display: flex; min-height: 100vh; }
|
||
|
||
/* ── Left panel ── */
|
||
.login-left {
|
||
flex: 1;
|
||
background: linear-gradient(145deg, #0b0920 0%, #160d3a 45%, #1e1250 100%);
|
||
position: relative; overflow: hidden;
|
||
display: flex; align-items: center;
|
||
padding: 60px 56px;
|
||
}
|
||
.login-left::before {
|
||
content: '';
|
||
position: absolute; inset: 0;
|
||
background-image: radial-gradient(circle, rgba(255,255,255,0.04) 1px, transparent 1px);
|
||
background-size: 24px 24px;
|
||
}
|
||
/* animated glow blobs */
|
||
.ll-blob { position: absolute; border-radius: 50%; pointer-events: none; animation: blobFloat 8s ease-in-out infinite; }
|
||
.ll-blob1 { width: 560px; height: 560px; background: radial-gradient(circle, rgba(155,93,229,0.28), transparent 68%); top: -180px; right: -120px; animation-delay: 0s; }
|
||
.ll-blob2 { width: 400px; height: 400px; background: radial-gradient(circle, rgba(6,214,224,0.18), transparent 68%); bottom: -120px; left: 10%; animation-delay: -3s; }
|
||
.ll-blob3 { width: 240px; height: 240px; background: radial-gradient(circle, rgba(241,91,181,0.14), transparent 68%); top: 40%; left: -80px; animation-delay: -5s; }
|
||
@keyframes blobFloat {
|
||
0%, 100% { transform: translate(0, 0) scale(1); }
|
||
33% { transform: translate(20px, -30px) scale(1.04); }
|
||
66% { transform: translate(-15px, 20px) scale(0.97); }
|
||
}
|
||
|
||
.ll-inner { position: relative; z-index: 1; max-width: 440px; }
|
||
|
||
.ll-logo {
|
||
font-family: 'Unbounded', sans-serif;
|
||
font-size: 1.3rem; font-weight: 800;
|
||
color: #fff; margin-bottom: 56px;
|
||
display: flex; align-items: center; gap: 10px;
|
||
}
|
||
.ll-logo-mark {
|
||
width: 36px; height: 36px; border-radius: 10px;
|
||
background: linear-gradient(135deg, #06D6E0, #9B5DE5);
|
||
display: flex; align-items: center; justify-content: center;
|
||
font-size: 1rem;
|
||
}
|
||
.ll-logo span { background: var(--grad-1); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; }
|
||
|
||
.ll-headline {
|
||
font-family: 'Unbounded', sans-serif;
|
||
font-size: 1.85rem; font-weight: 800; line-height: 1.28;
|
||
color: #fff; margin-bottom: 16px;
|
||
}
|
||
.ll-headline em { font-style: normal; background: var(--grad-1); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; }
|
||
|
||
.ll-tagline { font-size: 0.875rem; color: rgba(255,255,255,0.5); font-weight: 500; margin-bottom: 44px; line-height: 1.7; }
|
||
|
||
/* stats row */
|
||
.ll-stats { display: flex; gap: 20px; margin-bottom: 36px; }
|
||
.ll-stat {
|
||
flex: 1; padding: 14px 16px;
|
||
background: rgba(255,255,255,0.06);
|
||
border: 1px solid rgba(255,255,255,0.09);
|
||
border-radius: 14px;
|
||
}
|
||
.ll-stat-val { font-family: 'Unbounded', sans-serif; font-size: 1.3rem; font-weight: 800; color: #fff; }
|
||
.ll-stat-val span { background: var(--grad-1); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; }
|
||
.ll-stat-lbl { font-size: 0.75rem; color: rgba(255,255,255,0.45); font-weight: 500; margin-top: 2px; }
|
||
|
||
/* feature list */
|
||
.ll-features { display: flex; flex-direction: column; gap: 9px; }
|
||
.ll-feat {
|
||
display: flex; align-items: center; gap: 12px;
|
||
font-size: 0.84rem; color: rgba(255,255,255,0.78); font-weight: 600;
|
||
padding: 11px 16px;
|
||
background: rgba(255,255,255,0.055);
|
||
border: 1px solid rgba(255,255,255,0.08);
|
||
border-radius: 12px;
|
||
transition: background .2s, border-color .2s;
|
||
}
|
||
.ll-feat:hover { background: rgba(255,255,255,0.09); border-color: rgba(255,255,255,0.14); }
|
||
.ll-feat-icon {
|
||
width: 28px; height: 28px; border-radius: 8px; flex-shrink: 0;
|
||
display: flex; align-items: center; justify-content: center;
|
||
font-size: 0.9rem;
|
||
}
|
||
.ll-feat-icon.v { background: rgba(155,93,229,0.22); }
|
||
.ll-feat-icon.c { background: rgba(6,214,224,0.18); }
|
||
.ll-feat-icon.g { background: rgba(6,214,100,0.18); }
|
||
.ll-feat-icon.p { background: rgba(241,91,181,0.18); }
|
||
|
||
/* ── Right panel ── */
|
||
.login-right {
|
||
width: 480px; flex-shrink: 0;
|
||
display: flex; align-items: center; justify-content: center;
|
||
padding: 48px 52px;
|
||
background: linear-gradient(145deg, #0e0824 0%, #1a0d38 55%, #160e3a 100%);
|
||
position: relative;
|
||
}
|
||
.login-right::before {
|
||
content: '';
|
||
position: absolute; top: 0; bottom: 0; left: -28px;
|
||
width: 56px; background: inherit;
|
||
clip-path: polygon(28px 0, 56px 0, 56px 100%, 0 100%);
|
||
z-index: 0;
|
||
}
|
||
|
||
.auth-wrap {
|
||
width: 100%; max-width: 380px;
|
||
position: relative; z-index: 1;
|
||
animation: slideUp .45s cubic-bezier(.4,0,.2,1) both;
|
||
}
|
||
@keyframes slideUp { from { opacity: 0; transform: translateY(22px); } to { opacity: 1; transform: translateY(0); } }
|
||
|
||
/* ── Лого-метка ── */
|
||
.auth-logo { display: flex; align-items: center; gap: 10px; margin-bottom: 32px; }
|
||
.auth-logo-mark {
|
||
width: 34px; height: 34px; border-radius: 10px; flex-shrink: 0;
|
||
background: linear-gradient(135deg, #06D6E0, #9B5DE5);
|
||
display: flex; align-items: center; justify-content: center;
|
||
}
|
||
.auth-logo-mark svg { width: 17px; height: 17px; stroke: #fff; stroke-width: 2; fill: none; stroke-linecap: round; stroke-linejoin: round; }
|
||
.auth-logo-name { font-family: 'Unbounded', sans-serif; font-size: 0.88rem; font-weight: 800; color: #fff; letter-spacing: -.01em; }
|
||
.auth-logo-name span { background: var(--grad-1); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; }
|
||
|
||
/* ── Заголовок ── */
|
||
.auth-header { margin-bottom: 30px; }
|
||
.auth-hello {
|
||
font-family: 'Unbounded', sans-serif; font-size: 1.55rem; font-weight: 800; line-height: 1.2;
|
||
background: linear-gradient(135deg, #fff 30%, rgba(155,93,229,0.85) 100%);
|
||
-webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text;
|
||
}
|
||
.auth-sub { font-size: 0.84rem; color: rgba(255,255,255,0.38); margin-top: 8px; font-weight: 500; }
|
||
|
||
/* ── Табы — underline ── */
|
||
.auth-tabs {
|
||
display: flex;
|
||
border-bottom: 1.5px solid rgba(255,255,255,0.08);
|
||
margin-bottom: 30px;
|
||
position: relative;
|
||
background: none; padding: 0;
|
||
}
|
||
.auth-tab {
|
||
flex: 1; padding: 0 4px 14px;
|
||
border: none; background: transparent;
|
||
font-family: 'Manrope', sans-serif;
|
||
font-size: 0.88rem; font-weight: 700;
|
||
color: rgba(255,255,255,0.32); cursor: pointer;
|
||
transition: color .22s; position: relative; z-index: 1;
|
||
}
|
||
.auth-tab.active { color: #fff; }
|
||
.auth-tab-slider {
|
||
position: absolute; bottom: -1.5px; left: 0;
|
||
width: 50%; height: 2px;
|
||
background: linear-gradient(90deg, #9B5DE5, #06D6E0);
|
||
border-radius: 2px;
|
||
box-shadow: 0 0 12px rgba(155,93,229,0.7);
|
||
transition: transform .28s cubic-bezier(.4,0,.2,1);
|
||
}
|
||
.auth-tabs.on-reg .auth-tab-slider { transform: translateX(100%); }
|
||
|
||
/* ── Форма ── */
|
||
.auth-form { display: flex; flex-direction: column; gap: 22px; }
|
||
.auth-form.hidden { display: none; }
|
||
|
||
.auth-field { display: flex; flex-direction: column; }
|
||
.auth-label {
|
||
font-size: 0.7rem; font-weight: 700; color: rgba(255,255,255,0.38);
|
||
letter-spacing: .06em; text-transform: uppercase; margin-bottom: 8px;
|
||
}
|
||
|
||
/* инпут — только нижняя линия */
|
||
.auth-input-wrap { position: relative; }
|
||
.auth-input-icon {
|
||
position: absolute; left: 0; top: 50%; transform: translateY(-50%);
|
||
color: rgba(255,255,255,0.22); pointer-events: none; display: flex;
|
||
transition: color .2s;
|
||
}
|
||
.auth-input-wrap:focus-within .auth-input-icon { color: rgba(155,93,229,0.75); }
|
||
.auth-input {
|
||
width: 100%;
|
||
padding: 10px 30px 10px 26px;
|
||
border: none;
|
||
border-bottom: 1.5px solid rgba(255,255,255,0.12);
|
||
border-radius: 0;
|
||
background: transparent;
|
||
font-family: 'Manrope', sans-serif;
|
||
font-size: 0.92rem; font-weight: 500; color: #fff;
|
||
outline: none;
|
||
transition: border-color .2s;
|
||
}
|
||
.auth-input::placeholder { color: rgba(255,255,255,0.18); }
|
||
.auth-input:focus { border-color: transparent; }
|
||
/* анимированная линия фокуса */
|
||
.auth-input-wrap::after {
|
||
content: '';
|
||
position: absolute; bottom: 0; left: 0;
|
||
width: 0; height: 2px;
|
||
background: linear-gradient(90deg, #9B5DE5, #06D6E0);
|
||
border-radius: 2px;
|
||
box-shadow: 0 0 8px rgba(155,93,229,0.5);
|
||
transition: width .28s cubic-bezier(.4,0,.2,1);
|
||
}
|
||
.auth-input-wrap:focus-within::after { width: 100%; }
|
||
/* кнопка пароля */
|
||
.auth-eye {
|
||
position: absolute; right: 0; top: 50%; transform: translateY(-50%);
|
||
background: none; border: none; cursor: pointer; padding: 4px;
|
||
color: rgba(255,255,255,0.22); display: flex; border-radius: 6px;
|
||
transition: color .2s;
|
||
}
|
||
.auth-eye:hover { color: rgba(155,93,229,0.85); }
|
||
.auth-input.has-eye { padding-right: 30px; }
|
||
|
||
/* ── Ошибка — левый акцент ── */
|
||
.auth-error {
|
||
display: flex; align-items: flex-start; gap: 9px;
|
||
padding: 11px 14px;
|
||
background: rgba(249,65,68,0.09);
|
||
border-left: 3px solid #F94144;
|
||
border-radius: 0 10px 10px 0;
|
||
font-size: 0.81rem; color: #ff8090; font-weight: 600;
|
||
display: none;
|
||
}
|
||
.auth-error.visible { display: flex; }
|
||
.auth-error-icon { flex-shrink: 0; margin-top: 1px; color: #F94144; }
|
||
|
||
/* ── Кнопка ── */
|
||
.auth-btn {
|
||
margin-top: 6px;
|
||
padding: 14px;
|
||
border: none; border-radius: 14px;
|
||
background: linear-gradient(100deg, #7c3aed 0%, #9B5DE5 45%, #06D6E0 100%);
|
||
background-size: 200% 100%; background-position: 0% 0%;
|
||
color: #fff;
|
||
font-family: 'Manrope', sans-serif;
|
||
font-size: 0.95rem; font-weight: 700; letter-spacing: .01em;
|
||
cursor: pointer; position: relative; overflow: hidden;
|
||
transition: background-position .45s ease, transform .15s, box-shadow .15s;
|
||
}
|
||
.auth-btn::before {
|
||
content: '';
|
||
position: absolute; top: 0; left: -80%; width: 50%; height: 100%;
|
||
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.13), transparent);
|
||
transition: left .55s ease;
|
||
}
|
||
.auth-btn:hover:not(:disabled) { background-position: 100% 0%; transform: translateY(-1px); box-shadow: 0 10px 32px rgba(124,58,237,0.5); }
|
||
.auth-btn:hover:not(:disabled)::before { left: 130%; }
|
||
.auth-btn:active:not(:disabled) { transform: translateY(0); }
|
||
.auth-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||
.auth-btn-inner { display: flex; align-items: center; justify-content: center; gap: 8px; }
|
||
.auth-spinner {
|
||
width: 16px; height: 16px;
|
||
border: 2px solid rgba(255,255,255,0.3); border-top-color: #fff;
|
||
border-radius: 50%; animation: spin .7s linear infinite; display: none;
|
||
}
|
||
.auth-btn.loading .auth-spinner { display: block; }
|
||
@keyframes spin { to { transform: rotate(360deg); } }
|
||
|
||
/* ── Футер ── */
|
||
.auth-footer { margin-top: 28px; text-align: center; }
|
||
.auth-footer a {
|
||
display: inline-flex; align-items: center; gap: 6px;
|
||
font-size: 0.79rem; font-weight: 600;
|
||
color: rgba(255,255,255,0.3); text-decoration: none;
|
||
transition: color .2s;
|
||
}
|
||
.auth-footer a:hover { color: #c084fc; }
|
||
.auth-footer a svg { width: 14px; height: 14px; stroke: currentColor; stroke-width: 2; fill: none; }
|
||
|
||
/* ── Разделитель ── */
|
||
.auth-divider {
|
||
display: flex; align-items: center; gap: 10px;
|
||
color: rgba(255,255,255,0.18); font-size: 0.74rem; font-weight: 600;
|
||
}
|
||
.auth-divider::before, .auth-divider::after { content: ''; flex: 1; height: 1px; background: rgba(255,255,255,0.07); }
|
||
|
||
/* ── Password strength ── */
|
||
.pw-strength { display: flex; align-items: center; gap: 9px; margin-top: 8px; }
|
||
.pw-bars { display: flex; gap: 4px; flex: 1; }
|
||
.pw-seg { flex: 1; height: 2px; border-radius: 99px; background: rgba(255,255,255,0.1); transition: background .25s; }
|
||
.pw-seg.lit.s1 { background: #ef4444; }
|
||
.pw-seg.lit.s2 { background: #f59e0b; }
|
||
.pw-seg.lit.s3 { background: #22c55e; }
|
||
.pw-seg.lit.s4 { background: #06d6a0; }
|
||
.pw-lbl { font-size: 0.7rem; font-weight: 700; color: rgba(255,255,255,0.3); min-width: 52px; text-align: right; transition: color .25s; }
|
||
.pw-lbl.s1 { color: #ef4444; } .pw-lbl.s2 { color: #f59e0b; } .pw-lbl.s3 { color: #22c55e; } .pw-lbl.s4 { color: #06d6a0; }
|
||
|
||
/* ── Shake on error ── */
|
||
@keyframes authShake { 0%,100%{transform:translateX(0)} 20%{transform:translateX(-6px)} 40%{transform:translateX(6px)} 60%{transform:translateX(-4px)} 80%{transform:translateX(4px)} }
|
||
.auth-form.shake { animation: authShake .35s ease; }
|
||
|
||
/* ── Input error state ── */
|
||
.auth-input.invalid { border-color: rgba(249,65,68,0.6); }
|
||
|
||
/* ── Progress bar ── */
|
||
#auth-progress { position: fixed; top: 0; left: 0; height: 3px; width: 0%;
|
||
background: linear-gradient(90deg, #9B5DE5, #06D6E0, #F9C74F);
|
||
z-index: 9999; border-radius: 0 2px 2px 0;
|
||
transition: width .25s ease; pointer-events: none; display: none; }
|
||
|
||
/* ── Success button ── */
|
||
.auth-btn.success { background: linear-gradient(135deg, #22c55e, #06d6a0) !important; }
|
||
.auth-btn.success:hover { box-shadow: 0 10px 32px rgba(34,197,94,0.4); }
|
||
|
||
/* ── Remember me ── */
|
||
.auth-remember { display: flex; align-items: center; gap: 9px; margin-top: -6px; }
|
||
.auth-remember input[type=checkbox] { width: 14px; height: 14px; accent-color: #9B5DE5; cursor: pointer; flex-shrink: 0; }
|
||
.auth-remember label { font-size: 0.79rem; color: rgba(255,255,255,0.35); cursor: pointer; user-select: none; }
|
||
|
||
/* ── Caps lock warning ── */
|
||
.caps-warn { display: none; align-items: center; gap: 5px;
|
||
font-size: 0.72rem; font-weight: 700; color: #fbbf24;
|
||
padding: 4px 0; margin-top: 4px; }
|
||
.caps-warn.show { display: flex; }
|
||
|
||
/* ── Input icon transition ── */
|
||
.auth-input-icon { transition: color .2s; }
|
||
|
||
/* ── Canvas in left panel ── */
|
||
#ll-canvas { position: absolute; inset: 0; pointer-events: none; z-index: 0; }
|
||
|
||
/* responsive */
|
||
@media (max-width: 860px) {
|
||
.login-left { padding: 40px 28px; }
|
||
.login-right { width: 100%; padding: 36px 24px; }
|
||
.ll-headline { font-size: 1.4rem; }
|
||
.ll-stats { gap: 12px; }
|
||
}
|
||
@media (max-width: 640px) {
|
||
.login-layout { flex-direction: column; }
|
||
.login-left { min-height: auto; padding: 36px 24px 32px; }
|
||
.ll-stats, .ll-features { display: none; }
|
||
.ll-tagline { margin-bottom: 0; }
|
||
.ll-headline { font-size: 1.25rem; }
|
||
.login-right { padding: 32px 20px 40px; }
|
||
}
|
||
@media (max-width: 400px) {
|
||
.login-left { display: none; }
|
||
.login-right { min-height: 100vh; align-items: flex-start; padding-top: 32px; }
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="login-layout">
|
||
|
||
<!-- ── Left panel ── -->
|
||
<div class="login-left">
|
||
<canvas id="ll-canvas"></canvas>
|
||
<div class="ll-blob ll-blob1"></div>
|
||
<div class="ll-blob ll-blob2"></div>
|
||
<div class="ll-blob ll-blob3"></div>
|
||
<div class="ll-inner">
|
||
<div class="ll-logo">
|
||
<div class="ll-logo-mark"><svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="display:inline-block;vertical-align:middle"><path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/></svg></div>
|
||
Learn<span>Space</span>
|
||
</div>
|
||
<h1 class="ll-headline">Платформа<br>нового поколения<br>для <em>подготовки к ЦТ</em></h1>
|
||
<p class="ll-tagline">Умные задания · Прогресс в реальном времени<br>Аналитика для учителей</p>
|
||
|
||
<div class="ll-stats">
|
||
<div class="ll-stat">
|
||
<div class="ll-stat-val"><span id="stat-q">1000+</span></div>
|
||
<div class="ll-stat-lbl">вопросов в банке</div>
|
||
</div>
|
||
<div class="ll-stat">
|
||
<div class="ll-stat-val"><span id="stat-s">4</span></div>
|
||
<div class="ll-stat-lbl">предмета ЦТ/ЦЭ</div>
|
||
</div>
|
||
<div class="ll-stat">
|
||
<div class="ll-stat-val"><span>∞</span></div>
|
||
<div class="ll-stat-lbl">попыток</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="ll-features">
|
||
<div class="ll-feat">
|
||
<div class="ll-feat-icon v"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="display:inline-block;vertical-align:middle"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="2"/></svg></div>
|
||
Персональные задания по каждому предмету
|
||
</div>
|
||
<div class="ll-feat">
|
||
<div class="ll-feat-icon c"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="display:inline-block;vertical-align:middle"><path d="M12 20V10M18 20V4M6 20v-4"/></svg></div>
|
||
Детальная аналитика прогресса ученика
|
||
</div>
|
||
<div class="ll-feat">
|
||
<div class="ll-feat-icon g"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="display:inline-block;vertical-align:middle"><path d="M3 21V9l9-6 9 6v12"/><path d="M9 21V12h6v9"/></svg></div>
|
||
Управление классами и заданиями
|
||
</div>
|
||
<div class="ll-feat">
|
||
<div class="ll-feat-icon p"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="display:inline-block;vertical-align:middle"><path d="M16 4h2a2 2 0 012 2v14a2 2 0 01-2 2H6a2 2 0 01-2-2V6a2 2 0 012-2h2"/><rect x="8" y="2" width="8" height="4" rx="1"/></svg></div>
|
||
Доска активности и объявлений класса
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ── Right panel ── -->
|
||
<div class="login-right">
|
||
<div class="auth-wrap">
|
||
|
||
<div class="auth-logo">
|
||
<div class="auth-logo-mark">
|
||
<svg viewBox="0 0 24 24"><path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/></svg>
|
||
</div>
|
||
<div class="auth-logo-name">Learn<span>Space</span></div>
|
||
</div>
|
||
|
||
<div class="auth-header">
|
||
<div class="auth-hello" id="auth-hello">С возвращением!</div>
|
||
<div class="auth-sub" id="auth-sub">Войдите в свой аккаунт</div>
|
||
</div>
|
||
|
||
<div class="auth-tabs" id="auth-tabs">
|
||
<div class="auth-tab-slider"></div>
|
||
<button class="auth-tab active" data-tab="login" onclick="switchTab('login')">Войти</button>
|
||
<button class="auth-tab" data-tab="register" onclick="switchTab('register')">Регистрация</button>
|
||
</div>
|
||
|
||
<!-- ── Вход ── -->
|
||
<form class="auth-form" id="form-login" autocomplete="on">
|
||
<div class="auth-error" id="err-login">
|
||
<span class="auth-error-icon"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="display:inline-block;vertical-align:middle"><path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg></span>
|
||
<span id="err-login-text"></span>
|
||
</div>
|
||
<div class="auth-field">
|
||
<label class="auth-label" for="login-email">Email</label>
|
||
<div class="auth-input-wrap">
|
||
<span class="auth-input-icon">
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/></svg>
|
||
</span>
|
||
<input class="auth-input" type="email" id="login-email" name="email" placeholder="you@example.com" autocomplete="email" required />
|
||
</div>
|
||
</div>
|
||
<div class="auth-field">
|
||
<label class="auth-label" for="login-pass">Пароль</label>
|
||
<div class="auth-input-wrap">
|
||
<span class="auth-input-icon">
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
|
||
</span>
|
||
<input class="auth-input has-eye" type="password" id="login-pass" name="password" placeholder="••••••••" autocomplete="current-password" required />
|
||
<button type="button" class="auth-eye" onclick="toggleEye('login-pass', this)" aria-label="Показать пароль">
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/></svg>
|
||
</button>
|
||
</div>
|
||
<div class="caps-warn" id="caps-login-pass">
|
||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5M2 12l10 5 10-5"/></svg>
|
||
Caps Lock включён
|
||
</div>
|
||
</div>
|
||
<div class="auth-remember">
|
||
<input type="checkbox" id="remember-me" />
|
||
<label for="remember-me">Запомнить меня</label>
|
||
</div>
|
||
<button class="auth-btn" type="submit" id="btn-login">
|
||
<div class="auth-btn-inner">
|
||
<div class="auth-spinner"></div>
|
||
<span class="auth-btn-lbl">Войти</span>
|
||
</div>
|
||
</button>
|
||
</form>
|
||
|
||
<!-- ── Регистрация ── -->
|
||
<form class="auth-form hidden" id="form-register" autocomplete="on">
|
||
<div class="auth-error" id="err-reg">
|
||
<span class="auth-error-icon"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="display:inline-block;vertical-align:middle"><path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg></span>
|
||
<span id="err-reg-text"></span>
|
||
</div>
|
||
<div class="auth-field">
|
||
<label class="auth-label" for="reg-name">Имя и фамилия</label>
|
||
<div class="auth-input-wrap">
|
||
<span class="auth-input-icon">
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="8" r="4"/><path d="M4 20c0-4 3.6-7 8-7s8 3 8 7"/></svg>
|
||
</span>
|
||
<input class="auth-input" type="text" id="reg-name" name="name" placeholder="Иван Иванов" autocomplete="name" required />
|
||
</div>
|
||
</div>
|
||
<div class="auth-field">
|
||
<label class="auth-label" for="reg-email">Email</label>
|
||
<div class="auth-input-wrap">
|
||
<span class="auth-input-icon">
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/></svg>
|
||
</span>
|
||
<input class="auth-input" type="email" id="reg-email" name="email" placeholder="you@example.com" autocomplete="email" required />
|
||
</div>
|
||
</div>
|
||
<div class="auth-field">
|
||
<label class="auth-label" for="reg-pass">Пароль</label>
|
||
<div class="auth-input-wrap">
|
||
<span class="auth-input-icon">
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
|
||
</span>
|
||
<input class="auth-input has-eye" type="password" id="reg-pass" name="password" placeholder="Минимум 8 символов" autocomplete="new-password" required minlength="8" />
|
||
<button type="button" class="auth-eye" onclick="toggleEye('reg-pass', this)" aria-label="Показать пароль">
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/></svg>
|
||
</button>
|
||
</div>
|
||
<div class="caps-warn" id="caps-reg-pass">
|
||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5M2 12l10 5 10-5"/></svg>
|
||
Caps Lock включён
|
||
</div>
|
||
<div class="pw-strength" id="pw-strength" style="visibility:hidden">
|
||
<div class="pw-bars">
|
||
<div class="pw-seg" id="pw-s1"></div>
|
||
<div class="pw-seg" id="pw-s2"></div>
|
||
<div class="pw-seg" id="pw-s3"></div>
|
||
<div class="pw-seg" id="pw-s4"></div>
|
||
</div>
|
||
<span class="pw-lbl" id="pw-lbl"></span>
|
||
</div>
|
||
</div>
|
||
<button class="auth-btn" type="submit" id="btn-reg">
|
||
<div class="auth-btn-inner">
|
||
<div class="auth-spinner"></div>
|
||
<span class="auth-btn-lbl">Создать аккаунт</span>
|
||
</div>
|
||
</button>
|
||
</form>
|
||
|
||
<div class="auth-footer">
|
||
<a href="/">
|
||
<svg viewBox="0 0 24 24"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>
|
||
На главную
|
||
</a>
|
||
</div>
|
||
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="auth-progress"></div>
|
||
<script src="/js/api.js"></script>
|
||
<script>
|
||
if (LS.isLoggedIn()) window.location.href = '/dashboard';
|
||
|
||
let _tab = 'login';
|
||
|
||
// ── Remember Me ──
|
||
const RM_KEY = 'ls_remember_email';
|
||
const savedEmail = localStorage.getItem(RM_KEY);
|
||
if (savedEmail) {
|
||
document.getElementById('login-email').value = savedEmail;
|
||
document.getElementById('remember-me').checked = true;
|
||
}
|
||
|
||
// ── Count-up animation ──
|
||
function countUp(el, target, suffix) {
|
||
const start = Date.now();
|
||
const dur = 1200;
|
||
function tick() {
|
||
const t = Math.min(1, (Date.now() - start) / dur);
|
||
const ease = t < 0.5 ? 4*t*t*t : 1 - Math.pow(-2*t+2,3)/2;
|
||
el.textContent = Math.round(ease * target) + suffix;
|
||
if (t < 1) requestAnimationFrame(tick);
|
||
}
|
||
requestAnimationFrame(tick);
|
||
}
|
||
const statQ = document.getElementById('stat-q');
|
||
const statS = document.getElementById('stat-s');
|
||
if (statQ) countUp(statQ, 1000, '+');
|
||
if (statS) countUp(statS, 4, '');
|
||
|
||
// ── Progress bar ──
|
||
let _progTimer = null;
|
||
function startProgress() {
|
||
const bar = document.getElementById('auth-progress');
|
||
bar.style.display = 'block';
|
||
bar.style.width = '0%';
|
||
bar.style.transition = 'none';
|
||
requestAnimationFrame(() => {
|
||
bar.style.transition = 'width 2.5s cubic-bezier(.1,0,.05,1)';
|
||
bar.style.width = '80%';
|
||
});
|
||
}
|
||
function finishProgress() {
|
||
const bar = document.getElementById('auth-progress');
|
||
bar.style.transition = 'width .2s ease';
|
||
bar.style.width = '100%';
|
||
setTimeout(() => { bar.style.display = 'none'; bar.style.width = '0%'; }, 300);
|
||
}
|
||
|
||
// ── Success button ──
|
||
function showBtnSuccess(btnId, label) {
|
||
const btn = document.getElementById(btnId);
|
||
btn.classList.remove('loading');
|
||
btn.classList.add('success');
|
||
btn.querySelector('.auth-btn-lbl').textContent = '✓ ' + label;
|
||
}
|
||
|
||
const HELLO = { login: 'С возвращением!', register: 'Создать аккаунт' };
|
||
const SUB = { login: 'Войдите в свой аккаунт', register: 'Регистрация займёт 30 секунд' };
|
||
|
||
function switchTab(tab) {
|
||
if (_tab === tab) return;
|
||
_tab = tab;
|
||
document.getElementById('auth-hello').textContent = HELLO[tab];
|
||
document.getElementById('auth-sub').textContent = SUB[tab];
|
||
document.querySelectorAll('.auth-tab').forEach(b => b.classList.toggle('active', b.dataset.tab === tab));
|
||
document.getElementById('auth-tabs').classList.toggle('on-reg', tab === 'register');
|
||
// Slide transition
|
||
const formOut = document.getElementById(tab === 'login' ? 'form-register' : 'form-login');
|
||
const formIn = document.getElementById('form-' + tab);
|
||
formOut.classList.add('hidden');
|
||
formIn.classList.remove('hidden');
|
||
formIn.style.opacity = '0';
|
||
formIn.style.transform = 'translateX(' + (tab === 'login' ? '-12px' : '12px') + ')';
|
||
requestAnimationFrame(() => {
|
||
formIn.style.transition = 'opacity .22s ease, transform .22s ease';
|
||
formIn.style.opacity = '1';
|
||
formIn.style.transform = 'translateX(0)';
|
||
});
|
||
hideError('err-login'); hideError('err-reg');
|
||
const f = document.querySelector('#form-' + tab + ' .auth-input');
|
||
if (f) setTimeout(() => f.focus(), 60);
|
||
}
|
||
|
||
function showError(id, msg) {
|
||
const el = document.getElementById(id);
|
||
document.getElementById(id + '-text').textContent = msg;
|
||
el.classList.add('visible');
|
||
const form = el.closest('.auth-form');
|
||
if (form) {
|
||
form.classList.remove('shake');
|
||
void form.offsetWidth;
|
||
form.classList.add('shake');
|
||
form.addEventListener('animationend', () => form.classList.remove('shake'), { once: true });
|
||
}
|
||
form?.querySelectorAll('.auth-input').forEach(inp => inp.classList.add('invalid'));
|
||
}
|
||
function hideError(id) {
|
||
document.getElementById(id).classList.remove('visible');
|
||
document.getElementById(id)?.closest('.auth-form')?.querySelectorAll('.auth-input.invalid').forEach(inp => inp.classList.remove('invalid'));
|
||
}
|
||
|
||
function setLoading(btnId, loading) {
|
||
const btn = document.getElementById(btnId);
|
||
btn.disabled = loading;
|
||
btn.classList.toggle('loading', loading);
|
||
if (loading) btn.classList.remove('success');
|
||
}
|
||
|
||
function toggleEye(inputId, btn) {
|
||
const inp = document.getElementById(inputId);
|
||
const isText = inp.type === 'text';
|
||
inp.type = isText ? 'password' : 'text';
|
||
btn.innerHTML = isText
|
||
? `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/></svg>`
|
||
: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/></svg>`;
|
||
btn.style.color = isText ? '' : 'var(--violet)';
|
||
}
|
||
|
||
// ── Caps Lock detection ──
|
||
function watchCaps(inputId, warnId) {
|
||
const inp = document.getElementById(inputId);
|
||
const warn = document.getElementById(warnId);
|
||
if (!inp || !warn) return;
|
||
inp.addEventListener('keyup', e => {
|
||
warn.classList.toggle('show', e.getModifierState && e.getModifierState('CapsLock'));
|
||
});
|
||
inp.addEventListener('blur', () => warn.classList.remove('show'));
|
||
}
|
||
watchCaps('login-pass', 'caps-login-pass');
|
||
watchCaps('reg-pass', 'caps-reg-pass');
|
||
|
||
// ── Email icon validation ──
|
||
function watchEmail(inputId) {
|
||
const inp = document.getElementById(inputId);
|
||
if (!inp) return;
|
||
const icon = inp.closest('.auth-input-wrap')?.querySelector('.auth-input-icon');
|
||
if (!icon) return;
|
||
inp.addEventListener('input', () => {
|
||
const valid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(inp.value);
|
||
icon.style.color = inp.value.length > 3 ? (valid ? '#22c55e' : '#ef4444') : '';
|
||
});
|
||
}
|
||
watchEmail('login-email');
|
||
watchEmail('reg-email');
|
||
|
||
// ── Login ──
|
||
document.getElementById('form-login').addEventListener('submit', async e => {
|
||
e.preventDefault();
|
||
hideError('err-login');
|
||
setLoading('btn-login', true);
|
||
startProgress();
|
||
try {
|
||
const email = document.getElementById('login-email').value;
|
||
const pass = document.getElementById('login-pass').value;
|
||
if (document.getElementById('remember-me').checked) {
|
||
localStorage.setItem(RM_KEY, email);
|
||
} else {
|
||
localStorage.removeItem(RM_KEY);
|
||
}
|
||
await LS.login(email, pass);
|
||
finishProgress();
|
||
showBtnSuccess('btn-login', 'Входим...');
|
||
const u = LS.getUser();
|
||
setTimeout(() => {
|
||
window.location.href = ['teacher', 'admin'].includes(u?.role) ? '/classes' : '/dashboard';
|
||
}, 400);
|
||
} catch (err) {
|
||
finishProgress();
|
||
showError('err-login', err.message);
|
||
setLoading('btn-login', false);
|
||
}
|
||
});
|
||
|
||
// ── Register ──
|
||
document.getElementById('form-register').addEventListener('submit', async e => {
|
||
e.preventDefault();
|
||
hideError('err-reg');
|
||
setLoading('btn-reg', true);
|
||
startProgress();
|
||
try {
|
||
await LS.register(
|
||
document.getElementById('reg-email').value,
|
||
document.getElementById('reg-pass').value,
|
||
document.getElementById('reg-name').value
|
||
);
|
||
finishProgress();
|
||
showBtnSuccess('btn-reg', 'Аккаунт создан!');
|
||
const u = LS.getUser();
|
||
setTimeout(() => { window.location.href = ['teacher', 'admin'].includes(u?.role) ? '/classes' : '/dashboard'; }, 500);
|
||
} catch (err) {
|
||
finishProgress();
|
||
showError('err-reg', err.message);
|
||
setLoading('btn-reg', false);
|
||
}
|
||
});
|
||
|
||
// ── Password strength ──
|
||
const STRENGTH_LABELS = ['', 'Слабый', 'Средний', 'Хороший', 'Надёжный'];
|
||
function calcStrength(pw) {
|
||
if (!pw) return 0;
|
||
let s = 0;
|
||
if (pw.length >= 6) s++;
|
||
if (pw.length >= 10) s++;
|
||
if (/[A-Z]/.test(pw) && /[a-z]/.test(pw)) s++;
|
||
if (/[\d!@#$%^&*()_\-+=[\]{};:'",.<>?/\\|`~]/.test(pw)) s++;
|
||
return s;
|
||
}
|
||
|
||
document.getElementById('reg-pass').addEventListener('input', function() {
|
||
const pw = this.value;
|
||
const str = calcStrength(pw);
|
||
const wrap = document.getElementById('pw-strength');
|
||
wrap.style.visibility = pw ? 'visible' : 'hidden';
|
||
for (let i = 1; i <= 4; i++) {
|
||
const seg = document.getElementById(`pw-s${i}`);
|
||
seg.className = `pw-seg${i <= str ? ` lit s${str}` : ''}`;
|
||
}
|
||
const lbl = document.getElementById('pw-lbl');
|
||
lbl.className = `pw-lbl${str ? ` s${str}` : ''}`;
|
||
lbl.textContent = pw ? STRENGTH_LABELS[str] : '';
|
||
});
|
||
|
||
// Clear invalid on typing
|
||
document.querySelectorAll('.auth-input').forEach(inp => {
|
||
inp.addEventListener('input', () => inp.classList.remove('invalid'));
|
||
});
|
||
|
||
// ── Neural Network canvas ──
|
||
(function() {
|
||
const canvas = document.getElementById('ll-canvas');
|
||
if (!canvas) return;
|
||
const ctx = canvas.getContext('2d');
|
||
let W, H, mouse = { x: -999, y: -999 }, tick = 0;
|
||
|
||
const COLORS = ['155,93,229', '6,214,224', '241,91,181', '56,189,248'];
|
||
const CONN_D = 145;
|
||
const N_NODES = 90;
|
||
const nodes = [];
|
||
const signals = []; // pulses traveling along edges
|
||
|
||
function resize() {
|
||
const p = canvas.parentElement;
|
||
W = canvas.width = p.clientWidth || p.offsetWidth || 800;
|
||
H = canvas.height = p.clientHeight || p.offsetHeight || 600;
|
||
}
|
||
resize();
|
||
new ResizeObserver(resize).observe(canvas.parentElement);
|
||
|
||
function mkNode(x, y, burst) {
|
||
const hub = !burst && Math.random() < 0.13;
|
||
return {
|
||
x: x ?? Math.random() * W,
|
||
y: y ?? Math.random() * H,
|
||
vx: (Math.random() - 0.5) * (burst ? 2.0 : 0.45),
|
||
vy: (Math.random() - 0.5) * (burst ? 2.0 : 0.45),
|
||
r: hub ? 3.8 + Math.random() * 1.4 : 1.4 + Math.random() * 1.6,
|
||
hub,
|
||
color: COLORS[Math.floor(Math.random() * COLORS.length)],
|
||
pulse: Math.random() * Math.PI * 2,
|
||
life: burst ? 1 : null
|
||
};
|
||
}
|
||
for (let i = 0; i < N_NODES; i++) nodes.push(mkNode());
|
||
|
||
const par = canvas.parentElement;
|
||
par.addEventListener('mousemove', e => {
|
||
const r = canvas.getBoundingClientRect();
|
||
mouse.x = e.clientX - r.left; mouse.y = e.clientY - r.top;
|
||
});
|
||
par.addEventListener('mouseleave', () => { mouse.x = -999; mouse.y = -999; });
|
||
par.addEventListener('click', e => {
|
||
const r = canvas.getBoundingClientRect();
|
||
const cx = e.clientX - r.left, cy = e.clientY - r.top;
|
||
for (let i = 0; i < 8; i++) {
|
||
const n = mkNode(cx, cy, true);
|
||
const a = (i / 8) * Math.PI * 2;
|
||
n.vx = Math.cos(a) * (1.6 + Math.random());
|
||
n.vy = Math.sin(a) * (1.6 + Math.random());
|
||
nodes.push(n);
|
||
}
|
||
});
|
||
|
||
const TAIL_LEN = 10; // comet tail steps
|
||
|
||
function spawnSignal(ax, ay, bx, by, color, nodeB) {
|
||
signals.push({
|
||
ax, ay, bx, by,
|
||
t: 0,
|
||
spd: 0.007 + Math.random() * 0.007,
|
||
color,
|
||
nodeB, // destination node ref for flash (B)
|
||
tail: [], // comet tail history (A)
|
||
edgeActive: false
|
||
});
|
||
}
|
||
|
||
// active edge map: "i,j" → brightness boost (C)
|
||
const activeEdges = new Map();
|
||
|
||
function draw() {
|
||
ctx.clearRect(0, 0, W, H);
|
||
tick++;
|
||
|
||
// ── update nodes ──
|
||
for (let i = nodes.length - 1; i >= 0; i--) {
|
||
const n = nodes[i];
|
||
n.pulse += 0.02;
|
||
if (n.flash > 0) n.flash -= 0.06; // B: decay flash
|
||
|
||
const mdx = mouse.x - n.x, mdy = mouse.y - n.y;
|
||
const md = Math.sqrt(mdx * mdx + mdy * mdy) || 1;
|
||
if (md < 190) {
|
||
const f = (190 - md) / 190;
|
||
if (md < 65) { n.vx -= (mdx / md) * f * 0.042; n.vy -= (mdy / md) * f * 0.042; }
|
||
else { n.vx += (mdx / md) * f * 0.020; n.vy += (mdy / md) * f * 0.020; }
|
||
}
|
||
|
||
n.vx *= 0.986; n.vy *= 0.986;
|
||
n.x += n.vx; n.y += n.vy;
|
||
if (n.x < 0) { n.x = 0; n.vx = Math.abs(n.vx); }
|
||
if (n.x > W) { n.x = W; n.vx = -Math.abs(n.vx); }
|
||
if (n.y < 0) { n.y = 0; n.vy = Math.abs(n.vy); }
|
||
if (n.y > H) { n.y = H; n.vy = -Math.abs(n.vy); }
|
||
|
||
if (n.life !== null) {
|
||
n.life -= 0.018;
|
||
if (n.life <= 0) { nodes.splice(i, 1); continue; }
|
||
}
|
||
}
|
||
|
||
// ── build active edge set from signals (C) ──
|
||
activeEdges.clear();
|
||
for (const s of signals) {
|
||
if (s._edgeKey) activeEdges.set(s._edgeKey, Math.max(activeEdges.get(s._edgeKey) || 0, 1 - Math.abs(s.t - 0.5) * 2));
|
||
}
|
||
|
||
// ── draw edges + collect pairs ──
|
||
const pairs = [];
|
||
for (let i = 0; i < nodes.length; i++) {
|
||
for (let j = i + 1; j < nodes.length; j++) {
|
||
const a = nodes[i], b = nodes[j];
|
||
if (a.life !== null || b.life !== null) continue;
|
||
const dx = a.x - b.x, dy = a.y - b.y;
|
||
const d = Math.sqrt(dx * dx + dy * dy);
|
||
if (d < CONN_D) {
|
||
pairs.push([a, b, i, j]);
|
||
const key = i + ',' + j;
|
||
const boost = activeEdges.get(key) || 0; // C: active boost
|
||
const baseAlpha = (1 - d / CONN_D) * (a.hub || b.hub ? 0.28 : 0.16);
|
||
const alpha = baseAlpha + boost * 0.38;
|
||
const lw = (a.hub || b.hub ? 1.1 : 0.65) + boost * 0.8;
|
||
ctx.beginPath();
|
||
ctx.moveTo(a.x, a.y);
|
||
ctx.lineTo(b.x, b.y);
|
||
ctx.strokeStyle = boost > 0.05
|
||
? `rgba(${a.color},${Math.min(alpha, 0.7)})`
|
||
: `rgba(155,93,229,${alpha})`;
|
||
ctx.lineWidth = lw;
|
||
ctx.stroke();
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── spawn signals ──
|
||
if (tick % 16 === 0 && signals.length < 30 && pairs.length > 0) {
|
||
const idx = Math.floor(Math.random() * pairs.length);
|
||
const [a, b, ni, nj] = pairs[idx];
|
||
const color = Math.random() < 0.5 ? a.color : b.color;
|
||
const sig = { ax: a.x, ay: a.y, bx: b.x, by: b.y,
|
||
t: 0, spd: 0.007 + Math.random() * 0.007,
|
||
color, nodeB: b, tail: [], _edgeKey: ni + ',' + nj };
|
||
signals.push(sig);
|
||
}
|
||
|
||
// ── draw signals: comet tail + head ──
|
||
for (let i = signals.length - 1; i >= 0; i--) {
|
||
const s = signals[i];
|
||
s.t += s.spd;
|
||
|
||
// B: flash destination node on arrival
|
||
if (s.t >= 1) {
|
||
if (s.nodeB && s.nodeB.flash === undefined) s.nodeB.flash = 0;
|
||
if (s.nodeB) s.nodeB.flash = 1.0;
|
||
signals.splice(i, 1);
|
||
continue;
|
||
}
|
||
|
||
const x = s.ax + (s.bx - s.ax) * s.t;
|
||
const y = s.ay + (s.by - s.ay) * s.t;
|
||
const fade = s.t < 0.12 ? s.t / 0.12 : s.t > 0.88 ? (1 - s.t) / 0.12 : 1;
|
||
|
||
// A: push tail, limit length
|
||
s.tail.push({ x, y });
|
||
if (s.tail.length > TAIL_LEN) s.tail.shift();
|
||
|
||
// A: draw comet tail
|
||
for (let k = 0; k < s.tail.length - 1; k++) {
|
||
const tf = (k / TAIL_LEN) * fade;
|
||
ctx.beginPath();
|
||
ctx.moveTo(s.tail[k].x, s.tail[k].y);
|
||
ctx.lineTo(s.tail[k + 1].x, s.tail[k + 1].y);
|
||
ctx.strokeStyle = `rgba(${s.color},${tf * 0.55})`;
|
||
ctx.lineWidth = 1.5 * (k / TAIL_LEN);
|
||
ctx.stroke();
|
||
}
|
||
|
||
// head outer glow
|
||
const g = ctx.createRadialGradient(x, y, 0, x, y, 9);
|
||
g.addColorStop(0, `rgba(${s.color},${0.8 * fade})`);
|
||
g.addColorStop(1, `rgba(${s.color},0)`);
|
||
ctx.beginPath();
|
||
ctx.arc(x, y, 9, 0, Math.PI * 2);
|
||
ctx.fillStyle = g;
|
||
ctx.fill();
|
||
|
||
// head core
|
||
ctx.beginPath();
|
||
ctx.arc(x, y, 2.5, 0, Math.PI * 2);
|
||
ctx.fillStyle = `rgba(255,255,255,${0.97 * fade})`;
|
||
ctx.fill();
|
||
}
|
||
|
||
// ── draw nodes ──
|
||
for (const n of nodes) {
|
||
const flash = Math.max(0, n.flash || 0); // B
|
||
const baseA = n.life !== null ? n.life * 0.9 : 0.5 + Math.sin(n.pulse) * 0.12 + flash * 0.5;
|
||
const radius = n.r * (0.93 + Math.sin(n.pulse) * 0.07 + flash * 0.5);
|
||
const glowR = radius * (n.hub ? 5.5 : 4) * (1 + flash * 0.8);
|
||
|
||
// glow halo (brighter on flash)
|
||
const g = ctx.createRadialGradient(n.x, n.y, 0, n.x, n.y, glowR);
|
||
g.addColorStop(0, `rgba(${n.color},${Math.min(baseA * 0.38 + flash * 0.4, 0.9)})`);
|
||
g.addColorStop(1, `rgba(${n.color},0)`);
|
||
ctx.beginPath();
|
||
ctx.arc(n.x, n.y, glowR, 0, Math.PI * 2);
|
||
ctx.fillStyle = g;
|
||
ctx.fill();
|
||
|
||
// B: flash ring
|
||
if (flash > 0.05) {
|
||
ctx.beginPath();
|
||
ctx.arc(n.x, n.y, radius + flash * 10, 0, Math.PI * 2);
|
||
ctx.strokeStyle = `rgba(${n.color},${flash * 0.6})`;
|
||
ctx.lineWidth = flash * 2;
|
||
ctx.stroke();
|
||
}
|
||
|
||
// core dot
|
||
ctx.beginPath();
|
||
ctx.arc(n.x, n.y, radius, 0, Math.PI * 2);
|
||
ctx.fillStyle = `rgba(${n.color},${Math.min(baseA, 1)})`;
|
||
ctx.fill();
|
||
|
||
// hub: outer ring
|
||
if (n.hub) {
|
||
ctx.beginPath();
|
||
ctx.arc(n.x, n.y, radius + 3, 0, Math.PI * 2);
|
||
ctx.strokeStyle = `rgba(${n.color},${baseA * 0.38})`;
|
||
ctx.lineWidth = 1;
|
||
ctx.stroke();
|
||
}
|
||
}
|
||
|
||
requestAnimationFrame(draw);
|
||
}
|
||
draw();
|
||
})();
|
||
|
||
// auto-focus: if email was pre-filled, focus password
|
||
if (savedEmail) {
|
||
document.getElementById('login-pass').focus();
|
||
} else {
|
||
document.getElementById('login-email').focus();
|
||
}
|
||
</script>
|
||
</body>
|
||
</html>
|