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 += "" + d + " | "; });
html += "
";
pairs.forEach((p) => {
const tl = pairTimes[p] || data.pair_times?.[p] || "";
html += "" + p + " " + tl + " | ";
for (let d = 0; d < 6; d++) {
html += "";
(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 += " | ";
}
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 += "" + d + " | "; });
html += "
";
PAIRS.forEach((p) => {
const tl = (data.pair_times && (data.pair_times[p] || data.pair_times[String(p)])) || "";
html += "" + p + " " + tl + " | ";
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 += "" + preview + " | ";
}
html += "
";
});
html += "
";
$("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();