Базовая функциональность
This commit is contained in:
93
src/frontend/main.py
Normal file
93
src/frontend/main.py
Normal file
@@ -0,0 +1,93 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
import uvicorn
|
||||
from dotenv import load_dotenv
|
||||
from fastapi import FastAPI, HTTPException, Query
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
load_dotenv(Path(__file__).parent / ".env")
|
||||
|
||||
SCHEDULE_API = os.getenv("SCHEDULE_API", "http://127.0.0.1:8000").rstrip("/")
|
||||
STATIC_DIR = Path(__file__).parent / "static"
|
||||
|
||||
app = FastAPI(title="Расписание — фронт")
|
||||
|
||||
|
||||
def _proxy(path: str, params: dict):
|
||||
try:
|
||||
resp = requests.get(f"{SCHEDULE_API}/{path}", params=params, timeout=30)
|
||||
except requests.RequestException as exc:
|
||||
raise HTTPException(status_code=502, detail=f"Бэкенд недоступен: {exc}")
|
||||
if resp.status_code != 200:
|
||||
try:
|
||||
detail = resp.json().get("detail", resp.text)
|
||||
except Exception:
|
||||
detail = resp.text
|
||||
raise HTTPException(status_code=resp.status_code, detail=detail)
|
||||
return resp.json()
|
||||
|
||||
|
||||
@app.get("/api/groups")
|
||||
def api_groups(force: bool = Query(False)):
|
||||
return _proxy("groups", {"force": force})
|
||||
|
||||
|
||||
@app.get("/api/schedule/{group}")
|
||||
def api_schedule(group: str, force: bool = Query(False)):
|
||||
return _proxy(f"schedule/{group}", {"force": force})
|
||||
|
||||
|
||||
@app.get("/api/teachers")
|
||||
def api_teachers(force: bool = Query(False)):
|
||||
return _proxy("teachers", {"force": force})
|
||||
|
||||
|
||||
@app.get("/api/rooms")
|
||||
def api_rooms(force: bool = Query(False)):
|
||||
return _proxy("rooms", {"force": force})
|
||||
|
||||
|
||||
@app.get("/api/teacher/{name}")
|
||||
def api_teacher(name: str, force: bool = Query(False)):
|
||||
return _proxy(f"teacher/{name}", {"force": force})
|
||||
|
||||
|
||||
@app.get("/api/room/{label}")
|
||||
def api_room(label: str, force: bool = Query(False)):
|
||||
return _proxy(f"room/{label}", {"force": force})
|
||||
|
||||
|
||||
@app.get("/api/rooms-free")
|
||||
def api_rooms_free(day: str, period: int, force: bool = Query(False)):
|
||||
return _proxy("rooms-free", {"day": day, "period": period, "force": force})
|
||||
|
||||
|
||||
@app.get("/api/rooms-day")
|
||||
def api_rooms_day(day: str, force: bool = Query(False)):
|
||||
return _proxy("rooms-day", {"day": day, "force": force})
|
||||
|
||||
|
||||
@app.get("/api/week")
|
||||
def api_week(force: bool = Query(False)):
|
||||
return _proxy("week", {"force": force})
|
||||
|
||||
|
||||
@app.get("/api/status")
|
||||
def api_status(force: bool = Query(False)):
|
||||
return _proxy("status", {"force": force})
|
||||
|
||||
|
||||
@app.get("/")
|
||||
def index():
|
||||
return FileResponse(STATIC_DIR / "index.html")
|
||||
|
||||
|
||||
app.mount("/", StaticFiles(directory=STATIC_DIR, html=True), name="static")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run(app, host="127.0.0.1", port=8080)
|
||||
|
||||
526
src/frontend/static/app.js
Normal file
526
src/frontend/static/app.js
Normal file
@@ -0,0 +1,526 @@
|
||||
const groupSelect = document.getElementById("group-select");
|
||||
const entityLabel = document.getElementById("entity-label");
|
||||
const themeToggle = document.getElementById("theme-toggle");
|
||||
const scheduleEl = document.getElementById("schedule");
|
||||
const errorEl = document.getElementById("error");
|
||||
const loadingEl = document.getElementById("loading");
|
||||
|
||||
const RUSSIAN_DAYS = ["воскресенье", "понедельник", "вторник", "среда", "четверг", "пятница", "суббота"];
|
||||
const DAY_ORDER = ["понедельник", "вторник", "среда", "четверг", "пятница", "суббота", "воскресенье"];
|
||||
|
||||
const MODES = {
|
||||
groups: { label: "Группа", listUrl: "/api/groups", valueKey: "schedule-group" },
|
||||
teachers: { label: "Преподаватель", listUrl: "/api/teachers", valueKey: "schedule-teacher" },
|
||||
rooms: { label: "Аудитория", listUrl: "/api/rooms", valueKey: "schedule-room" },
|
||||
};
|
||||
|
||||
const PARITY_LABEL = { ч: "Числитель", з: "Знаменатель" };
|
||||
const PARITY_CLASS = { ч: "badge badge-ch", з: "badge badge-zn" };
|
||||
|
||||
let currentParity = null;
|
||||
let mode = "groups";
|
||||
|
||||
function todayName() {
|
||||
return RUSSIAN_DAYS[new Date().getDay()];
|
||||
}
|
||||
|
||||
function enc(value) {
|
||||
return encodeURIComponent(value);
|
||||
}
|
||||
|
||||
async function fetchJSON(url) {
|
||||
const resp = await fetch(url);
|
||||
if (!resp.ok) {
|
||||
let detail = resp.statusText;
|
||||
try {
|
||||
const body = await resp.json();
|
||||
if (body.detail) detail = body.detail;
|
||||
} catch (_) {}
|
||||
throw new Error(detail);
|
||||
}
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
async function loadWeek() {
|
||||
try {
|
||||
const data = await fetchJSON("/api/week");
|
||||
currentParity = data.parity;
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
function formatDT(iso) {
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return "";
|
||||
const pad = (n) => String(n).padStart(2, "0");
|
||||
return `${pad(d.getDate())}.${pad(d.getMonth() + 1)}.${d.getFullYear()} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
async function loadStatus() {
|
||||
try {
|
||||
return await fetchJSON("/api/status");
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- навигация по вкладкам ---------- */
|
||||
|
||||
function setActiveMenu(view) {
|
||||
document.querySelectorAll(".side-menu a").forEach((a) => {
|
||||
a.classList.toggle("active", a.dataset.view === view);
|
||||
});
|
||||
}
|
||||
|
||||
function fillOptions(list) {
|
||||
groupSelect.innerHTML = "";
|
||||
list.forEach((item) => {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = item;
|
||||
opt.textContent = item;
|
||||
groupSelect.appendChild(opt);
|
||||
});
|
||||
}
|
||||
|
||||
async function setMode(next) {
|
||||
mode = next;
|
||||
localStorage.setItem("schedule-mode", next);
|
||||
entityLabel.textContent = MODES[next].label;
|
||||
setActiveMenu(next);
|
||||
if (next === "rooms") {
|
||||
entityLabel.textContent = "День";
|
||||
groupSelect.classList.remove("hidden");
|
||||
fillOptions(FREE_WEEKDAYS);
|
||||
const savedDay = localStorage.getItem("schedule-room-day");
|
||||
if (savedDay && FREE_WEEKDAYS.includes(savedDay)) groupSelect.value = savedDay;
|
||||
await renderRoomsView(groupSelect.value);
|
||||
return;
|
||||
}
|
||||
groupSelect.classList.remove("hidden");
|
||||
const list = await fetchJSON(MODES[next].listUrl);
|
||||
fillOptions(list);
|
||||
if (!list.length) return;
|
||||
const saved = localStorage.getItem(MODES[next].valueKey);
|
||||
if (saved && list.includes(saved)) groupSelect.value = saved;
|
||||
await renderView();
|
||||
}
|
||||
|
||||
async function renderView(force = false) {
|
||||
const value = groupSelect.value;
|
||||
if (!value) return;
|
||||
localStorage.setItem(MODES[mode].valueKey, value);
|
||||
scheduleEl.innerHTML = "";
|
||||
errorEl.classList.add("hidden");
|
||||
loadingEl.classList.remove("hidden");
|
||||
try {
|
||||
if (mode === "groups") {
|
||||
const [data, status] = await Promise.all([
|
||||
fetchJSON(`/api/schedule/${enc(value)}${force ? "?force=1" : ""}`),
|
||||
loadStatus(),
|
||||
]);
|
||||
renderGroupSchedule(data, status);
|
||||
} else {
|
||||
const [data, status] = await Promise.all([
|
||||
fetchJSON(`/api/teacher/${enc(value)}`),
|
||||
loadStatus(),
|
||||
]);
|
||||
renderTeacher(data, status);
|
||||
}
|
||||
} catch (err) {
|
||||
errorEl.textContent = "Ошибка: " + err.message;
|
||||
errorEl.classList.remove("hidden");
|
||||
} finally {
|
||||
loadingEl.classList.add("hidden");
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- общие элементы ---------- */
|
||||
|
||||
function statusChip(label, value) {
|
||||
const chip = document.createElement("div");
|
||||
chip.className = "status-chip";
|
||||
const lbl = document.createElement("span");
|
||||
lbl.className = "status-label";
|
||||
lbl.textContent = label;
|
||||
const val = document.createElement("span");
|
||||
val.className = "status-value";
|
||||
val.textContent = value;
|
||||
chip.appendChild(lbl);
|
||||
chip.appendChild(val);
|
||||
return chip;
|
||||
}
|
||||
|
||||
function statusRowHTML(status) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "schedule-status";
|
||||
if (status) {
|
||||
const modified = formatDT(status.table_modified);
|
||||
const synced = formatDT(status.last_updated);
|
||||
if (modified) row.appendChild(statusChip("Изменение таблицы", modified));
|
||||
if (synced) row.appendChild(statusChip("Синхронизация", synced));
|
||||
}
|
||||
if (currentParity) {
|
||||
const chip = document.createElement("div");
|
||||
chip.className = "status-chip current-week";
|
||||
const lbl = document.createElement("span");
|
||||
lbl.className = "status-label";
|
||||
lbl.textContent = "Сейчас";
|
||||
const badge = document.createElement("span");
|
||||
badge.className = "badge active " + (currentParity === "ч" ? "badge-ch" : "badge-zn");
|
||||
badge.textContent = PARITY_LABEL[currentParity];
|
||||
chip.appendChild(lbl);
|
||||
chip.appendChild(badge);
|
||||
row.appendChild(chip);
|
||||
}
|
||||
return row.childNodes.length ? row : null;
|
||||
}
|
||||
|
||||
function makeDayBlock(day) {
|
||||
const block = document.createElement("div");
|
||||
block.className = "day-block" + (day === todayName() ? " today" : "");
|
||||
const head = document.createElement("div");
|
||||
head.className = "day-head";
|
||||
head.innerHTML = `<span class="day-title">${day}</span>${day === todayName() ? '<span class="today-badge">сегодня</span>' : ""}`;
|
||||
block.appendChild(head);
|
||||
return block;
|
||||
}
|
||||
|
||||
function parityColHTML(label, bodyHtml) {
|
||||
return `<div class="parity-col">${bodyHtml}</div>`;
|
||||
}
|
||||
|
||||
function alwaysColHTML(lessonsHtml) {
|
||||
return `<div class="parity-col">${lessonsHtml}</div>`;
|
||||
}
|
||||
|
||||
const OKNO_HTML = '<div class="lesson window">Окно</div>';
|
||||
const FREE_HTML = '<div class="lesson window">Свободна</div>';
|
||||
|
||||
function addPeriodRow(block, num, time, lessonsHTML) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "period";
|
||||
row.innerHTML = `<div><div class="period-num">Пара ${num}</div><div class="period-time">${escapeHTML(time || "")}</div></div>`;
|
||||
const col = document.createElement("div");
|
||||
col.innerHTML = lessonsHTML;
|
||||
row.appendChild(col);
|
||||
block.appendChild(row);
|
||||
}
|
||||
|
||||
function entityLessonHTML(parity, entry) {
|
||||
const active = parity === currentParity ? " active" : "";
|
||||
const details = [];
|
||||
if (entry.room) details.push(entry.room);
|
||||
if (entry.teacher) details.push(entry.teacher);
|
||||
if (entry.groups && entry.groups.length) details.push(entry.groups.join(", "));
|
||||
return `
|
||||
<div class="lesson">
|
||||
<div>
|
||||
<span class="${PARITY_CLASS[parity]}${active}">${PARITY_LABEL[parity]}</span>
|
||||
${entry.marker ? `<span class="badge badge-kv">${escapeHTML(entry.marker)}</span>` : ""}
|
||||
<span class="subject">${escapeHTML(entry.subject)}</span>
|
||||
</div>
|
||||
${details.length ? `<div class="details">${escapeHTML(details.join(" · "))}</div>` : ""}
|
||||
${entry.note ? `<div class="lesson-note">${escapeHTML(entry.note)}</div>` : ""}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function singleLessonHTML(badgeKey, lesson, full) {
|
||||
const config = {
|
||||
always: { label: "Обе недели", cls: "badge badge-always" },
|
||||
ч: { label: "Числитель", cls: "badge badge-ch" },
|
||||
з: { label: "Знаменатель", cls: "badge badge-zn" },
|
||||
};
|
||||
const { label, cls } = config[badgeKey];
|
||||
const active = badgeKey !== "always" && badgeKey === currentParity ? " active" : "";
|
||||
const details = [lesson.teacher, lesson.room].filter(Boolean).join(" · ");
|
||||
return `
|
||||
<div class="lesson${full ? " full" : ""}">
|
||||
<div>
|
||||
<span class="${cls}${active}">${label}</span>
|
||||
${lesson.marker ? `<span class="badge badge-kv">${escapeHTML(lesson.marker)}</span>` : ""}
|
||||
<span class="subject">${escapeHTML(lesson.subject)}</span>
|
||||
</div>
|
||||
${details ? `<div class="details">${escapeHTML(details)}</div>` : ""}
|
||||
${lesson.note ? `<div class="lesson-note">${escapeHTML(lesson.note)}</div>` : ""}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/* ---------- рендер расписания группы ---------- */
|
||||
|
||||
function renderGroupSchedule(data, status) {
|
||||
scheduleEl.appendChild(Object.assign(document.createElement("div"), {
|
||||
className: "schedule-title",
|
||||
textContent: `Расписание группы ${data.group}`,
|
||||
}));
|
||||
if (data.semester_until) {
|
||||
scheduleEl.appendChild(Object.assign(document.createElement("div"), {
|
||||
className: "schedule-meta",
|
||||
textContent: `Семестр до ${data.semester_until}`,
|
||||
}));
|
||||
}
|
||||
const top = statusRowHTML(status);
|
||||
if (top) scheduleEl.appendChild(top);
|
||||
|
||||
const daysContainer = document.createElement("div");
|
||||
scheduleEl.appendChild(daysContainer);
|
||||
let hasAnyLessons = false;
|
||||
|
||||
DAY_ORDER.forEach((day) => {
|
||||
const periods = data.days[day];
|
||||
if (!periods) return;
|
||||
const block = makeDayBlock(day);
|
||||
|
||||
const rows = Object.entries(periods)
|
||||
.sort(([a], [b]) => Number(a) - Number(b))
|
||||
.map(([num, p]) => {
|
||||
const always = p.ч && p.з && p.ч.raw === p.з.raw;
|
||||
return { num, p, always, hasLesson: Boolean(always || p.ч || p.з) };
|
||||
});
|
||||
|
||||
const lastWithLesson = rows.reduce((acc, r, i) => (r.hasLesson ? i : acc), -1);
|
||||
if (lastWithLesson === -1) {
|
||||
block.appendChild(Object.assign(document.createElement("div"), {
|
||||
className: "lesson empty",
|
||||
textContent: "Занятий нет",
|
||||
}));
|
||||
daysContainer.appendChild(block);
|
||||
return;
|
||||
}
|
||||
|
||||
rows.slice(0, lastWithLesson + 1).forEach(({ num, p, always }) => {
|
||||
let inner;
|
||||
if (always) {
|
||||
inner = `<div class="parities">${alwaysColHTML(p.ч.lessons.map((l) => singleLessonHTML("always", l)).join(""))}</div>`;
|
||||
hasAnyLessons = true;
|
||||
} else {
|
||||
const chHtml = p.ч ? p.ч.lessons.map((l) => singleLessonHTML("ч", l)).join("") : null;
|
||||
const znHtml = p.з ? p.з.lessons.map((l) => singleLessonHTML("з", l)).join("") : null;
|
||||
if (!chHtml && !znHtml) {
|
||||
inner = `<div class="parities">${parityColHTML("Числитель", OKNO_HTML)}</div>`;
|
||||
} else {
|
||||
inner = `<div class="parities">${parityColHTML("Числитель", chHtml || OKNO_HTML)}${parityColHTML("Знаменатель", znHtml || OKNO_HTML)}</div>`;
|
||||
hasAnyLessons = true;
|
||||
}
|
||||
}
|
||||
addPeriodRow(block, num, p.time, inner);
|
||||
});
|
||||
|
||||
daysContainer.appendChild(block);
|
||||
});
|
||||
|
||||
if (!hasAnyLessons) {
|
||||
daysContainer.innerHTML = "";
|
||||
daysContainer.appendChild(Object.assign(document.createElement("div"), {
|
||||
className: "empty-day",
|
||||
textContent: "Расписание пустое",
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
const TEACHER_DAYS = DAY_ORDER.slice(0, 6);
|
||||
|
||||
/* ---------- рендер преподавателя ---------- */
|
||||
|
||||
function renderTeacher(data, status) {
|
||||
scheduleEl.appendChild(Object.assign(document.createElement("div"), {
|
||||
className: "schedule-title",
|
||||
textContent: `Преподаватель: ${data.full_name || data.name}`,
|
||||
}));
|
||||
const top = statusRowHTML(status);
|
||||
if (top) scheduleEl.appendChild(top);
|
||||
|
||||
const daysContainer = document.createElement("div");
|
||||
scheduleEl.appendChild(daysContainer);
|
||||
let hasAny = false;
|
||||
|
||||
TEACHER_DAYS.forEach((day) => {
|
||||
const periods = data.days[day];
|
||||
const occupied = periods && Object.values(periods).some((s) => (s.ч || []).length || (s.з || []).length);
|
||||
const block = makeDayBlock(day);
|
||||
if (!occupied) {
|
||||
block.appendChild(Object.assign(document.createElement("div"), {
|
||||
className: "lesson empty",
|
||||
textContent: "Занятий нет",
|
||||
}));
|
||||
daysContainer.appendChild(block);
|
||||
return;
|
||||
}
|
||||
Object.entries(periods)
|
||||
.sort(([a], [b]) => Number(a) - Number(b))
|
||||
.forEach(([num, slot]) => {
|
||||
const chHtml = (slot.ч || []).map((entry) => { hasAny = true; return entityLessonHTML("ч", entry); }).join("");
|
||||
const znHtml = (slot.з || []).map((entry) => { hasAny = true; return entityLessonHTML("з", entry); }).join("");
|
||||
if (!chHtml && !znHtml) return;
|
||||
const inner = `<div class="parities">${parityColHTML("Числитель", chHtml || OKNO_HTML)}${parityColHTML("Знаменатель", znHtml || OKNO_HTML)}</div>`;
|
||||
addPeriodRow(block, num, slot.time, inner);
|
||||
});
|
||||
daysContainer.appendChild(block);
|
||||
});
|
||||
|
||||
if (!hasAny) {
|
||||
daysContainer.innerHTML = "";
|
||||
daysContainer.appendChild(Object.assign(document.createElement("div"), {
|
||||
className: "empty-day",
|
||||
textContent: "Занятий нет",
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- аудитории: занятость по выбранному дню ---------- */
|
||||
|
||||
const FREE_WEEKDAYS = ["понедельник", "вторник", "среда", "четверг", "пятница", "суббота"];
|
||||
|
||||
async function renderRoomsView(day) {
|
||||
const chosenDay = FREE_WEEKDAYS.includes(day) ? day : (FREE_WEEKDAYS.includes(todayName()) ? todayName() : "понедельник");
|
||||
scheduleEl.innerHTML = "";
|
||||
errorEl.classList.add("hidden");
|
||||
loadingEl.classList.remove("hidden");
|
||||
try {
|
||||
const status = await loadStatus();
|
||||
scheduleEl.appendChild(Object.assign(document.createElement("div"), {
|
||||
className: "schedule-title",
|
||||
textContent: "Занятость аудиторий",
|
||||
}));
|
||||
const top = statusRowHTML(status);
|
||||
if (top) scheduleEl.appendChild(top);
|
||||
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "rooms-wrap";
|
||||
scheduleEl.appendChild(wrap);
|
||||
|
||||
const data = await fetchJSON(`/api/rooms-day?day=${enc(chosenDay)}`);
|
||||
wrap.appendChild(roomsDayTable(data));
|
||||
} catch (err) {
|
||||
errorEl.textContent = "Ошибка: " + err.message;
|
||||
errorEl.classList.remove("hidden");
|
||||
} finally {
|
||||
loadingEl.classList.add("hidden");
|
||||
}
|
||||
}
|
||||
|
||||
function roomsDayTable(data) {
|
||||
const periodNums = [];
|
||||
data.rooms.forEach((r) => {
|
||||
Object.keys(r.periods || {}).forEach((p) => {
|
||||
if (!periodNums.includes(p)) periodNums.push(p);
|
||||
});
|
||||
});
|
||||
periodNums.sort((a, b) => Number(a) - Number(b));
|
||||
return buildRoomsList(data, periodNums);
|
||||
}
|
||||
|
||||
function buildRoomsList(data, periodNums) {
|
||||
const list = document.createElement("div");
|
||||
list.className = "rooms-list";
|
||||
data.rooms.forEach((room) => {
|
||||
const card = document.createElement("div");
|
||||
card.className = "room-card";
|
||||
const head = document.createElement("div");
|
||||
head.className = "day-head";
|
||||
head.innerHTML = `<div class="room-title"><span class="day-title">${escapeHTML(room.room)}</span>${room.info ? `<div class="room-info">${escapeHTML(room.info)}</div>` : ""}</div>`;
|
||||
card.appendChild(head);
|
||||
const periods = room.periods || {};
|
||||
periodNums.forEach((p) => {
|
||||
const slot = periods[p] || {};
|
||||
const row = document.createElement("div");
|
||||
row.className = "room-row";
|
||||
const left = document.createElement("div");
|
||||
left.className = "room-row-num";
|
||||
left.innerHTML = `Пара ${p}${slot.time ? `<div class="period-time">${escapeHTML(slot.time)}</div>` : ""}`;
|
||||
row.appendChild(left);
|
||||
const body = document.createElement("div");
|
||||
body.className = "room-row-body";
|
||||
const chEntries = slot.ч || [];
|
||||
const znEntries = slot.з || [];
|
||||
if (!chEntries.length && !znEntries.length) {
|
||||
body.innerHTML = `<div class="parities">${parityColHTML("", OKNO_HTML)}</div>`;
|
||||
} else {
|
||||
const chHtml = chEntries.map((e) => entityLessonHTML("ч", e)).join("");
|
||||
const znHtml = znEntries.map((e) => entityLessonHTML("з", e)).join("");
|
||||
body.innerHTML = `<div class="parities">${parityColHTML("", chHtml || OKNO_HTML)}${parityColHTML("", znHtml || OKNO_HTML)}</div>`;
|
||||
}
|
||||
row.appendChild(body);
|
||||
card.appendChild(row);
|
||||
});
|
||||
list.appendChild(card);
|
||||
});
|
||||
return list;
|
||||
}
|
||||
|
||||
function roomPeriodCell(slot) {
|
||||
const td = document.createElement("td");
|
||||
td.className = "r-cell";
|
||||
const all = [...((slot && slot.ч) || []), ...((slot && slot.з) || [])];
|
||||
if (!all.length) {
|
||||
td.classList.add("r-free");
|
||||
td.textContent = "окно";
|
||||
return td;
|
||||
}
|
||||
td.classList.add("r-busy");
|
||||
const seen = new Set();
|
||||
const lines = [];
|
||||
all.forEach((e) => {
|
||||
if (!e.subject || seen.has(e.subject)) return;
|
||||
seen.add(e.subject);
|
||||
lines.push(e.subject);
|
||||
});
|
||||
lines.forEach((x) => {
|
||||
const div = document.createElement("div");
|
||||
div.className = "r-subj";
|
||||
div.textContent = x;
|
||||
td.appendChild(div);
|
||||
});
|
||||
td.title = lines.join("; ");
|
||||
return td;
|
||||
}
|
||||
|
||||
function escapeHTML(s) {
|
||||
return s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
||||
}
|
||||
|
||||
function applyTheme(theme) {
|
||||
document.documentElement.setAttribute("data-theme", theme);
|
||||
localStorage.setItem("theme", theme);
|
||||
}
|
||||
|
||||
function initTheme() {
|
||||
const saved = localStorage.getItem("theme");
|
||||
applyTheme(saved === "dark" ? "dark" : "light");
|
||||
}
|
||||
|
||||
themeToggle.addEventListener("click", () => {
|
||||
const current = document.documentElement.getAttribute("data-theme") === "dark" ? "dark" : "light";
|
||||
applyTheme(current === "dark" ? "light" : "dark");
|
||||
});
|
||||
|
||||
initTheme();
|
||||
|
||||
document.querySelectorAll(".side-menu a").forEach((link) => {
|
||||
link.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
setMode(link.dataset.view).catch((err) => {
|
||||
errorEl.textContent = "Ошибка: " + err.message;
|
||||
errorEl.classList.remove("hidden");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
groupSelect.addEventListener("change", () => {
|
||||
if (mode === "rooms") {
|
||||
localStorage.setItem("schedule-room-day", groupSelect.value);
|
||||
renderRoomsView(groupSelect.value).catch((err) => {
|
||||
errorEl.textContent = "Ошибка: " + err.message;
|
||||
errorEl.classList.remove("hidden");
|
||||
});
|
||||
return;
|
||||
}
|
||||
renderView();
|
||||
});
|
||||
|
||||
loadWeek().finally(() => {
|
||||
const savedMode = localStorage.getItem("schedule-mode");
|
||||
const initial = ["groups", "teachers", "rooms"].includes(savedMode) ? savedMode : "groups";
|
||||
setMode(initial).catch((err) => {
|
||||
errorEl.textContent = "Ошибка: " + err.message;
|
||||
errorEl.classList.remove("hidden");
|
||||
});
|
||||
});
|
||||
BIN
src/frontend/static/img/favicon.png
Normal file
BIN
src/frontend/static/img/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 315 B |
198
src/frontend/static/img/owl-white.svg
Normal file
198
src/frontend/static/img/owl-white.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 37 KiB |
61
src/frontend/static/img/owl.svg
Normal file
61
src/frontend/static/img/owl.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 13 KiB |
58
src/frontend/static/index.html
Normal file
58
src/frontend/static/index.html
Normal file
@@ -0,0 +1,58 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Расписание · Факультет ИВТ ЯрГУ</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/img/owl-white.svg">
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="topbar">
|
||||
<div class="topbar-brand">
|
||||
<a href="https://ivt.uniyar.ac.ru/" target="_blank" rel="noopener">
|
||||
<img class="topbar-logo" src="/img/owl.svg" alt="ЯрГУ им. П.Г. Демидова">
|
||||
</a>
|
||||
<div class="topbar-text">
|
||||
<a href="https://www.uniyar.ac.ru/" target="_blank" rel="noopener" class="topbar-university">Ярославский государственный университет им. П.Г. Демидова</a>
|
||||
<a href="https://ivt.uniyar.ac.ru/" target="_blank" rel="noopener" class="topbar-faculty">Факультет информатики и вычислительной техники</a>
|
||||
</div>
|
||||
</div>
|
||||
<button id="theme-toggle" class="theme-toggle" title="Переключить тему">
|
||||
<svg class="icon icon-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
|
||||
<svg class="icon icon-moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="layout">
|
||||
<aside class="side">
|
||||
<div class="side-head">
|
||||
<a href="/" class="side-logo-link"><img class="side-logo" src="/img/owl-white.svg" alt="Факультет ИВТ"></a>
|
||||
<div class="side-name">Расписание занятий</div>
|
||||
</div>
|
||||
<div class="side-nav">
|
||||
<nav class="side-menu">
|
||||
<a href="#" class="active" data-view="groups">Расписание групп</a>
|
||||
<a href="#" data-view="teachers">Преподаватели</a>
|
||||
<a href="#" data-view="rooms">Аудитории</a>
|
||||
</nav>
|
||||
<div class="side-label" id="entity-label">Группа</div>
|
||||
<select id="group-select" aria-label="Выбор группы"></select>
|
||||
</div>
|
||||
<div class="side-footer">
|
||||
<a href="https://vk.com/ivt7k" target="_blank" rel="noopener">Сообщество ВКонтакте</a>
|
||||
<a href="https://ivt.uniyar.ac.ru/" target="_blank" rel="noopener">Сайт факультета</a>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main>
|
||||
<div id="error" class="error hidden"></div>
|
||||
<div id="loading" class="loading hidden">Загрузка…</div>
|
||||
<div id="schedule" class="schedule"></div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
683
src/frontend/static/style.css
Normal file
683
src/frontend/static/style.css
Normal file
@@ -0,0 +1,683 @@
|
||||
:root {
|
||||
--bg: #f1f5f9;
|
||||
--surface: #ffffff;
|
||||
--surface-2: #f7fafc;
|
||||
--surface-3: #eef2f8;
|
||||
--border: #dde5ef;
|
||||
--border-2: #c3d1e2;
|
||||
--text: #17223a;
|
||||
--muted: #57677f;
|
||||
--muted-2: #8c9bb0;
|
||||
|
||||
--nav-1: #1b2dd8;
|
||||
--nav-2: #167cf2;
|
||||
--accent: #0241e3;
|
||||
--accent-blue: #1f4fd0;
|
||||
--accent-red: #d01838;
|
||||
--accent-red-bright: #e61d43;
|
||||
--accent-blue-bg: #e5ecfc;
|
||||
--on-accent: #ffffff;
|
||||
|
||||
--error-bg: #fbe9ea;
|
||||
--error-border: #f3c1c5;
|
||||
--error-text: #b00d15;
|
||||
--shadow: rgba(10, 50, 110, 0.12);
|
||||
|
||||
--badge-ch-bg: #e3ebfc;
|
||||
--badge-ch-text: #1f4fd0;
|
||||
--badge-zn-bg: #fbe3e7;
|
||||
--badge-zn-text: #c40f30;
|
||||
--badge-kv-bg: #edf1f7;
|
||||
--badge-kv-text: #43536b;
|
||||
--badge-always-bg: #e6edf6;
|
||||
--badge-always-text: #2e3d55;
|
||||
--badge-ch-active-bg: linear-gradient(90deg, #1b2dd8, #167cf2);
|
||||
--badge-ch-active-text: #ffffff;
|
||||
--badge-zn-active-bg: #e61d43;
|
||||
--badge-zn-active-text: #ffffff;
|
||||
}
|
||||
|
||||
[data-theme="dark"] {
|
||||
--bg: #0a1120;
|
||||
--surface: #111a2e;
|
||||
--surface-2: #16223a;
|
||||
--surface-3: #1d2e4c;
|
||||
--border: #25375a;
|
||||
--border-2: #3a4f78;
|
||||
--text: #e7eef8;
|
||||
--muted: #96adca;
|
||||
--muted-2: #647e9f;
|
||||
|
||||
--nav-1: #1d2f72;
|
||||
--nav-2: #1358b0;
|
||||
--accent: #5ba3f7;
|
||||
--accent-blue: #86b8ff;
|
||||
--accent-red: #e96a7e;
|
||||
--accent-red-bright: #ff6b7f;
|
||||
--accent-blue-bg: #1a2d52;
|
||||
--on-accent: #ffffff;
|
||||
|
||||
--error-bg: #4d1c22;
|
||||
--error-border: #7c2a34;
|
||||
--error-text: #ffc2c9;
|
||||
--shadow: rgba(0, 0, 0, 0.45);
|
||||
|
||||
--badge-ch-bg: #1a2c55;
|
||||
--badge-ch-text: #8cb8ff;
|
||||
--badge-zn-bg: #54222d;
|
||||
--badge-zn-text: #ff9dae;
|
||||
--badge-kv-bg: #1e2c43;
|
||||
--badge-kv-text: #b6c6da;
|
||||
--badge-always-bg: #23314d;
|
||||
--badge-always-text: #d9e3f0;
|
||||
--badge-ch-active-bg: #3d86f2;
|
||||
--badge-ch-active-text: #ffffff;
|
||||
--badge-zn-active-bg: #ff6479;
|
||||
--badge-zn-active-text: #ffffff;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: Roboto, -apple-system, "Segoe UI", Arial, sans-serif;
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
a { color: var(--accent); text-decoration: none; }
|
||||
a:hover { color: var(--accent-red); }
|
||||
|
||||
/* ---- Верхняя белая полоса с цветной совой (как на сайте) ---- */
|
||||
.topbar {
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 8px 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.topbar-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.topbar-logo {
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.topbar-text { display: flex; flex-direction: column; line-height: 1.25; min-width: 0; }
|
||||
|
||||
.topbar-university {
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.topbar-university:hover { color: var(--accent-red); }
|
||||
|
||||
.topbar-faculty {
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.topbar-faculty:hover { color: var(--accent-red); }
|
||||
|
||||
.theme-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--border-2);
|
||||
background: var(--surface);
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.theme-toggle:hover { color: var(--accent); border-color: var(--accent); }
|
||||
.theme-toggle .icon { width: 18px; height: 18px; }
|
||||
|
||||
.icon-sun { display: none; }
|
||||
.icon-moon { display: block; }
|
||||
|
||||
[data-theme="dark"] .icon-sun { display: block; }
|
||||
[data-theme="dark"] .icon-moon { display: none; }
|
||||
|
||||
/* ---- Двухколоночный layout: синяя панель + контент ---- */
|
||||
.layout {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
min-height: calc(100vh - 63px);
|
||||
}
|
||||
|
||||
.side {
|
||||
width: 300px;
|
||||
flex-shrink: 0;
|
||||
background: linear-gradient(to bottom, var(--nav-1), var(--nav-2));
|
||||
color: var(--on-accent);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 26px 22px 22px;
|
||||
position: sticky;
|
||||
top: 63px;
|
||||
align-self: flex-start;
|
||||
height: calc(100vh - 63px);
|
||||
}
|
||||
|
||||
.side-head { text-align: center; padding-bottom: 18px; border-bottom: 1px solid rgba(255, 255, 255, 0.22); }
|
||||
|
||||
.side-logo-link { display: inline-block; }
|
||||
.side-logo { width: 92px; height: 92px; }
|
||||
|
||||
.side-name {
|
||||
margin-top: 12px;
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.side-nav {
|
||||
padding: 18px 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.side-menu {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.side-menu a {
|
||||
display: block;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
padding: 8px 10px;
|
||||
border-left: 3px solid transparent;
|
||||
}
|
||||
|
||||
.side-menu a:hover { color: #ffffff; background: rgba(0, 0, 0, 0.12); }
|
||||
|
||||
.side-menu a.active {
|
||||
color: #ffffff;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-left-color: #ffffff;
|
||||
}
|
||||
|
||||
.side-label {
|
||||
text-transform: uppercase;
|
||||
font-size: 12px;
|
||||
letter-spacing: 1px;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
#group-select {
|
||||
width: 100%;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
padding: 9px 10px;
|
||||
border: none;
|
||||
background: #ffffff;
|
||||
color: #141c2b;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#group-select option { background: #ffffff; color: #141c2b; }
|
||||
|
||||
[data-theme="dark"] #group-select {
|
||||
background: rgba(10, 19, 40, 0.6);
|
||||
color: #ffffff;
|
||||
border: 1px solid rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
|
||||
[data-theme="dark"] #group-select option {
|
||||
background: var(--surface-3);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.side-footer {
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.22);
|
||||
padding-top: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.side-footer a { color: rgba(255, 255, 255, 0.92); }
|
||||
.side-footer a:hover { color: #ffffff; text-decoration: underline; }
|
||||
|
||||
main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 26px 28px 40px;
|
||||
}
|
||||
|
||||
.schedule { max-width: 900px; }
|
||||
|
||||
.schedule-title {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.schedule-title .badge { margin-left: 10px; font-size: 13px; vertical-align: middle; }
|
||||
|
||||
.schedule-meta { color: var(--muted); font-size: 14px; margin-bottom: 10px; }
|
||||
|
||||
.schedule-status {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.status-chip {
|
||||
flex: 1 1 0;
|
||||
min-width: 170px;
|
||||
min-height: 54px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 3px;
|
||||
padding: 8px 12px;
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.status-chip.current-week { align-items: flex-start; }
|
||||
|
||||
.status-label {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.6px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.status-value {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.current-week .badge { margin-right: 0; font-size: 12px; }
|
||||
|
||||
.day-block {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.day-block.today {
|
||||
border-color: var(--accent-red-bright);
|
||||
box-shadow: inset 3px 0 0 var(--accent-red-bright);
|
||||
}
|
||||
|
||||
.day-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
.day-title { font-size: 15px; font-weight: 700; text-transform: capitalize; color: var(--text); }
|
||||
|
||||
.today-badge {
|
||||
background: var(--accent-red-bright);
|
||||
color: #ffffff;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
padding: 2px 8px;
|
||||
letter-spacing: 0.4px;
|
||||
}
|
||||
|
||||
.period {
|
||||
padding: 8px 14px;
|
||||
display: grid;
|
||||
grid-template-columns: 96px 1fr;
|
||||
gap: 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.period:last-child { border-bottom: none; }
|
||||
|
||||
.period-num { font-weight: 700; color: var(--text); }
|
||||
|
||||
.period-time { font-size: 13px; color: var(--muted); margin-top: 2px; }
|
||||
|
||||
.parities {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.parity-col {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.parity-col .lesson {
|
||||
margin-bottom: 0;
|
||||
flex: 1 1 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.parity-col .window { justify-content: center; text-align: center; }
|
||||
|
||||
@media (max-width: 1023px) {
|
||||
.parities { flex-direction: column; }
|
||||
}
|
||||
|
||||
.lesson {
|
||||
border: 1px solid var(--border);
|
||||
padding: 7px 11px;
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
.lesson:hover { border-color: var(--accent); }
|
||||
|
||||
.lesson .subject { font-weight: 700; font-size: 14px; }
|
||||
|
||||
.lesson .details { font-size: 13px; color: var(--muted); margin-top: 1px; }
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
padding: 2px 8px;
|
||||
margin-right: 6px;
|
||||
vertical-align: 1px;
|
||||
}
|
||||
|
||||
.badge-ch { background: var(--badge-ch-bg); color: var(--badge-ch-text); }
|
||||
.badge-zn { background: var(--badge-zn-bg); color: var(--badge-zn-text); }
|
||||
.badge-kv { background: var(--badge-kv-bg); color: var(--badge-kv-text); }
|
||||
.badge-always { background: var(--badge-always-bg); color: var(--badge-always-text); }
|
||||
|
||||
.badge-ch.active { background: var(--badge-ch-active-bg); color: var(--badge-ch-active-text); }
|
||||
.badge-zn.active { background: var(--badge-zn-active-bg); color: var(--badge-zn-active-text); }
|
||||
|
||||
.lesson.full { grid-column: 1 / -1; }
|
||||
|
||||
.empty-day { color: var(--muted-2); font-size: 14px; padding: 10px 14px; }
|
||||
|
||||
.lesson.empty {
|
||||
grid-column: 1 / -1;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
background: var(--surface-3);
|
||||
border: 1px dashed var(--border-2);
|
||||
font-weight: 600;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.window {
|
||||
border-style: dashed;
|
||||
color: var(--muted-2);
|
||||
background: var(--surface);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ---- поиск свободной аудитории ---- */
|
||||
.free-widget {
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
padding: 12px 14px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.free-title {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.free-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.free-hint { font-size: 13px; color: var(--muted); }
|
||||
|
||||
.free-controls select {
|
||||
font-size: 14px;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid var(--border-2);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.free-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.free-item {
|
||||
text-align: left;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.free-item:hover { border-color: var(--accent); }
|
||||
|
||||
.free-room { font-weight: 700; }
|
||||
|
||||
.free-info { font-size: 12px; color: var(--muted); }
|
||||
|
||||
.free-loading, .free-none { color: var(--muted); font-size: 14px; padding: 6px 0; }
|
||||
|
||||
.error {
|
||||
background: var(--error-bg);
|
||||
color: var(--error-text);
|
||||
border: 1px solid var(--error-border);
|
||||
padding: 12px 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.loading { color: var(--muted); text-align: center; padding: 30px; }
|
||||
|
||||
.hidden { display: none; }
|
||||
|
||||
/* ---- Мобильная версия: панель сверху ---- */
|
||||
@media (max-width: 860px) {
|
||||
.layout { flex-direction: column; }
|
||||
|
||||
.side {
|
||||
width: 100%;
|
||||
position: static;
|
||||
height: auto;
|
||||
padding: 14px 16px;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.side-head {
|
||||
text-align: left;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding-bottom: 0;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.side-logo { width: 52px; height: 52px; }
|
||||
.side-name { margin-top: 0; font-size: 15px; }
|
||||
.side-nav { padding: 0; flex: 1; min-width: 180px; }
|
||||
.side-label { display: none; }
|
||||
.side-footer { display: none; }
|
||||
|
||||
.side-menu {
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.side-menu a {
|
||||
border-left: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
padding: 6px 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.side-menu a.active { border-bottom-color: #ffffff; }
|
||||
|
||||
main { padding: 16px 12px 30px; }
|
||||
.period { grid-template-columns: 80px 1fr; gap: 10px; }
|
||||
}
|
||||
|
||||
/* ---- занятость аудиторий по дню ---- */
|
||||
.rooms-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.rooms-controls select {
|
||||
font-size: 15px;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid var(--border-2);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.rooms-scroll { overflow-x: auto; }
|
||||
|
||||
.rooms-table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
min-width: 640px;
|
||||
table-layout: fixed;
|
||||
background: var(--surface);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.rooms-table th:first-child,
|
||||
.rooms-table td:first-child { width: 170px; }
|
||||
|
||||
.rooms-table th,
|
||||
.rooms-table td {
|
||||
border: 1px solid var(--border);
|
||||
padding: 6px 9px;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.rooms-table th {
|
||||
background: var(--surface-3);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.r-name {
|
||||
font-weight: 700;
|
||||
width: 170px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.r-name-info { font-weight: 400; color: var(--muted); font-size: 12px; margin-top: 2px; line-height: 1.35; }
|
||||
|
||||
.r-cell { min-width: 90px; }
|
||||
|
||||
.r-cell.r-free {
|
||||
background: var(--surface);
|
||||
color: var(--muted-2);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.r-cell.r-busy { background: var(--badge-ch-bg); }
|
||||
|
||||
.r-subj { font-weight: 600; color: var(--text); line-height: 1.3; }
|
||||
|
||||
/* ---- аудитории: список карточками (мобильная версия) ---- */
|
||||
.rooms-list { display: flex; flex-direction: column; }
|
||||
|
||||
.room-card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
padding: 14px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.room-title { line-height: 1.3; }
|
||||
|
||||
.room-info {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.room-row {
|
||||
display: grid;
|
||||
grid-template-columns: 96px 1fr;
|
||||
gap: 12px;
|
||||
padding: 10px 0;
|
||||
border-top: 1px solid var(--border);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.room-row-num { font-weight: 700; color: var(--text); }
|
||||
|
||||
.room-row-body { min-width: 0; }
|
||||
|
||||
.room-lesson {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: var(--text);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.room-lesson:last-child { margin-bottom: 0; }
|
||||
|
||||
|
||||
|
||||
.lesson-note {
|
||||
color: var(--accent-red-bright);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
margin-top: 4px;
|
||||
white-space: pre-line;
|
||||
}
|
||||
Reference in New Issue
Block a user