const API = ""; const WK = { numerator: "Числ.", denominator: "Знам.", both: "" }; const DAYS = ["Пн", "Вт", "Ср", "Чт", "Пт", "Сб"]; let token = localStorage.getItem("shokey_token"); let user = JSON.parse(localStorage.getItem("shokey_user") || "null"); let weekMode = "auto"; let currentWeek = "numerator"; let pairTimes = {}; let scheduleCache = null; let deferredPwaPrompt = null; const PAIRS = [1, 2, 3, 4, 5, 6, 7, 8]; const DAY_NAMES = ["Понедельник", "Вторник", "Среда", "Четверг", "Пятница", "Суббота"]; const DAY_SHORT_MOBILE = ["Сегодня", "Завтра", "Послезавтра", "Через 3 дня"]; const NAV = [ { id: "home", label: "Расписание", roles: "all" }, { id: "homework", label: "ДЗ", roles: "all" }, { id: "edit", label: "Изменить", roles: "staff" }, { id: "groups", label: "Группы", roles: "staff" }, { id: "news", label: "Новости", roles: "staff" }, { id: "admin", label: "Аккаунты", roles: "admin" }, { id: "settings", label: "Настройки", roles: "all" }, ]; function $(id) { return document.getElementById(id); } function apiError(data, fallback) { const d = data?.detail; if (typeof d === "string") return d; if (Array.isArray(d)) return d.map((x) => x.msg || x).join("; "); return fallback || "Ошибка"; } async function api(path, opts = {}) { const h = { ...(opts.headers || {}) }; if (!(opts.body instanceof FormData)) h["Content-Type"] = "application/json"; if (token) h.Authorization = "Bearer " + token; const res = await fetch(API + path, { ...opts, headers: h }); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(apiError(data, res.statusText)); return data; } function setAuth(t, u) { token = t; user = u; localStorage.setItem("shokey_token", t); localStorage.setItem("shokey_user", JSON.stringify(u)); } function canStaff() { return user && (user.role === "admin" || (user.role === "leader" && user.leader_approved)); } function showView(name) { ["viewLogin", "viewRegister", "viewVerify", "viewHome", "viewHomework", "viewEdit", "viewGroups", "viewNews", "viewAdmin", "viewSettings"].forEach((id) => $(id).classList.add("hidden")); const m = { login: "viewLogin", register: "viewRegister", verify: "viewVerify", home: "viewHome", homework: "viewHomework", edit: "viewEdit", groups: "viewGroups", news: "viewNews", admin: "viewAdmin", settings: "viewSettings" }; $(m[name]).classList.remove("hidden"); document.querySelectorAll(".nav-item, .bottom-nav button").forEach((b) => b.classList.toggle("active", b.dataset.view === name)); } function buildNav() { const items = NAV.filter((n) => { if (n.roles === "all") return true; if (n.roles === "staff") return canStaff(); if (n.roles === "admin") return user && user.role === "admin"; return false; }); const mk = (el, isSide) => items.map((n) => ``).join(""); $("sideNav").innerHTML = mk(true, true); $("bottomNav").innerHTML = mk(false, false); document.querySelectorAll("#sideNav .nav-item, #bottomNav button").forEach((btn) => { btn.onclick = () => { showView(btn.dataset.view); onNav(btn.dataset.view); }; }); } async function onNav(v) { if (v === "home") await refreshGrid(); if (v === "homework") await refreshHw(); if (v === "edit") { await refreshEdit(); await renderEditGrid(); } if (v === "groups") await refreshGroups(); if (v === "news") await refreshNewsAdmin(); if (v === "admin") await refreshAdmin(); } function scheduleMap(cells) { const map = {}; (cells || []).forEach((c) => { const k = c.day_of_week + "_" + c.pair_number; (map[k] = map[k] || []).push(c); }); return map; } function lessonHtml(c) { const cls = c.is_cancelled ? "cell-lesson cancelled" : "cell-lesson"; let h = "
"; if (c.is_cancelled) { h += "
ОТМЕНА
"; if (c.cancel_reason) h += "
" + c.cancel_reason + "
"; } else { h += "
" + (c.subject || "—") + "
"; h += "
" + (c.teacher || "") + " " + (c.room || "") + "
"; } if (c.week_type && c.week_type !== "both") h += "
" + (WK[c.week_type] || c.week_type) + "
"; return h + "
"; } function jsDayToSchedule(jsDay) { return jsDay === 0 ? 6 : jsDay - 1; } function renderSchedule(data) { scheduleCache = data; const desktop = window.matchMedia("(min-width: 900px)").matches; $("gridWrap").classList.toggle("hidden", !desktop); $("mobileWrap").classList.toggle("hidden", desktop); if (desktop) renderGrid(data); else renderMobileSchedule(data); } function renderMobileSchedule(data) { $("weekLabel").textContent = data.current_week; pairTimes = data.pair_times || {}; const map = scheduleMap(data.cells); const startDay = jsDayToSchedule(new Date().getDay()); let html = '
'; for (let off = 0; off < 4; off++) { const dayIdx = (startDay + off) % 6; const label = off < 3 ? DAY_SHORT_MOBILE[off] : DAY_NAMES[dayIdx]; html += '

' + label + " · " + DAY_NAMES[dayIdx] + "

"; let has = false; PAIRS.forEach((p) => { const lessons = map[dayIdx + "_" + p] || []; if (!lessons.length) return; has = true; const tl = pairTimes[p] || pairTimes[String(p)] || ""; html += '
' + p + " пара · " + tl + "
"; lessons.forEach((c) => { html += lessonHtml(c); }); html += "
"; }); if (!has) html += '

Нет занятий

'; html += "
"; } html += "
"; $("mobileWrap").innerHTML = html; } function renderGrid(data) { $("weekLabel").textContent = data.current_week; pairTimes = data.pair_times || {}; const pairs = PAIRS; const map = {}; (data.cells || []).forEach((c) => { const k = c.day_of_week + "_" + c.pair_number; (map[k] = map[k] || []).push(c); }); let html = ""; DAYS.forEach((d) => { html += ""; }); html += ""; pairs.forEach((p) => { const tl = pairTimes[p] || data.pair_times?.[p] || ""; html += ""; for (let d = 0; d < 6; d++) { html += ""; } html += ""; }); html += "
Время" + d + "
" + p + "
" + tl + "
"; (map[d + "_" + p] || []).forEach((c) => { const cls = c.is_cancelled ? "cell-lesson cancelled" : "cell-lesson"; html += "
"; if (c.is_cancelled) { html += "
ОТМЕНА
"; if (c.cancel_reason) html += "
" + c.cancel_reason + "
"; } else { html += "
" + (c.subject || "—") + "
"; html += "
" + (c.teacher || "") + " " + (c.room || "") + "
"; } if (c.week_type && c.week_type !== "both") html += "
" + (WK[c.week_type] || c.week_type) + "
"; html += "
"; }); html += "
"; $('gridWrap').innerHTML = html; } async function loadGroupsSelect(sel, selected) { const groups = await api("/api/groups"); const names = groups.map((g) => g.name); sel.innerHTML = names.map((g) => "").join(""); if (selected && names.includes(selected)) sel.value = selected; else if (user?.group_name && names.includes(user.group_name)) sel.value = user.group_name; return names; } async function refreshGrid() { const g = $("groupSelect").value; if (!g) return; const w = weekMode === "auto" ? currentWeek : weekMode; renderSchedule(await api("/api/schedule/grid?group_name=" + encodeURIComponent(g) + "&week=" + w)); } async function refreshHw() { const g = $("groupSelect")?.value || user?.group_name; const list = await api("/api/homework?group_name=" + encodeURIComponent(g)); $("hwList").innerHTML = list.length ? list.map((h) => "
" + h.subject + ": " + h.title + "
" + (h.due_date || "") + " · " + (h.author_name || "") + "
" ).join("") : "

Нет заданий

"; } async function refreshEdit() { const g = $("editGroup").value; const items = await api("/api/schedule?group_name=" + encodeURIComponent(g) + "&week=both"); $("editList").innerHTML = "
" + (items.map((l) => "
" + DAYS[l.day_of_week] + " п" + l.pair_number + " " + (l.time_label || "") + " — " + (l.is_cancelled ? "ОТМЕНА: " + (l.cancel_reason || "") : l.subject) + "
" ).join("") || "

Пусто

") + "
"; document.querySelectorAll("[data-del]").forEach((b) => { b.onclick = async () => { await api("/api/schedule/" + b.dataset.del, { method: "DELETE" }); refreshEdit(); refreshGrid(); }; }); } async function refreshGroups() { const groups = await api("/api/groups"); $("groupsList").innerHTML = groups.map((g) => "
" + g.name + " " + (user.role === "admin" ? "" : "") + "
" ).join(""); document.querySelectorAll("[data-rmg]").forEach((b) => { b.onclick = async () => { await api("/api/groups/" + b.dataset.rmg, { method: "DELETE" }); refreshGroups(); loadGroupsSelect($("groupSelect")); }; }); } let uploadedNewsUrl = null; $("newsFile")?.addEventListener("change", async (e) => { const f = e.target.files[0]; if (!f) return; const fd = new FormData(); fd.append("file", f); const res = await fetch(API + "/api/news/upload", { method: "POST", headers: { Authorization: "Bearer " + token }, body: fd }); const d = await res.json(); uploadedNewsUrl = d.url; alert("Фото загружено"); }); async function refreshNewsAdmin() { const items = await api("/api/news"); $("newsAdminList").innerHTML = items.map((n) => "
" + n.title + "
" + (n.body || "") + "
" ).join("").split("").join("").join(""); document.querySelectorAll("[data-deln]").forEach((b) => { b.onclick = async () => { await api("/api/news/" + b.dataset.deln, { method: "DELETE" }); refreshNewsAdmin(); }; }); } async function refreshAdmin() { const users = await api("/api/admin/users"); $("adminUsers").innerHTML = users.map((u) => { let actions = ""; if (u.leader_pending) actions += " "; if (!u.email_verified) actions += " "; const sys = u.role === "admin" || u.email === "shokey@shokey.ru" || u.email === "admin@miet.ru"; if (!sys) { actions += " "; actions += " "; if (u.is_active === false) actions += " "; else actions += " "; actions += ""; } return "
" + u.full_name + " · " + u.email + (u.is_active === false ? " (отключён)" : "") + "
Роль: " + u.role + (u.leader_pending ? " (ожидает)" : "") + (u.email_verified ? "" : " · email не подтверждён") + "
" + actions + "
"; }).join(""); } function applyVerifyHint(data) { let text = data.message || "Введите код из письма. Поддержка: support@shokey.ru"; if (data.dev_code) { text = (data.message || "Код подтверждения:") + " " + data.dev_code; $("verifyCode").value = data.dev_code; } $("verifyHint").textContent = text; } async function afterLogin(data) { const me = await api("/api/auth/me"); user = me; localStorage.setItem("shokey_user", JSON.stringify(me)); if (!data.email_verified) { applyVerifyHint(data); showView("verify"); $("sidebar").style.display = "none"; $("bottomNav").classList.add("hidden"); return; } $("sidebar").style.display = ""; $("bottomNav").classList.remove("hidden"); buildNav(); const w = await api("/api/week"); currentWeek = w.current; await loadGroupsSelect($("groupSelect"), user.group_name); await loadGroupsSelect($("regGroup")); if (canStaff()) await loadGroupsSelect($("editGroup"), user.group_name); if (user.role === "admin") $("logoAdminBlock")?.classList.remove("hidden"); showView("home"); await refreshGrid(); } $("btnLogin").onclick = async () => { try { const body = new URLSearchParams(); body.set("username", $("email").value.trim()); body.set("password", $("password").value); const res = await fetch(API + "/api/auth/login", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body }); const data = await res.json(); if (!res.ok) throw new Error(data.detail); setAuth(data.access_token, data); await afterLogin(data); } catch (e) { $("loginError").textContent = e.message; $("loginError").classList.remove("hidden"); } }; $("btnRegister").onclick = async () => { try { const data = await api("/api/auth/register", { method: "POST", body: JSON.stringify({ email: $("regEmail").value.trim(), password: $("regPassword").value, full_name: $("regName").value.trim(), role: $("regRole").value, group_name: $("regGroup").value, })}); setAuth(data.access_token, data); await afterLogin(data); } catch (e) { $("regError").textContent = e.message; $("regError").classList.remove("hidden"); } }; $("btnVerify").onclick = async () => { const data = await api("/api/auth/verify-email", { method: "POST", body: JSON.stringify({ code: $("verifyCode").value }) }); setAuth(data.access_token, data); await afterLogin(data); }; $("btnResendCode")?.addEventListener("click", async () => { try { const r = await api("/api/auth/resend-verification", { method: "POST" }); applyVerifyHint(r); } catch (e) { $("verifyHint").textContent = e.message; } }); $("btnSendSupport")?.addEventListener("click", async () => { try { const r = await api("/api/support", { method: "POST", body: JSON.stringify({ subject: $("supportSubject").value, message: $("supportMessage").value }), }); alert(r.message || "Отправлено"); $("supportMessage").value = ""; } catch (e) { alert(e.message); } }); document.querySelectorAll(".week-btn").forEach((btn) => { btn.onclick = () => { document.querySelectorAll(".week-btn").forEach((b) => b.classList.remove("active")); btn.classList.add("active"); weekMode = btn.dataset.week; refreshGrid(); }; }); $("groupSelect").onchange = refreshGrid; $("btnAddHw").onclick = async () => { await api("/api/homework", { method: "POST", body: JSON.stringify({ group_name: $("groupSelect").value, subject: $("hwSubject").value, title: $("hwTitle").value, description: $("hwDesc").value, due_date: $("hwDue").value || null, })}); refreshHw(); }; $("btnAddLesson").onclick = async () => { await api("/api/schedule", { method: "POST", body: JSON.stringify({ group_name: $("editGroup").value, day_of_week: +$("editDay").value, pair_number: +$("editPair").value, week_type: $("editWeek").value, subject: $("editSubject").value || null, teacher: $("editTeacher").value || null, room: $("editRoom").value || null, is_cancelled: $("editCancelled").checked, cancel_reason: $("editCancelReason").value || null, })}); refreshEdit(); refreshGrid(); }; $("btnAddGroup").onclick = async () => { await api("/api/groups", { method: "POST", body: JSON.stringify({ name: $("newGroupName").value }) }); $("newGroupName").value = ""; refreshGroups(); loadGroupsSelect($("groupSelect")); }; $("btnSaveNews").onclick = async () => { await api("/api/news", { method: "POST", body: JSON.stringify({ title: $("newsTitle").value, body: $("newsBody").value, image_url: uploadedNewsUrl, })}); $("newsTitle").value = ""; $("newsBody").value = ""; uploadedNewsUrl = null; refreshNewsAdmin(); }; $("btnShowRegister").onclick = () => { showView("register"); loadGroupsSelect($("regGroup")); }; $("btnShowLogin").onclick = () => showView("login"); const logout = () => { localStorage.clear(); location.reload(); }; $("sideLogout").onclick = logout; $("btnSaveServer").onclick = () => localStorage.setItem("shokey_server_hint", $("serverUrl").value); $("serverUrl").value = localStorage.getItem("shokey_server_hint") || location.origin; if (token) { api("/api/auth/me").then(async (me) => { user = me; if (!me.email_verified) { try { const r = await api("/api/auth/resend-verification", { method: "POST" }); await afterLogin({ email_verified: false, message: r.message, dev_code: r.dev_code }); } catch (e) { await afterLogin({ email_verified: false, message: e.message }); } } else { await afterLogin({ email_verified: true }); } }).catch(logout); } async function renderEditGrid() { const g = $("editGroup")?.value; if (!g || !canStaff()) return; const data = await api("/api/schedule/grid?group_name=" + encodeURIComponent(g) + "&week=both"); const map = scheduleMap(data.cells); let html = ""; DAYS.forEach((d) => { html += ""; }); html += ""; PAIRS.forEach((p) => { const tl = (data.pair_times && (data.pair_times[p] || data.pair_times[String(p)])) || ""; html += ""; for (let d = 0; d < 6; d++) { const lessons = map[d + "_" + p] || []; const preview = lessons.length ? lessons.map((c) => (c.is_cancelled ? "ОТМЕНА" : (c.subject || "—"))).join(", ") : "+"; html += ""; } html += ""; }); html += "
" + d + "
" + p + "
" + tl + "
" + preview + "
"; $("editGridWrap").innerHTML = html; } function openEditCell(day, pair) { const g = $("editGroup").value; const subj = prompt("Предмет (пусто = отмена пары):"); if (subj === null) return; const teacher = prompt("Преподаватель:", "") || ""; const room = prompt("Аудитория:", "") || ""; const week = $("editWeek")?.value || "both"; const cancelled = !subj.trim(); const reason = cancelled ? (prompt("Причина отмены:", "") || "") : ""; api("/api/schedule", { method: "POST", body: JSON.stringify({ group_name: g, day_of_week: day, pair_number: pair, week_type: week, subject: cancelled ? null : subj.trim(), teacher, room, is_cancelled: cancelled, cancel_reason: reason || null, }), }).then(() => { refreshEdit(); renderEditGrid(); refreshGrid(); }).catch((e) => alert(e.message)); } async function loadAppSettings() { try { const s = await api("/api/settings"); if (s.logo_url && $("appLogo")) { $("appLogo").src = s.logo_url; $("appLogo").classList.remove("hidden"); } if (s.app_name && $("brandTitle")) $("brandTitle").textContent = s.app_name; } catch (_) {} } function initPwa() { if ("serviceWorker" in navigator) navigator.serviceWorker.register("/static/sw.js").catch(() => {}); window.addEventListener("beforeinstallprompt", (e) => { e.preventDefault(); deferredPwaPrompt = e; $("pwaInstall")?.classList.remove("hidden"); }); $("btnPwaInstall")?.addEventListener("click", async () => { if (!deferredPwaPrompt) return; deferredPwaPrompt.prompt(); await deferredPwaPrompt.userChoice; deferredPwaPrompt = null; $("pwaInstall")?.classList.add("hidden"); }); $("btnPwaDismiss")?.addEventListener("click", () => $("pwaInstall")?.classList.add("hidden")); } if ($("adminUsers") && !$("adminUsers").dataset.bound) { $("adminUsers").dataset.bound = "1"; $("adminUsers").addEventListener("click", async (e) => { const b = e.target.closest("button"); if (!b) return; try { if (b.dataset.appr) await api("/api/admin/users/" + b.dataset.appr + "/approve-leader", { method: "POST" }); else if (b.dataset.ver) await api("/api/admin/users/" + b.dataset.ver + "/verify-email", { method: "POST" }); else if (b.dataset.off) { if (!confirm("Отключить?")) return; await api("/api/admin/users/" + b.dataset.off + "/deactivate", { method: "POST" }); } else if (b.dataset.act) await api("/api/admin/users/" + b.dataset.act + "/activate", { method: "POST" }); else if (b.dataset.delu) { if (!confirm("Удалить навсегда?")) return; await api("/api/admin/users/" + b.dataset.delu, { method: "DELETE" }); } else if (b.dataset.setpw) { const inp = document.querySelector("[data-pw='" + b.dataset.setpw + "']"); if (!inp?.value || inp.value.length < 6) { alert("Минимум 6 символов"); return; } await api("/api/admin/users/" + b.dataset.setpw + "/password", { method: "POST", body: JSON.stringify({ password: inp.value }) }); inp.value = ""; alert("Пароль изменён"); return; } else return; refreshAdmin(); } catch (err) { alert(err.message); } }); } $("editGridWrap")?.addEventListener("click", (e) => { const td = e.target.closest(".edit-cell"); if (td) openEditCell(+td.dataset.ed, +td.dataset.ep); }); $("editGroup")?.addEventListener("change", () => { refreshEdit(); renderEditGrid(); }); $("btnUploadLogo")?.addEventListener("click", async () => { const f = $("logoFile")?.files?.[0]; if (!f) return alert("Выберите файл"); const fd = new FormData(); fd.append("file", f); const res = await fetch(API + "/api/admin/settings/logo", { method: "POST", headers: { Authorization: "Bearer " + token }, body: fd }); const d = await res.json(); if (!res.ok) return alert(apiError(d, "Ошибка")); loadAppSettings(); alert("Логотип сохранён"); }); window.addEventListener("resize", () => { if (scheduleCache) renderSchedule(scheduleCache); }); initPwa(); loadAppSettings();