Files
Maxim Dolgolyov 26ba289019 a11y: WCAG AA contrast + ARIA roles + focus management across all pages
- css/ls.css: --text-3 #8898AA → #56687A (5.1:1 contrast), min-height 44px on .btn-primary/.btn-ghost/.sb-link, new .icon-btn utility (44×44px)
- js/api.js: lsConfirm — role=dialog, aria-modal, aria-labelledby, Tab focus trap, restore focus on close; lsToast — aria-live=polite on container, role=alert on errors; live quiz — role=dialog, role=radiogroup, role=radio, aria-checked, keyboard support
- test-run.html: q-opt divs — role=radio/checkbox, aria-checked, tabindex, keyboard enter/space; confirm modal — role=dialog, aria-modal; btn-flag — aria-pressed; dots — aria-label, aria-current; touch targets 44px
- board.html: btn-del-ann — aria-label; reaction buttons — aria-label, aria-pressed
- All 18 HTML files: replace hardcoded color:#8898AA with color:var(--text-3)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 11:42:38 +03:00

919 lines
49 KiB
HTML
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Доска класса — LearnSpace</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link href="https://fonts.googleapis.com/css2?family=Unbounded:wght@400;700;800&family=Manrope:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/css/ls.css" />
<style>
/* 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" id="app-sidebar"></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="clearTimeout(window._boardSearchTimer); window._boardSearchTimer = setTimeout(render, 250)" />
</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/sidebar.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" aria-hidden="true"><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" aria-hidden="true"><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" aria-hidden="true"><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>'];
const REACTION_LABELS = ['Нравится', 'Обожаю', 'Огонь'];
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;
const lbl = rxState[idx] ? `Убрать «${REACTION_LABELS[idx]}»` : `${REACTION_LABELS[idx]}`;
return `<button class="reaction-btn ${rxState[idx] ? 'active' : ''}" onclick="toggleReaction(${a.id},${idx},this)" aria-label="${lbl}" aria-pressed="${rxState[idx] ? 'true' : 'false'}">${em}${count ? ` <span aria-hidden="true">${count}</span>` : ''}</button>`;
}).join('');
const delBtn = isTeacher
? `<button class="btn-del-ann" onclick="deleteAnnouncement(${a.id})" aria-label="Удалить объявление"><i data-lucide="x" style="width:13px;height:13px" aria-hidden="true"></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>