Files
magistr/frontend/student/index.html

505 lines
15 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Расписание студента</title>
<style>
* {
box-sizing: border-box;
}
:root {
--bg: #f4f7fb;
--panel: #ffffff;
--panel-border: #dbe3ef;
--text: #162033;
--muted: #66758f;
--accent: #0f766e;
--accent-strong: #0b5d57;
--blue: #2563eb;
--warning: #b45309;
--shadow: 0 12px 34px rgba(22, 32, 51, 0.08);
}
[data-theme="dark"] {
--bg: #0e131d;
--panel: #151d2b;
--panel-border: #263449;
--text: #edf3fb;
--muted: #9cadc5;
--accent: #2dd4bf;
--accent-strong: #5eead4;
--blue: #60a5fa;
--warning: #f59e0b;
--shadow: 0 18px 44px rgba(0, 0, 0, 0.28);
}
body {
margin: 0;
min-height: 100vh;
background: var(--bg);
color: var(--text);
font-family: "Segoe UI", system-ui, -apple-system, sans-serif;
}
.shell {
max-width: 1240px;
margin: 0 auto;
padding: 24px;
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 18px;
}
.title h1 {
margin: 0 0 4px;
font-size: 28px;
font-weight: 700;
letter-spacing: 0;
}
.title span {
color: var(--muted);
font-size: 14px;
}
.actions {
display: flex;
align-items: center;
gap: 10px;
}
.btn,
.select,
.date-input {
min-height: 40px;
border: 1px solid var(--panel-border);
border-radius: 8px;
background: var(--panel);
color: var(--text);
font: inherit;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 40px;
padding: 0 14px;
cursor: pointer;
text-decoration: none;
}
.btn:hover {
border-color: var(--accent);
color: var(--accent-strong);
}
.btn-primary {
border-color: var(--accent);
background: var(--accent);
color: #fff;
}
.btn-primary:hover {
background: var(--accent-strong);
color: #fff;
}
.toolbar {
display: grid;
grid-template-columns: minmax(220px, 1fr) auto auto;
gap: 12px;
margin-bottom: 16px;
padding: 14px;
border: 1px solid var(--panel-border);
border-radius: 8px;
background: var(--panel);
box-shadow: var(--shadow);
}
.select,
.date-input {
width: 100%;
padding: 0 12px;
}
.week-nav {
display: flex;
gap: 8px;
}
.status {
min-height: 22px;
margin-bottom: 12px;
color: var(--muted);
font-size: 14px;
}
.schedule {
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
gap: 10px;
}
.day {
min-height: 260px;
border: 1px solid var(--panel-border);
border-radius: 8px;
background: var(--panel);
overflow: hidden;
}
.day-head {
padding: 12px;
border-bottom: 1px solid var(--panel-border);
background: color-mix(in srgb, var(--accent) 10%, transparent);
}
.day-name {
font-weight: 700;
}
.day-date {
margin-top: 2px;
color: var(--muted);
font-size: 13px;
}
.lesson-list {
display: flex;
flex-direction: column;
gap: 8px;
padding: 10px;
}
.lesson {
border-left: 4px solid var(--blue);
border-radius: 7px;
background: color-mix(in srgb, var(--blue) 9%, transparent);
padding: 10px;
}
.lesson-time {
color: var(--muted);
font-size: 13px;
font-weight: 600;
}
.lesson-title {
margin-top: 5px;
font-weight: 700;
line-height: 1.25;
}
.lesson-meta {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 8px;
}
.chip {
border: 1px solid var(--panel-border);
border-radius: 999px;
padding: 3px 8px;
color: var(--muted);
font-size: 12px;
background: var(--panel);
}
.empty {
padding: 16px 10px;
color: var(--muted);
font-size: 13px;
}
.error {
color: var(--warning);
}
@media (max-width: 980px) {
.schedule {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 640px) {
.shell {
padding: 16px;
}
.topbar,
.actions,
.toolbar {
grid-template-columns: 1fr;
align-items: stretch;
}
.topbar,
.actions {
display: grid;
}
.week-nav {
justify-content: stretch;
}
.week-nav .btn {
flex: 1;
}
.schedule {
grid-template-columns: 1fr;
}
}
</style>
</head>
<body>
<main class="shell">
<header class="topbar">
<div class="title">
<h1>Расписание студента</h1>
<span id="week-label">Загрузка периода...</span>
</div>
<div class="actions">
<button class="btn" id="theme-toggle-local" type="button" aria-label="Переключить тему"></button>
<a class="btn" href="/" id="logout-link">Выйти</a>
</div>
</header>
<section class="toolbar">
<select class="select" id="group-select" aria-label="Группа">
<option value="">Загрузка групп...</option>
</select>
<div class="week-nav">
<button class="btn" id="prev-week" type="button" aria-label="Предыдущая неделя"></button>
<button class="btn btn-primary" id="today-week" type="button">Сегодня</button>
<button class="btn" id="next-week" type="button" aria-label="Следующая неделя"></button>
</div>
<input class="date-input" id="date-picker" type="date" aria-label="Дата недели">
</section>
<div class="status" id="status-line"></div>
<section class="schedule" id="schedule-grid" aria-live="polite"></section>
</main>
<script>
(() => {
const token = localStorage.getItem('token');
const groupSelect = document.getElementById('group-select');
const grid = document.getElementById('schedule-grid');
const statusLine = document.getElementById('status-line');
const weekLabel = document.getElementById('week-label');
const datePicker = document.getElementById('date-picker');
let weekStart = startOfWeek(new Date());
document.getElementById('logout-link').addEventListener('click', () => {
localStorage.removeItem('token');
localStorage.removeItem('role');
localStorage.removeItem('departmentId');
localStorage.removeItem('userId');
});
document.getElementById('theme-toggle-local').addEventListener('click', () => {
const next = document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark';
document.documentElement.dataset.theme = next;
localStorage.setItem('theme', next);
});
document.documentElement.dataset.theme = localStorage.getItem('theme') || 'light';
document.getElementById('prev-week').addEventListener('click', () => moveWeek(-1));
document.getElementById('today-week').addEventListener('click', () => {
weekStart = startOfWeek(new Date());
loadSchedule();
});
document.getElementById('next-week').addEventListener('click', () => moveWeek(1));
datePicker.addEventListener('change', () => {
if (datePicker.value) {
weekStart = startOfWeek(new Date(datePicker.value));
loadSchedule();
}
});
groupSelect.addEventListener('change', () => {
localStorage.setItem('studentGroupId', groupSelect.value);
loadSchedule();
});
init();
async function init() {
await loadGroups();
await loadSchedule();
}
async function loadGroups() {
try {
const groups = await fetchJson('/api/groups');
if (!groups.length) {
groupSelect.innerHTML = '<option value="">Группы не найдены</option>';
return;
}
const savedGroupId = localStorage.getItem('studentGroupId');
groupSelect.innerHTML = groups.map(group => (
`<option value="${escapeHtml(group.id)}">${escapeHtml(group.name)}</option>`
)).join('');
groupSelect.value = savedGroupId && groups.some(group => String(group.id) === savedGroupId)
? savedGroupId
: String(groups[0].id);
} catch (error) {
groupSelect.innerHTML = '<option value="">Ошибка загрузки групп</option>';
showStatus(error.message, true);
}
}
async function loadSchedule() {
const groupId = groupSelect.value;
const startDate = toIsoDate(weekStart);
const endDate = toIsoDate(addDays(weekStart, 6));
datePicker.value = startDate;
weekLabel.textContent = `${formatDate(weekStart)} - ${formatDate(addDays(weekStart, 6))}`;
renderEmptyWeek('Загрузка...');
if (!groupId) {
showStatus('Выберите группу', false);
renderEmptyWeek('Нет выбранной группы');
return;
}
try {
const lessons = await fetchJson(`/api/schedule?groupId=${encodeURIComponent(groupId)}&startDate=${startDate}&endDate=${endDate}`);
renderWeek(lessons);
showStatus(lessons.length ? `Найдено занятий: ${lessons.length}` : 'На этой неделе занятий нет', false);
} catch (error) {
renderEmptyWeek('Ошибка загрузки');
showStatus(error.message, true);
}
}
function renderWeek(lessons) {
const lessonsByDate = new Map();
lessons.forEach(lesson => {
if (!lessonsByDate.has(lesson.date)) lessonsByDate.set(lesson.date, []);
lessonsByDate.get(lesson.date).push(lesson);
});
grid.innerHTML = daysOfWeek().map(date => {
const key = toIsoDate(date);
const dayLessons = lessonsByDate.get(key) || [];
return renderDay(date, dayLessons);
}).join('');
}
function renderEmptyWeek(message) {
grid.innerHTML = daysOfWeek().map(date => renderDay(date, [], message)).join('');
}
function renderDay(date, lessons, emptyMessage = 'Занятий нет') {
return `
<article class="day">
<div class="day-head">
<div class="day-name">${escapeHtml(dayName(date))}</div>
<div class="day-date">${escapeHtml(formatDate(date))}</div>
</div>
<div class="lesson-list">
${lessons.length ? lessons.map(renderLesson).join('') : `<div class="empty">${escapeHtml(emptyMessage)}</div>`}
</div>
</article>
`;
}
function renderLesson(lesson) {
const groups = Array.isArray(lesson.groupNames) ? lesson.groupNames.join(', ') : '';
const subgroups = Array.isArray(lesson.subgroupNames) && lesson.subgroupNames.length
? lesson.subgroupNames.join(', ')
: lesson.subgroupName;
return `
<div class="lesson">
<div class="lesson-time">${escapeHtml(trimTime(lesson.startTime))} - ${escapeHtml(trimTime(lesson.endTime))}</div>
<div class="lesson-title">${escapeHtml(lesson.subjectName)}</div>
<div class="lesson-meta">
<span class="chip">${escapeHtml(lesson.lessonTypeName)}</span>
${subgroups ? `<span class="chip">${escapeHtml(subgroups)}</span>` : ''}
<span class="chip">${escapeHtml(lesson.lessonFormat)}</span>
<span class="chip">${escapeHtml(lesson.teacherName)}</span>
<span class="chip">${escapeHtml(lesson.classroomName)}</span>
${groups ? `<span class="chip">${escapeHtml(groups)}</span>` : ''}
</div>
</div>
`;
}
function moveWeek(delta) {
weekStart = addDays(weekStart, delta * 7);
loadSchedule();
}
function daysOfWeek() {
return Array.from({ length: 7 }, (_, index) => addDays(weekStart, index));
}
async function fetchJson(url) {
const response = await fetch(url, {
headers: token ? { Authorization: `Bearer ${token}` } : {}
});
const data = await response.json().catch(() => null);
if (!response.ok) {
throw new Error(data?.message || `Ошибка HTTP: ${response.status}`);
}
return data;
}
function showStatus(message, isError) {
statusLine.textContent = message;
statusLine.classList.toggle('error', isError);
}
function startOfWeek(date) {
const copy = new Date(date.getFullYear(), date.getMonth(), date.getDate());
const day = copy.getDay() || 7;
copy.setDate(copy.getDate() - day + 1);
return copy;
}
function addDays(date, days) {
const copy = new Date(date);
copy.setDate(copy.getDate() + days);
return copy;
}
function toIsoDate(date) {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
}
function formatDate(date) {
return date.toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric' });
}
function dayName(date) {
const value = date.toLocaleDateString('ru-RU', { weekday: 'long' });
return value.charAt(0).toUpperCase() + value.slice(1);
}
function trimTime(value) {
return String(value || '').slice(0, 5);
}
function escapeHtml(value) {
return String(value ?? '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
}
})();
</script>
</body>
</html>