LearnSpace: full-stack educational whiteboard platform

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-04-12 10:10:37 +03:00
commit be4d43105e
204 changed files with 118117 additions and 0 deletions
+960
View File
@@ -0,0 +1,960 @@
<!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>
/* board-specific extra variables */
:root {
--surface: rgba(255,255,255,0.88);
--border: rgba(15,23,42,0.09); --border-h: rgba(15,23,42,0.18);
--amber: #FF9F1C;
--grad-v: linear-gradient(135deg, #9B5DE5, #c084fc);
--grad-c: linear-gradient(135deg, #06D6E0, #06b6d4);
--grad-g: linear-gradient(135deg, #06D664, #06b84a);
--blur: blur(24px); --shadow: 0 4px 24px rgba(15,23,42,0.08);
--shadow-h: 0 12px 48px rgba(15,23,42,0.14);
}
#bg-canvas { position: fixed; inset: 0; pointer-events: none; z-index: 0; }
.notif-badge { position: absolute; top: 2px; right: 2px; min-width: 16px; height: 16px; border-radius: 999px; background: #F15BB5; color: #fff; font-size: 0.65rem; font-weight: 700; display: flex; align-items: center; justify-content: center; padding: 0 4px; }
/* ── container ── */
.container { position: relative; z-index: 1; max-width: 1140px; margin: 0 auto; padding: 28px 24px 80px; }
/* ── new items banner ── */
.new-banner {
display: none;
align-items: center; gap: 10px;
padding: 10px 18px;
margin-bottom: 16px;
background: linear-gradient(135deg, rgba(6,214,224,0.12), rgba(155,93,229,0.12));
border: 1px solid rgba(155,93,229,0.25);
border-radius: var(--r-pill);
font-size: 0.83rem; font-weight: 700; color: var(--violet);
cursor: pointer;
animation: banner-in 0.4s cubic-bezier(.34,1.56,.64,1);
}
.new-banner.visible { display: flex; }
@keyframes banner-in { from { opacity:0; transform:translateY(-8px); } to { opacity:1; transform:none; } }
/* ── board header ── */
.board-header { display: flex; align-items: center; gap: 12px; margin-bottom: 20px; flex-wrap: wrap; }
.board-title {
font-family: 'Unbounded', sans-serif;
font-size: 1.3rem; font-weight: 800; flex: 1; min-width: 180px;
background: var(--grad-1); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text;
}
.live-badge { display: flex; align-items: center; gap: 5px; padding: 4px 12px; border-radius: var(--r-pill); background: rgba(6,214,100,0.10); border: 1px solid rgba(6,214,100,0.30); font-size: 0.75rem; font-weight: 700; color: #06a84e; user-select: none; }
.live-dot { width: 7px; height: 7px; border-radius: 50%; background: #06D664; animation: pulse-dot 1.6s ease-in-out infinite; }
@keyframes pulse-dot { 0%,100% { opacity:1; transform:scale(1); } 50% { opacity:0.5; transform:scale(1.5); } }
.class-select { padding: 7px 14px; border: 1.5px solid var(--border-h); border-radius: var(--r-pill); background: var(--surface); font-family: 'Manrope', sans-serif; font-size: 0.85rem; font-weight: 600; color: var(--text); cursor: pointer; outline: none; transition: border-color var(--tr); }
.class-select:focus { border-color: var(--violet); }
.refresh-info { font-size: 0.72rem; color: var(--text-3); display: flex; align-items: center; gap: 5px; }
/* ── search ── */
.search-wrap { position: relative; margin-bottom: 16px; }
.search-input {
width: 100%;
padding: 10px 16px 10px 40px;
border: 1.5px solid var(--border-h);
border-radius: var(--r-pill);
background: var(--surface);
backdrop-filter: var(--blur);
font-family: 'Manrope', sans-serif;
font-size: 0.88rem; color: var(--text);
outline: none;
transition: border-color var(--tr), box-shadow var(--tr);
}
.search-input:focus { border-color: var(--violet); box-shadow: 0 0 0 3px rgba(155,93,229,0.10); }
.search-icon { position: absolute; left: 14px; top: 50%; transform: translateY(-50%); font-size: 1rem; pointer-events: none; }
/* ── filter tabs ── */
.filter-tabs { display: flex; gap: 6px; margin-bottom: 24px; flex-wrap: wrap; }
.filter-tab { padding: 7px 18px; border: 1.5px solid var(--border-h); border-radius: var(--r-pill); background: transparent; font-family: 'Manrope', sans-serif; font-size: 0.82rem; font-weight: 600; color: var(--text-3); cursor: pointer; transition: all var(--tr); }
.filter-tab:hover { border-color: var(--violet); color: var(--violet); }
.filter-tab.active { background: var(--grad-1); border-color: transparent; color: #fff; box-shadow: 0 4px 16px rgba(155,93,229,0.28); }
.filter-count { display: inline-block; min-width: 18px; height: 18px; padding: 0 5px; border-radius: 999px; background: rgba(255,255,255,0.25); font-size: 0.68rem; font-weight: 800; line-height: 18px; margin-left: 4px; }
.filter-tab:not(.active) .filter-count { background: rgba(15,23,42,0.07); color: var(--text-3); }
/* ── compose ── */
.compose-wrap { margin-bottom: 20px; }
.compose-toggle { display: inline-flex; align-items: center; gap: 7px; padding: 9px 20px; border: 1.5px dashed var(--border-h); border-radius: var(--r-pill); background: transparent; font-family: 'Manrope', sans-serif; font-size: 0.82rem; font-weight: 600; color: var(--text-3); cursor: pointer; transition: all var(--tr); }
.compose-toggle:hover { border-color: var(--cyan); color: #0aa6b0; background: rgba(6,214,224,0.04); }
.compose-box { display: none; background: var(--surface); backdrop-filter: var(--blur); border: 1px solid var(--border); border-radius: var(--r-lg); padding: 18px; box-shadow: var(--shadow); margin-top: 10px; animation: card-in 0.2s ease; }
.compose-box.open { display: block; }
.compose-textarea { width: 100%; min-height: 80px; resize: vertical; padding: 10px 12px; border: 1px solid var(--border-h); border-radius: 12px; background: rgba(255,255,255,0.7); font-family: 'Manrope', sans-serif; font-size: 0.9rem; color: var(--text); outline: none; transition: border-color var(--tr); }
.compose-textarea:focus { border-color: var(--cyan); }
.compose-footer { display: flex; align-items: center; gap: 8px; margin-top: 10px; }
.compose-chars { font-size: 0.72rem; color: var(--text-3); margin-right: auto; }
.compose-chars.warn { color: #c07400; }
.btn-post { padding: 8px 22px; border: none; border-radius: var(--r-pill); background: var(--grad-1); color: #fff; font-family: 'Manrope', sans-serif; font-size: 0.85rem; font-weight: 700; cursor: pointer; transition: transform var(--tr), box-shadow var(--tr); }
.btn-post:hover { transform: translateY(-1px); box-shadow: 0 6px 24px rgba(6,214,224,0.35); }
.btn-post:disabled { opacity: 0.55; cursor: not-allowed; transform: none; }
.btn-cancel-compose { padding: 8px 16px; border: 1.5px solid var(--border-h); border-radius: var(--r-pill); background: transparent; font-family: 'Manrope', sans-serif; font-size: 0.85rem; font-weight: 600; color: var(--text-3); cursor: pointer; transition: all var(--tr); }
.btn-cancel-compose:hover { border-color: var(--pink); color: #c0306a; }
/* ── masonry ── */
.masonry { columns: 3 320px; column-gap: 16px; }
/* ── card base ── */
.card {
break-inside: avoid;
margin-bottom: 12px;
background: var(--surface);
backdrop-filter: var(--blur);
border: 1px solid var(--border);
border-radius: var(--r-lg);
overflow: hidden;
box-shadow: var(--shadow);
transition: transform 0.2s ease, box-shadow 0.2s ease;
opacity: 0;
animation: card-in 0.38s ease forwards;
will-change: transform;
}
.card:hover { box-shadow: var(--shadow-h); }
@keyframes card-in { from { opacity:0; transform:translateY(16px) scale(0.98); } to { opacity:1; transform:none; } }
/* card accent strip */
.card-strip {
height: 3px;
width: 100%;
}
.card-assignment .card-strip { background: var(--grad-v); }
.card-announcement .card-strip { background: var(--grad-c); }
.card-activity .card-strip { background: var(--grad-g); }
.card-body { padding: 12px 15px 10px; }
/* card icon + type row */
.card-head { display: flex; align-items: flex-start; gap: 9px; margin-bottom: 8px; }
.card-icon {
width: 34px; height: 34px; border-radius: 9px;
display: flex; align-items: center; justify-content: center;
font-size: 1rem; flex-shrink: 0;
}
.card-assignment .card-icon { background: linear-gradient(135deg, rgba(155,93,229,0.14), rgba(155,93,229,0.06)); }
.card-announcement .card-icon { background: linear-gradient(135deg, rgba(6,214,224,0.14), rgba(6,214,224,0.06)); }
.card-activity .card-icon { background: linear-gradient(135deg, rgba(6,214,100,0.14), rgba(6,214,100,0.06)); }
.card-head-info { flex: 1; min-width: 0; }
.card-type-label { font-size: 0.63rem; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; margin-bottom: 1px; }
.card-assignment .card-type-label { color: var(--violet); }
.card-announcement .card-type-label { color: #0aa6b0; }
.card-activity .card-type-label { color: #0b9e4a; }
.card-head-actions { display: flex; align-items: center; gap: 5px; flex-shrink: 0; }
.card-title { font-size: 0.87rem; font-weight: 700; line-height: 1.35; word-break: break-word; }
/* NEW badge */
.badge-new { display: inline-block; padding: 1px 7px; border-radius: var(--r-pill); background: var(--grad-1); color: #fff; font-size: 0.6rem; font-weight: 800; letter-spacing: 0.04em; vertical-align: middle; margin-left: 6px; }
/* tags */
.tags { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 6px; }
.tag { padding: 2px 8px; border-radius: var(--r-pill); background: rgba(15,23,42,0.05); font-size: 0.67rem; font-weight: 600; color: var(--text-3); }
.tag.done { background: rgba(6,214,100,0.12); color: #06a84e; }
.tag.class { background: rgba(6,214,224,0.10); color: #0aa6b0; }
.tag.personal{ background: rgba(241,91,181,0.10); color: #9b3c7e; }
.tag.pending { background: rgba(255,159,28,0.12); color: #c07400; }
.tag.hw { background: rgba(155,93,229,0.10); color: var(--violet); }
.tag.late { background: rgba(241,91,181,0.10); color: #c0306a; }
.tag.urgent { background: rgba(255,59,48,0.10); color: #d63300; }
/* deadline countdown */
.deadline-row { display: flex; align-items: center; gap: 5px; margin-top: 6px; font-size: 0.72rem; color: var(--text-3); }
.deadline-row .icon { font-size: 0.85rem; }
.countdown { font-weight: 700; }
.countdown.urgent { color: #d63300; }
.countdown.warn { color: #c07400; }
.countdown.ok { color: var(--text-2); }
/* SVG progress ring */
.ring-wrap { display: flex; align-items: center; gap: 10px; margin-top: 8px; }
.ring-label { font-size: 0.73rem; color: var(--text-3); line-height: 1.5; }
.ring-label strong { color: var(--text); font-weight: 700; }
/* announcement text expand */
.announce-text { font-size: 0.82rem; line-height: 1.6; color: var(--text-2); white-space: pre-wrap; word-break: break-word; }
.announce-text.clamped { display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
.expand-btn { margin-top: 4px; background: none; border: none; font-family: 'Manrope', sans-serif; font-size: 0.74rem; font-weight: 700; color: var(--violet); cursor: pointer; padding: 0; }
.expand-btn:hover { text-decoration: underline; }
/* reactions */
.reactions { display: flex; gap: 4px; margin-top: 8px; flex-wrap: wrap; }
.reaction-btn {
display: inline-flex; align-items: center; gap: 3px;
padding: 3px 8px;
border: 1.5px solid var(--border-h);
border-radius: var(--r-pill);
background: transparent;
font-size: 0.78rem; font-weight: 600; color: var(--text-3);
cursor: pointer; transition: all 0.15s ease;
}
.reaction-btn:hover { border-color: var(--violet); background: rgba(155,93,229,0.06); }
.reaction-btn.active { border-color: var(--violet); background: rgba(155,93,229,0.10); color: var(--violet); }
/* activity score */
.score-chip { display: inline-flex; align-items: center; gap: 4px; padding: 3px 10px; border-radius: var(--r-pill); font-size: 0.76rem; font-weight: 700; }
.score-chip.good { background: rgba(6,214,100,0.12); color: #06a84e; }
.score-chip.ok { background: rgba(255,159,28,0.12); color: #c07400; }
.score-chip.bad { background: rgba(241,91,181,0.10); color: #c0306a; }
/* card footer */
.card-footer { display: flex; align-items: center; justify-content: space-between; margin-top: 8px; padding-top: 7px; border-top: 1px solid var(--border); }
.card-time { font-size: 0.72rem; color: var(--text-3); }
.card-author { font-size: 0.72rem; color: var(--text-3); font-weight: 600; }
/* action buttons */
.btn-action {
padding: 5px 14px; border: none; border-radius: var(--r-pill);
background: var(--grad-1); color: #fff;
font-family: 'Manrope', sans-serif; font-size: 0.78rem; font-weight: 700;
cursor: pointer; transition: transform var(--tr), box-shadow var(--tr);
text-decoration: none; display: inline-block;
}
.btn-action:hover { transform: translateY(-1px); box-shadow: 0 4px 16px rgba(155,93,229,0.3); }
.btn-del-ann {
width: 28px; height: 28px; border-radius: 50%;
border: 1.5px solid var(--border-h);
background: transparent;
font-size: 0.85rem; color: var(--text-3);
cursor: pointer; display: flex; align-items: center; justify-content: center;
transition: all var(--tr);
}
.btn-del-ann:hover { border-color: #c0306a; color: #c0306a; background: rgba(241,91,181,0.07); }
/* empty + loading */
.empty-state { column-span: all; text-align: center; padding: 60px 20px; color: var(--text-3); font-size: 0.95rem; width: 100%; }
.empty-state .empty-icon { display: none; }
.skeleton-card { break-inside: avoid; margin-bottom: 12px; background: rgba(255,255,255,0.6); border: 1px solid var(--border); border-radius: var(--r-lg); padding: 14px; animation: shimmer 1.4s ease-in-out infinite; }
@keyframes shimmer { 0%,100% { opacity:0.7; } 50% { opacity:0.35; } }
.sk-line { height: 12px; border-radius: 6px; background: rgba(15,23,42,0.08); margin-bottom: 10px; }
.sk-circle { width: 40px; height: 40px; border-radius: 12px; background: rgba(15,23,42,0.08); flex-shrink: 0; }
/* ── Mobile responsive ── */
@media (max-width: 768px) {
.container { padding: 20px 14px 80px; }
.board-header { flex-wrap: wrap; gap: 8px; }
.board-title { font-size: 1.05rem !important; min-width: 0; flex: 1 1 auto; }
.class-select { font-size: 0.82rem; padding: 6px 12px; }
.masonry { columns: 1 !important; }
.filter-tab { padding: 6px 12px; font-size: 0.78rem; }
.compose-box { padding: 14px; }
}
@media (max-width: 480px) {
.board-header { gap: 6px; }
.live-badge, .refresh-info { display: none; }
}
</style>
<script src="https://cdn.jsdelivr.net/npm/lucide@0.469.0/dist/umd/lucide.min.js"></script>
</head>
<body>
<canvas id="bg-canvas"></canvas>
<div class="app-layout">
<aside class="sidebar">
<div class="sb-brand">
<a href="/dashboard" class="sb-logo"><span class="sb-lbl">Learn<span>Space</span></span></a>
<button class="sb-toggle" title="Свернуть меню"><i data-lucide="chevron-left" class="sb-icon"></i></button>
</div>
<nav class="sb-nav">
<button class="sb-link" onclick="lsSearchOpen()" title="Ctrl+K"><i data-lucide="search" class="sb-icon"></i><span class="sb-lbl">Поиск</span></button>
<a href="/dashboard" class="sb-link"><i data-lucide="home" class="sb-icon"></i><span class="sb-lbl">Дашборд</span></a>
<span class="sb-link active"><i data-lucide="layout-dashboard" class="sb-icon"></i><span class="sb-lbl">Доска</span></span>
<a href="/classes" class="sb-link" id="btn-classes" style="display:none"><i data-lucide="graduation-cap" class="sb-icon"></i><span class="sb-lbl">Классы</span></a>
<a href="/library" class="sb-link"><i data-lucide="book-open" class="sb-icon"></i><span class="sb-lbl">Библиотека</span></a>
<a href="/theory" class="sb-link"><i data-lucide="brain" class="sb-icon"></i><span class="sb-lbl">Теория</span></a>
<a href="/lab" class="sb-link"><i data-lucide="atom" class="sb-icon"></i><span class="sb-lbl">Лаборатория</span></a>
<a href="/biochem" class="sb-link"><i data-lucide="flask-conical" class="sb-icon"></i><span class="sb-lbl">Биохимия</span></a>
<a href="/hangman" class="sb-link"><i data-lucide="gamepad-2" class="sb-icon"></i><span class="sb-lbl">Виселица</span></a>
<a href="/crossword" class="sb-link"><i data-lucide="grid-3x3" class="sb-icon"></i><span class="sb-lbl">Кроссворд</span></a>
<a href="/pet" class="sb-link"><i data-lucide="heart" class="sb-icon"></i><span class="sb-lbl">Питомец</span></a>
<a href="/collection" class="sb-link"><i data-lucide="layers" class="sb-icon"></i><span class="sb-lbl">Коллекция</span></a>
<a href="/knowledge-map" class="sb-link"><i data-lucide="share-2" class="sb-icon"></i><span class="sb-lbl">Карта знаний</span></a>
<a href="/red-book.html" class="sb-link"><i data-lucide="leaf" class="sb-icon"></i><span class="sb-lbl">Красная книга</span></a>
<a href="/classroom" class="sb-link"><i data-lucide="presentation" class="sb-icon"></i><span class="sb-lbl">Онлайн-урок</span></a>
<div class="sb-divider"></div>
<a href="/analytics" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="bar-chart-2" class="sb-icon"></i><span class="sb-lbl">Аналитика</span></a>
<a href="/question-bank" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="database" class="sb-icon"></i><span class="sb-lbl">Банк вопросов</span></a>
<a href="/live-quiz" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="radio" class="sb-icon"></i><span class="sb-lbl">Live-квиз</span></a>
<a href="/gradebook" class="sb-link sb-teacher-only" style="display:none"><i data-lucide="table" class="sb-icon"></i><span class="sb-lbl">Журнал</span></a>
<a href="/admin" class="sb-link" id="btn-admin" style="display:none"><i data-lucide="settings" class="sb-icon"></i><span class="sb-lbl">Управление</span></a>
</nav>
<div style="padding: 4px 2px">
<div id="notif-wrap">
<button class="sb-link" id="notif-btn" onclick="LS.notif.toggle()">
<i data-lucide="bell" class="sb-icon"></i><span class="sb-lbl">Уведомления</span>
<span class="sb-badge" id="notif-badge" style="display:none"></span>
</button>
</div>
</div>
<div class="sb-foot">
<a href="/profile" class="sb-user-row" style="text-decoration:none">
<div class="sb-avatar" id="nav-avatar">?</div>
<div class="sb-user-info">
<div class="sb-user-name" id="nav-user"></div>
<span class="sb-logout" style="pointer-events:none">Мой профиль</span>
</div>
</a>
</div>
</aside>
<div class="notif-drop" id="notif-drop"></div>
<div class="sb-content">
<div class="container">
<!-- new items banner -->
<div class="new-banner" id="new-banner" onclick="dismissBanner()">
<i data-lucide="sparkles" style="width:14px;height:14px;vertical-align:-2px"></i> <span id="new-banner-text">Новые записи</span> — нажмите чтобы обновить
</div>
<!-- header -->
<div class="board-header">
<div class="board-title" id="board-title">Доска класса</div>
<div class="live-badge"><div class="live-dot"></div> LIVE</div>
<select class="class-select" id="class-select" style="display:none"></select>
<span class="refresh-info" id="refresh-info">Обновление через 30 с</span>
</div>
<!-- compose -->
<div class="compose-wrap" id="compose-wrap" style="display:none">
<button class="compose-toggle" onclick="toggleCompose()"><i data-lucide="megaphone" style="width:14px;height:14px;vertical-align:-2px"></i> Написать объявление</button>
<div class="compose-box" id="compose-box">
<textarea class="compose-textarea" id="compose-text" placeholder="Напишите объявление для класса…" oninput="onComposeInput()" maxlength="1000"></textarea>
<div class="compose-footer">
<span class="compose-chars" id="compose-chars">0 / 1000</span>
<button class="btn-cancel-compose" onclick="toggleCompose()">Отмена</button>
<button class="btn-post" id="btn-post" onclick="postAnnouncement()">Опубликовать</button>
</div>
</div>
</div>
<!-- search -->
<div class="search-wrap">
<i data-lucide="search" class="sb-icon" style="width:16px;height:16px"></i>
<input class="search-input" type="text" id="search-input" placeholder="Поиск по доске…" oninput="render()" />
</div>
<!-- filter tabs -->
<div class="filter-tabs" id="filter-tabs">
<button class="filter-tab active" data-filter="all">Все <span class="filter-count" id="cnt-all">0</span></button>
<button class="filter-tab" data-filter="assignment"><i data-lucide="clipboard-list" class="sb-icon"></i> Задания <span class="filter-count" id="cnt-assignment">0</span></button>
<button class="filter-tab" data-filter="announcement"><i data-lucide="megaphone" class="sb-icon"></i> Объявления <span class="filter-count" id="cnt-announcement">0</span></button>
<button class="filter-tab" data-filter="activity"><i data-lucide="zap" class="sb-icon"></i> Активность <span class="filter-count" id="cnt-activity">0</span></button>
</div>
<!-- masonry -->
<div class="masonry" id="masonry"></div>
</div>
<script src="/js/api.js"></script>
<script src="/js/notifications.js"></script>
<script>
if (!LS.requireAuth()) throw 0;
/* ════════════════════════════════════════
CANVAS BACKGROUND
════════════════════════════════════════ */
(function initCanvas() {
const canvas = document.getElementById('bg-canvas');
const ctx = canvas.getContext('2d');
let W, H;
const mouse = { x: 0, y: 0, tx: 0, ty: 0 };
function resize() { W = canvas.width = window.innerWidth; H = canvas.height = window.innerHeight; }
resize();
window.addEventListener('resize', resize);
document.addEventListener('mousemove', e => { mouse.tx = e.clientX; mouse.ty = e.clientY; });
const circles = Array.from({ length: 24 }, () => ({
x: Math.random() * 1400, y: Math.random() * 900,
r: 50 + Math.random() * 120,
dx: (Math.random() - 0.5) * 0.2,
dy: (Math.random() - 0.5) * 0.2,
hue: [272, 185, 320][Math.floor(Math.random() * 3)],
hueSpeed: (Math.random() - 0.5) * 0.08,
alpha: 0.025 + Math.random() * 0.045,
px: 0.008 + Math.random() * 0.022,
}));
function draw() {
mouse.x += (mouse.tx - mouse.x) * 0.06;
mouse.y += (mouse.ty - mouse.y) * 0.06;
const mx = mouse.x - W / 2, my = mouse.y - H / 2;
ctx.clearRect(0, 0, W, H);
for (const c of circles) {
c.hue += c.hueSpeed;
c.x += c.dx + mx * c.px * 0.003;
c.y += c.dy + my * c.px * 0.003;
if (c.x < -c.r) c.x = W + c.r;
if (c.x > W + c.r) c.x = -c.r;
if (c.y < -c.r) c.y = H + c.r;
if (c.y > H + c.r) c.y = -c.r;
const g = ctx.createRadialGradient(c.x, c.y, 0, c.x, c.y, c.r);
g.addColorStop(0, `hsla(${c.hue},75%,65%,${c.alpha})`);
g.addColorStop(1, `hsla(${c.hue},75%,65%,0)`);
ctx.beginPath();
ctx.arc(c.x, c.y, c.r, 0, Math.PI * 2);
ctx.fillStyle = g;
ctx.fill();
}
requestAnimationFrame(draw);
}
draw();
})();
/* ════════════════════════════════════════
CARD MAGNETIC HOVER
════════════════════════════════════════ */
document.addEventListener('mousemove', e => {
const card = e.target.closest?.('.card');
if (!card) return;
const r = card.getBoundingClientRect();
const mx = (e.clientX - r.left) / r.width - 0.5;
const my = (e.clientY - r.top) / r.height - 0.5;
card.style.transform = `perspective(600px) rotateY(${mx * 4}deg) rotateX(${-my * 4}deg) translateY(-3px)`;
});
document.addEventListener('mouseleave', () => {
document.querySelectorAll('.card').forEach(c => c.style.transform = '');
}, true);
document.addEventListener('mouseover', e => {
if (!e.target.closest?.('.card')) {
document.querySelectorAll('.card').forEach(c => c.style.transform = '');
}
});
/* ════════════════════════════════════════
STATE
════════════════════════════════════════ */
const { user, isTeacher, isAdmin } = LS.initPage();
let _classes = [];
let _currentClassId = null;
let _feed = null;
let _prevTotal = 0;
let _filter = 'all';
let _refreshTimer = null;
let _reactions = {}; // { `ann_${id}`: { '<svg class="ic" viewBox="0 0 24 24"><path d="M7 10v12"/><path d="M15 5.88 14 10h5.83a2 2 0 0 1 1.92 2.56l-2.33 8A2 2 0 0 1 17.5 22H4a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2h2.76a2 2 0 0 0 1.79-1.11L12 2a3.13 3.13 0 0 1 3 3.88Z"/></svg>': true, '<svg class="ic" viewBox="0 0 24 24"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg>': false, '<svg class="ic" viewBox="0 0 24 24"><path d="M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.072-2.143-.224-4.054 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.153.433-2.294 1-3a2.5 2.5 0 0 0 2.5 3z"/></svg>': false } }
let _expanded = {}; // { `ann_${id}`: true/false }
let _deadlineTimers = [];
/* nav */
if (isTeacher) {
document.getElementById('btn-classes').style.display = '';
if (isAdmin) document.getElementById('btn-admin').style.display = '';
}
/* reactions from localStorage */
function loadReactions() {
try { _reactions = JSON.parse(localStorage.getItem('ls_reactions') || '{}'); } catch {}
}
function saveReactions() {
try { localStorage.setItem('ls_reactions', JSON.stringify(_reactions)); } catch {}
}
/* filter tabs */
document.querySelectorAll('.filter-tab').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.filter-tab').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
_filter = btn.dataset.filter;
animateFilter();
});
});
function animateFilter() {
const m = document.getElementById('masonry');
m.style.opacity = '0';
m.style.transform = 'translateY(8px)';
m.style.transition = 'opacity 0.15s ease, transform 0.15s ease';
setTimeout(() => {
render();
m.style.opacity = '1';
m.style.transform = '';
}, 150);
}
/* search */
document.getElementById('search-input').addEventListener('keydown', e => {
if (e.key === 'Escape') { e.target.value = ''; render(); }
});
/* ════════════════════════════════════════
INIT
════════════════════════════════════════ */
async function init() {
loadReactions();
const preselect = Number(sessionStorage.getItem('board_class') || 0);
sessionStorage.removeItem('board_class');
try {
_classes = isTeacher ? await LS.getClasses() : await LS.myClasses();
if (!_classes.length) {
document.getElementById('masonry').innerHTML = `<div class="empty-state"><div class="empty-icon"><i data-lucide="graduation-cap" style="width:48px;height:48px;stroke:var(--text-3);stroke-width:1.2"></i></div><p>Вы не состоите ни в одном классе</p><p style="font-size:0.82rem;color:var(--text-3);margin-top:4px">Попросите учителя дать код приглашения и введите его на <a href="/dashboard" style="color:var(--violet);font-weight:600">дашборде</a></p></div>`;
return;
}
const sel = document.getElementById('class-select');
if (_classes.length > 1) {
const hasPreselect = preselect && _classes.some(c => c.id === preselect);
const placeholder = document.createElement('option');
placeholder.value = ''; placeholder.textContent = '— Выберите класс —';
sel.appendChild(placeholder);
_classes.forEach(c => {
const o = document.createElement('option');
o.value = c.id; o.textContent = c.name;
if (c.id === preselect) o.selected = true;
sel.appendChild(o);
});
sel.style.display = '';
sel.addEventListener('change', () => {
const val = Number(sel.value);
if (!val) return;
_currentClassId = val;
loadFeed();
scheduleRefresh();
});
// Always load first class (or preselected one)
const loadId = hasPreselect ? preselect : _classes[0].id;
_currentClassId = loadId;
sel.value = loadId;
showSkeletons();
await loadFeed();
scheduleRefresh();
} else {
_currentClassId = _classes[0].id;
showSkeletons();
await loadFeed();
scheduleRefresh();
}
} catch (e) {
document.getElementById('masonry').innerHTML = `<div class="empty-state"><p style="color:var(--pink)">${esc(e.message)}</p></div>`;
}
}
/* ════════════════════════════════════════
SKELETONS
════════════════════════════════════════ */
function showSkeletons() {
document.getElementById('masonry').innerHTML = Array.from({ length: 6 }, () => `
<div class="skeleton-card">
<div style="display:flex;gap:12px;margin-bottom:12px">
<div class="sk-circle"></div>
<div style="flex:1">
<div class="sk-line" style="width:40%;height:9px"></div>
<div class="sk-line" style="width:75%;height:13px;margin-top:6px"></div>
</div>
</div>
<div class="sk-line" style="width:100%"></div>
<div class="sk-line" style="width:65%"></div>
</div>`).join('');
}
/* ════════════════════════════════════════
LOAD FEED
════════════════════════════════════════ */
async function loadFeed(silent = false) {
if (!silent) showSkeletons();
try {
const newFeed = await LS.classFeed(_currentClassId);
const newTotal = newFeed.assignments.length + newFeed.announcements.length + newFeed.activity.length;
if (silent && _feed && newTotal > _prevTotal) {
// show "new items" banner
const diff = newTotal - _prevTotal;
document.getElementById('new-banner-text').textContent = `${diff} новых записей`;
document.getElementById('new-banner').classList.add('visible');
_feed = newFeed; _prevTotal = newTotal;
return; // don't re-render until user clicks banner
}
_feed = newFeed; _prevTotal = newTotal;
document.getElementById('board-title').textContent = newFeed.class.name + ' — Доска';
if (isTeacher) document.getElementById('compose-wrap').style.display = '';
render();
} catch (e) {
if (!silent) document.getElementById('masonry').innerHTML =
`<div class="empty-state"><p style="color:var(--pink)">${e.message.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')}</p></div>`;
}
}
function dismissBanner() {
document.getElementById('new-banner').classList.remove('visible');
render();
}
/* ════════════════════════════════════════
AUTO REFRESH
════════════════════════════════════════ */
function scheduleRefresh() {
clearInterval(_refreshTimer);
let cd = 30;
const info = document.getElementById('refresh-info');
const tick = setInterval(() => {
cd--;
info.textContent = cd > 0 ? `Обновление через ${cd} с` : 'Обновляется…';
if (cd <= 0) {
clearInterval(tick);
loadFeed(true).then(scheduleRefresh);
}
}, 1000);
_refreshTimer = tick;
}
/* ════════════════════════════════════════
RENDER
════════════════════════════════════════ */
function render() {
if (!_feed) return;
clearDeadlineTimers();
const q = document.getElementById('search-input').value.trim().toLowerCase();
const items = [];
const now = Date.now();
const DAY = 86400000;
const isNew = ts => ts && (now - new Date(ts).getTime()) < DAY;
if (_filter === 'all' || _filter === 'assignment') {
_feed.assignments.forEach(a => {
if (q && !a.title.toLowerCase().includes(q)) return;
items.push({ type: 'assignment', data: a, ts: a.created_at });
});
}
if (_filter === 'all' || _filter === 'announcement') {
_feed.announcements.forEach(a => {
if (q && !a.text.toLowerCase().includes(q) && !a.author_name.toLowerCase().includes(q)) return;
items.push({ type: 'announcement', data: a, ts: a.created_at });
});
}
if (_filter === 'all' || _filter === 'activity') {
_feed.activity.forEach(a => {
if (q && !a.student_name.toLowerCase().includes(q) && !a.assignment_title.toLowerCase().includes(q)) return;
items.push({ type: 'activity', data: a, ts: a.completed_at });
});
}
items.sort((a, b) => new Date(b.ts || 0) - new Date(a.ts || 0));
// update counts
document.getElementById('cnt-all').textContent = (_feed.assignments.length + _feed.announcements.length + _feed.activity.length);
document.getElementById('cnt-assignment').textContent = _feed.assignments.length;
document.getElementById('cnt-announcement').textContent = _feed.announcements.length;
document.getElementById('cnt-activity').textContent = _feed.activity.length;
const m = document.getElementById('masonry');
if (!items.length) {
m.innerHTML = `<div class="empty-state"><div class="empty-icon"><i data-lucide="${q ? 'search' : 'inbox'}" style="width:48px;height:48px;stroke:var(--text-3);stroke-width:1.2"></i></div><p>${q ? 'Ничего не найдено' : 'В этом классе пока нет активности'}</p></div>`;
return;
}
m.innerHTML = items.map((item, idx) => {
const delay = `animation-delay:${Math.min(idx * 50, 500)}ms`;
const fresh = isNew(item.ts);
if (item.type === 'assignment') return renderAssignment(item.data, delay, fresh);
if (item.type === 'announcement') return renderAnnouncement(item.data, delay, fresh);
if (item.type === 'activity') return renderActivity(item.data, delay, fresh);
}).join('');
if (window.lucide) lucide.createIcons();
// start countdown timers for deadline cards
startDeadlineTimers();
}
/* ════════════════════════════════════════
ASSIGNMENT CARD
════════════════════════════════════════ */
function renderAssignment(a, delay, fresh) {
const MODES = { exam: 'Контроль', repeat: 'Практика', ct: 'ЦТ-формат', homework: 'ДЗ' };
const pct = a.total_members > 0 ? Math.round(a.done_count / a.total_members * 100) : 0;
const deadline = a.deadline ? new Date(a.deadline) : null;
const isLate = deadline && deadline < new Date();
const isUrgent = deadline && !isLate && (deadline - Date.now()) < 86400000;
const newBadge = fresh ? '<span class="badge-new">NEW</span>' : '';
let statusTag = '';
if (!isTeacher) {
const maxAtt = a.max_attempts || 0;
const usedAtt = a.attempts_used ?? 0;
const attExhausted = maxAtt > 0 && usedAtt >= maxAtt;
if (attExhausted) statusTag = `<span class="tag late">Попытки исчерпаны</span>`;
else if (a.my_status === 'completed') statusTag = `<span class="tag done"><i data-lucide="check" style="width:11px;height:11px;vertical-align:-1px"></i> Сдано</span>`;
else if (isLate) statusTag = `<span class="tag late">Просрочено</span>`;
else if (isUrgent) statusTag = `<span class="tag urgent"><i data-lucide="zap" style="width:11px;height:11px;vertical-align:-1px"></i> Скоро дедлайн</span>`;
else statusTag = `<span class="tag pending">Не сдано</span>`;
}
// SVG ring for teacher
let progressHtml = '';
if (isTeacher) {
const stroke = 15.9; const r = 15.9; const circ = 2 * Math.PI * r;
const dash = (pct / 100) * circ;
const ringColor = pct >= 80 ? '#06D664' : pct >= 50 ? '#FF9F1C' : '#F15BB5';
progressHtml = `
<div class="ring-wrap">
<svg width="44" height="44" viewBox="0 0 36 36">
<defs><linearGradient id="rg${a.id}" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#06D6E0"/><stop offset="100%" stop-color="#9B5DE5"/>
</linearGradient></defs>
<circle cx="18" cy="18" r="${stroke}" fill="none" stroke="rgba(15,23,42,0.07)" stroke-width="3.5"/>
<circle cx="18" cy="18" r="${stroke}" fill="none" stroke="url(#rg${a.id})" stroke-width="3.5"
stroke-dasharray="${dash.toFixed(1)} ${(circ - dash).toFixed(1)}"
stroke-dashoffset="${(circ * 0.25).toFixed(1)}" stroke-linecap="round"/>
<text x="18" y="20.5" text-anchor="middle" font-size="7.5" font-weight="800" fill="#3D4F6B">${pct}%</text>
</svg>
<div class="ring-label">
<strong>${a.done_count}</strong> из <strong>${a.total_members}</strong> сдали
</div>
</div>`;
}
// deadline countdown (updated by JS timer)
let deadlineHtml = '';
if (deadline) {
const cls = isLate ? 'late' : isUrgent ? 'urgent' : 'ok';
deadlineHtml = `<div class="deadline-row">
<span class="icon"><i data-lucide="calendar" style="width:13px;height:13px;vertical-align:-2px"></i></span>
<span>Дедлайн: ${fmtDate(deadline)}</span>
${!isLate ? `<span class="countdown ${isUrgent ? 'urgent' : 'warn'}" data-deadline="${deadline.toISOString()}" id="cd-${a.id}"></span>` : `<span class="countdown urgent">— просрочено</span>`}
</div>`;
}
// action
const actionHtml = !isTeacher && a.my_status !== 'completed'
? `<a href="/dashboard" class="btn-action">Перейти <svg class="ic" viewBox="0 0 24 24"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg></a>` : '';
return `<div class="card card-assignment" style="${delay}">
<div class="card-strip"></div>
<div class="card-body">
<div class="card-head">
<div class="card-icon"><i data-lucide="${a.is_homework ? 'book-open' : 'clipboard-list'}" style="width:22px;height:22px;stroke:currentColor;stroke-width:1.8"></i></div>
<div class="card-head-info">
<div class="card-type-label">${a.is_homework ? 'Домашнее задание' : 'Задание'}</div>
<div class="card-title">${esc(a.title)}${newBadge}</div>
</div>
</div>
${a.file_title ? `<div style="font-size:0.73rem;color:var(--text-3);margin-bottom:4px"><i data-lucide="paperclip" style="width:11px;height:11px;vertical-align:-1px"></i> ${esc(a.file_title)}</div>` : ''}
<div class="tags">
<span class="tag class"><i data-lucide="school" style="width:11px;height:11px;vertical-align:-1px"></i> Для класса</span>
<span class="tag">${MODES[a.mode] || a.mode}</span>
${a.is_homework ? `<span class="tag hw">ДЗ</span>` : ''}
${statusTag}
</div>
${deadlineHtml}
${progressHtml}
${!isTeacher && (a.max_attempts > 0 || a.attempts_used > 0) ? `<div style="font-size:0.73rem;color:var(--text-3);margin-top:4px"><i data-lucide="repeat" style="width:11px;height:11px;vertical-align:-2px"></i> Попытка ${(a.attempts_used||0)+1}${a.max_attempts > 0 ? ` из ${a.max_attempts}` : ''}</div>` : ''}
${actionHtml ? `<div style="margin-top:8px">${actionHtml}</div>` : ''}
</div>
</div>`;
}
/* ════════════════════════════════════════
ANNOUNCEMENT CARD
════════════════════════════════════════ */
const EMOJIS = ['<svg class="ic" viewBox="0 0 24 24"><path d="M7 10v12"/><path d="M15 5.88 14 10h5.83a2 2 0 0 1 1.92 2.56l-2.33 8A2 2 0 0 1 17.5 22H4a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2h2.76a2 2 0 0 0 1.79-1.11L12 2a3.13 3.13 0 0 1 3 3.88Z"/></svg>', '<svg class="ic" viewBox="0 0 24 24"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg>', '<svg class="ic" viewBox="0 0 24 24"><path d="M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.072-2.143-.224-4.054 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.153.433-2.294 1-3a2.5 2.5 0 0 0 2.5 3z"/></svg>'];
function renderAnnouncement(a, delay, fresh) {
const key = `ann_${a.id}`;
const rxState = _reactions[key] || {};
const expanded = _expanded[key];
const newBadge = fresh ? '<span class="badge-new">NEW</span>' : '';
const isClamped = a.text.length > 200 && !expanded;
const rxHtml = EMOJIS.map((em, idx) => {
const count = rxState[idx] ? 1 : 0;
return `<button class="reaction-btn ${rxState[idx] ? 'active' : ''}" onclick="toggleReaction(${a.id},${idx},this)">${em}${count ? ` ${count}` : ''}</button>`;
}).join('');
const delBtn = isTeacher
? `<button class="btn-del-ann" onclick="deleteAnnouncement(${a.id})" title="Удалить"><i data-lucide="x" style="width:13px;height:13px"></i></button>` : '';
return `<div class="card card-announcement" style="${delay}">
<div class="card-strip"></div>
<div class="card-body">
<div class="card-head">
<div class="card-icon"><i data-lucide="megaphone" style="width:22px;height:22px;stroke:currentColor;stroke-width:1.8"></i></div>
<div class="card-head-info">
<div class="card-type-label">Объявление</div>
<div class="card-title" style="font-size:0.87rem;font-weight:600;color:var(--text-2)">${newBadge}</div>
</div>
<div class="card-head-actions">${delBtn}</div>
</div>
<div class="announce-text ${isClamped ? 'clamped' : ''}">${esc(a.text)}</div>
${a.text.length > 200 ? `<button class="expand-btn" onclick="toggleExpand(${a.id},this)">${isClamped ? '<i data-lucide="chevron-down" style="width:12px;height:12px;vertical-align:-2px"></i> читать дальше' : '<i data-lucide="chevron-up" style="width:12px;height:12px;vertical-align:-2px"></i> свернуть'}</button>` : ''}
<div class="reactions">${rxHtml}</div>
<div class="card-footer">
<span class="card-author"><i data-lucide="pencil-line" style="width:11px;height:11px;vertical-align:-1px"></i> ${esc(a.author_name)}</span>
<span class="card-time">${fmtRelative(a.created_at)}</span>
</div>
</div>
</div>`;
}
/* ════════════════════════════════════════
ACTIVITY CARD
════════════════════════════════════════ */
function renderActivity(a, delay, fresh) {
const pct = a.total > 0 ? Math.round(a.score / a.total * 100) : 0;
const cls = pct >= 80 ? 'good' : pct >= 50 ? 'ok' : 'bad';
const icon = pct >= 80 ? 'trophy' : pct >= 50 ? 'file-text' : 'trending-down';
const newBadge = fresh ? '<span class="badge-new">NEW</span>' : '';
return `<div class="card card-activity" style="${delay}">
<div class="card-strip"></div>
<div class="card-body">
<div class="card-head">
<div class="card-icon"><i data-lucide="${icon}" style="width:22px;height:22px;stroke:currentColor;stroke-width:1.8"></i></div>
<div class="card-head-info">
<div class="card-type-label">Активность</div>
<div class="card-title">${esc(a.student_name)}${newBadge}</div>
</div>
</div>
<div style="font-size:0.79rem;color:var(--text-2);margin-bottom:6px">
завершил(а) «${esc(a.assignment_title)}»
</div>
<span class="score-chip ${cls}">${a.score} / ${a.total}${pct}%</span>
<div class="card-footer">
<span></span>
<span class="card-time">${fmtRelative(a.completed_at)}</span>
</div>
</div>
</div>`;
}
/* ════════════════════════════════════════
DEADLINE COUNTDOWN TIMERS
════════════════════════════════════════ */
function startDeadlineTimers() {
document.querySelectorAll('[data-deadline]').forEach(el => {
function update() {
const ms = new Date(el.dataset.deadline) - Date.now();
if (ms <= 0) { el.textContent = ''; return; }
const h = Math.floor(ms / 3600000);
const m = Math.floor((ms % 3600000) / 60000);
const s = Math.floor((ms % 60000) / 1000);
el.textContent = h > 0 ? `(${h} ч ${m} мин)` : `(${m}:${String(s).padStart(2,'0')})`;
}
update();
const t = setInterval(update, 1000);
_deadlineTimers.push(t);
});
}
function clearDeadlineTimers() {
_deadlineTimers.forEach(clearInterval);
_deadlineTimers = [];
}
/* ════════════════════════════════════════
INTERACTIONS
════════════════════════════════════════ */
function toggleExpand(id, btn) {
const key = `ann_${id}`;
_expanded[key] = !_expanded[key];
const expanded = _expanded[key];
const card = btn.closest('.card');
const textEl = card?.querySelector('.announce-text');
if (textEl) textEl.classList.toggle('clamped', !expanded);
btn.innerHTML = expanded ? '<svg class="ic" viewBox="0 0 24 24"><polyline points="22 15 12 3 2 15"/></svg> свернуть' : '<svg class="ic" viewBox="0 0 24 24"><polyline points="2 9 12 21 22 9"/></svg> читать дальше';
}
function toggleReaction(id, idx, btn) {
const key = `ann_${id}`;
if (!_reactions[key]) _reactions[key] = {};
_reactions[key][idx] = !_reactions[key][idx];
saveReactions();
const active = _reactions[key][idx];
btn.classList.toggle('active', active);
btn.innerHTML = EMOJIS[idx] + (active ? ' 1' : '');
}
async function deleteAnnouncement(id) {
if (!await LS.confirm('Это действие нельзя отменить.', { title: 'Удалить объявление?', confirmText: 'Удалить', danger: true })) return;
try {
await LS.deleteAnnouncement(_currentClassId, id);
await loadFeed(true);
render();
LS.toast('Объявление удалено', 'info');
} catch (e) {
LS.toast(e.message, 'error');
}
}
/* ════════════════════════════════════════
COMPOSE
════════════════════════════════════════ */
function toggleCompose() {
const box = document.getElementById('compose-box');
box.classList.toggle('open');
if (box.classList.contains('open')) document.getElementById('compose-text').focus();
}
function onComposeInput() {
const len = document.getElementById('compose-text').value.length;
const el = document.getElementById('compose-chars');
el.textContent = `${len} / 1000`;
el.className = `compose-chars${len > 900 ? ' warn' : ''}`;
}
async function postAnnouncement() {
const text = document.getElementById('compose-text').value.trim();
if (!text) return;
const btn = document.getElementById('btn-post');
btn.disabled = true; btn.textContent = 'Публикую…';
try {
await LS.createAnnouncement(_currentClassId, text);
document.getElementById('compose-text').value = '';
document.getElementById('compose-chars').textContent = '0 / 1000';
document.getElementById('compose-box').classList.remove('open');
await loadFeed(true);
render();
scheduleRefresh();
LS.toast('Объявление опубликовано', 'success');
} catch (e) {
LS.toast(e.message, 'error');
} finally {
btn.disabled = false; btn.textContent = 'Опубликовать';
}
}
// Ctrl+Enter to post
document.getElementById('compose-text').addEventListener('keydown', e => {
if (e.ctrlKey && e.key === 'Enter') postAnnouncement();
if (e.key === 'Escape') toggleCompose();
});
/* ════════════════════════════════════════
HELPERS
════════════════════════════════════════ */
function fmtDate(d) {
if (!(d instanceof Date)) d = new Date(d);
return d.toLocaleDateString('ru-RU', { day: 'numeric', month: 'short', year: d.getFullYear() !== new Date().getFullYear() ? 'numeric' : undefined });
}
function fmtRelative(s) {
if (!s) return '';
const d = new Date(s);
const diff = Date.now() - d.getTime();
const min = Math.floor(diff / 60000);
if (min < 1) return 'только что';
if (min < 60) return `${min} мин назад`;
const h = Math.floor(min / 60);
if (h < 24) return `${h} ч назад`;
const days = Math.floor(h / 24);
if (days < 7) return `${days} дн назад`;
return fmtDate(d);
}
/* ── START ── */
init();
LS.notif.init();
if (window.lucide) lucide.createIcons();
</script>
</div>
</div>
<script src="/js/search.js"></script>
<script src="/js/mobile.js"></script>
</body>
</html>