26ba289019
- 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>
919 lines
49 KiB
HTML
919 lines
49 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>
|
||
/* 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,'&').replace(/</g,'<').replace(/>/g,'>')}</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>
|