470 lines
27 KiB
HTML
470 lines
27 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="ru">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>Расписание — Morozovka Portal</title>
|
||
<style>
|
||
* { box-sizing: border-box; }
|
||
body { background: #0d1117; color: #e6edf3; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; margin: 0; padding: 20px; min-height: 100vh; }
|
||
.page { max-width: 1200px; margin: 0 auto; }
|
||
h1 { color: #58a6ff; margin: 0 0 15px; }
|
||
.back-link { display: inline-block; margin-bottom: 15px; color: #8b949e; text-decoration: none; }
|
||
.back-link:hover { color: #58a6ff; }
|
||
|
||
.status-banner { padding: 18px 20px; border-radius: 10px; text-align: center; margin-bottom: 20px; }
|
||
.status-on_place { background: rgba(63,185,80,.15); border: 1px solid #238636; color: #3fb950; }
|
||
.status-delayed { background: rgba(210,153,34,.15); border: 1px solid #d29922; color: #d29922; }
|
||
.status-away, .status-cancelled { background: rgba(248,81,73,.15); border: 1px solid #f85149; color: #f85149; }
|
||
.status-free { background: rgba(139,148,158,.15); border: 1px solid #8b949e; color: #8b949e; }
|
||
.status-title { font-size: 1.3rem; font-weight: bold; margin-bottom: 4px; }
|
||
.status-message { font-size: .95rem; opacity: .9; }
|
||
|
||
.calendar { background: #161b22; border: 1px solid #30363d; border-radius: 12px; overflow: hidden; }
|
||
.cal-toolbar { display: flex; align-items: center; justify-content: space-between; padding: 14px 20px; border-bottom: 1px solid #30363d; gap: 15px; flex-wrap: wrap; }
|
||
.cal-nav { display: flex; gap: 8px; align-items: center; }
|
||
.btn-icon { background: #21262d; border: 1px solid #30363d; color: #e6edf3; width: 36px; height: 36px; border-radius: 6px; cursor: pointer; font-size: 1.1rem; display: inline-flex; align-items: center; justify-content: center; }
|
||
.btn-icon:hover { background: #30363d; }
|
||
.btn-secondary { background: #21262d; border: 1px solid #30363d; color: #e6edf3; padding: 8px 16px; border-radius: 6px; cursor: pointer; font-weight: 600; font-size: .9rem; }
|
||
.btn-secondary:hover { background: #30363d; }
|
||
.cal-title { font-size: 1.2rem; margin: 0; color: #e6edf3; font-weight: 600; flex: 1; text-align: center; min-width: 150px; }
|
||
.view-switcher { display: flex; background: #0d1117; border: 1px solid #30363d; border-radius: 6px; overflow: hidden; }
|
||
.view-switcher button { background: transparent; border: none; color: #8b949e; padding: 8px 14px; cursor: pointer; font-weight: 600; font-size: .9rem; transition: all .15s; }
|
||
.view-switcher button:hover { color: #e6edf3; }
|
||
.view-switcher button.active { background: #58a6ff; color: #fff; }
|
||
|
||
.cal-filters { display: flex; gap: 10px; padding: 12px 20px; border-bottom: 1px solid #30363d; flex-wrap: wrap; background: #0d1117; }
|
||
.filter-chip { display: inline-flex; align-items: center; gap: 6px; padding: 6px 12px; background: #21262d; border: 1px solid #30363d; border-radius: 20px; cursor: pointer; font-size: .85rem; color: #8b949e; user-select: none; transition: all .15s; }
|
||
.filter-chip:hover { border-color: #58a6ff; }
|
||
.filter-chip input { display: none; }
|
||
.filter-chip:has(input:checked) { background: rgba(88,166,255,.1); border-color: #58a6ff; color: #e6edf3; }
|
||
|
||
.month-header { display: grid; grid-template-columns: repeat(7,1fr); background: #0d1117; border-bottom: 1px solid #30363d; }
|
||
.month-header-cell { padding: 10px; text-align: center; color: #8b949e; font-weight: 600; font-size: .75rem; text-transform: uppercase; }
|
||
.month-grid { display: grid; grid-template-columns: repeat(7,1fr); grid-auto-rows: minmax(110px,auto); }
|
||
.month-cell { border-right: 1px solid #30363d; border-bottom: 1px solid #30363d; padding: 6px; overflow: hidden; position: relative; min-height: 110px; }
|
||
.month-cell:nth-child(7n) { border-right: none; }
|
||
.month-cell.other-month { background: rgba(0,0,0,.15); }
|
||
.month-cell.other-month .month-day-num { color: #4d5560; }
|
||
.month-cell.today { background: rgba(88,166,255,.05); }
|
||
.month-cell.today .month-day-num { background: #58a6ff; color: #fff; border-radius: 50%; width: 24px; height: 24px; display: inline-flex; align-items: center; justify-content: center; }
|
||
.month-day-num { font-size: .85rem; color: #8b949e; font-weight: 600; margin-bottom: 6px; }
|
||
.month-event { display: flex; gap: 4px; font-size: .7rem; padding: 2px 5px; border-radius: 3px; margin-bottom: 3px; cursor: pointer; overflow: hidden; white-space: nowrap; border-left: 3px solid; }
|
||
.month-event:hover { opacity: .85; }
|
||
.month-event .event-time { font-weight: 600; opacity: .75; }
|
||
.month-event .event-title { overflow: hidden; text-overflow: ellipsis; }
|
||
.month-more { font-size: .7rem; color: #58a6ff; padding: 2px 4px; }
|
||
|
||
.week-view, .day-view { display: flex; flex-direction: column; }
|
||
.week-header { display: grid; grid-template-columns: 50px repeat(7,1fr); background: #0d1117; border-bottom: 1px solid #30363d; }
|
||
.week-day-header { padding: 10px 4px; text-align: center; border-left: 1px solid #30363d; }
|
||
.week-day-header.today .day-num { background: #58a6ff; color: #fff; border-radius: 50%; width: 26px; height: 26px; display: inline-flex; align-items: center; justify-content: center; margin: 0 auto; }
|
||
.day-name { font-size: .7rem; color: #8b949e; text-transform: uppercase; font-weight: 600; margin-bottom: 4px; }
|
||
.day-num { font-size: 1.1rem; font-weight: 600; color: #e6edf3; }
|
||
.week-body { display: grid; grid-template-columns: 50px repeat(7,1fr); overflow-y: auto; max-height: 560px; position: relative; }
|
||
.day-body { display: grid; grid-template-columns: 50px 1fr; overflow-y: auto; max-height: 600px; position: relative; }
|
||
.time-gutter-col { background: #0d1117; border-right: 1px solid #30363d; }
|
||
.hour-label { height: 40px; padding: 2px 6px; font-size: .7rem; color: #6e7681; text-align: right; }
|
||
.day-col { position: relative; border-left: 1px solid #30363d; }
|
||
.day-col-single { border-left: none; }
|
||
.hour-cell { height: 40px; border-bottom: 1px solid #21262d; }
|
||
.week-event { position: absolute; background: rgba(88,166,255,.2); border-left: 3px solid #58a6ff; border-radius: 4px; padding: 3px 6px; font-size: .7rem; color: #e6edf3; overflow: hidden; cursor: pointer; z-index: 2; min-height: 18px; transition: filter .15s; }
|
||
.week-event:hover { filter: brightness(1.3); z-index: 10; }
|
||
.we-time { font-size: .65rem; opacity: .85; font-weight: 600; }
|
||
.we-title { font-weight: 600; overflow: hidden; text-overflow: ellipsis; }
|
||
.we-loc { font-size: .65rem; opacity: .7; margin-top: 1px; }
|
||
.we-desc { font-size: .7rem; opacity: .8; margin-top: 4px; line-height: 1.3; }
|
||
.now-line { position: absolute; left: 0; right: 0; height: 2px; background: #f85149; z-index: 20; pointer-events: none; }
|
||
.now-line::before { content: ''; position: absolute; left: -4px; top: -3px; width: 8px; height: 8px; background: #f85149; border-radius: 50%; }
|
||
|
||
.event-type-lecture { background: rgba(88,166,255,.18); border-left-color: #58a6ff; }
|
||
.event-type-workshop { background: rgba(163,113,247,.18); border-left-color: #a371f7; }
|
||
.event-type-meeting { background: rgba(210,153,34,.18); border-left-color: #d29922; }
|
||
.event-type-personal { background: rgba(139,148,158,.18); border-left-color: #8b949e; }
|
||
.month-event.event-type-lecture { background: rgba(88,166,255,.15); color: #58a6ff; }
|
||
.month-event.event-type-workshop { background: rgba(163,113,247,.15); color: #a371f7; }
|
||
.month-event.event-type-meeting { background: rgba(210,153,34,.15); color: #d29922; }
|
||
.month-event.event-type-personal { background: rgba(139,148,158,.15); color: #8b949e; }
|
||
|
||
.event-popup { position: fixed; top: 50%; left: 50%; transform: translate(-50%,-50%); background: #161b22; border: 1px solid #30363d; border-radius: 12px; padding: 25px; max-width: 420px; width: 90%; z-index: 1000; box-shadow: 0 20px 50px rgba(0,0,0,.6); }
|
||
.event-popup.hidden { display: none; }
|
||
.event-popup h3 { margin: 0 0 12px; color: #58a6ff; }
|
||
.popup-close { position: absolute; top: 12px; right: 15px; cursor: pointer; color: #8b949e; font-size: 1.1rem; }
|
||
.popup-close:hover { color: #f85149; }
|
||
#popup-meta { color: #8b949e; font-size: .9rem; line-height: 1.8; }
|
||
#popup-desc { margin-top: 15px; color: #c9d1d9; line-height: 1.5; white-space: pre-wrap; }
|
||
|
||
.chat-fab { position: fixed; bottom: 25px; right: 25px; width: 60px; height: 60px; border-radius: 50%; background: linear-gradient(135deg,#58a6ff,#a371f7); color: #fff; border: none; font-size: 1.5rem; cursor: pointer; box-shadow: 0 6px 20px rgba(88,166,255,.4); z-index: 999; transition: transform .2s; display: flex; align-items: center; justify-content: center; }
|
||
.chat-fab:hover { transform: scale(1.08); }
|
||
.chat-window { position: fixed; bottom: 95px; right: 25px; width: 360px; height: 500px; background: #161b22; border: 1px solid #30363d; border-radius: 12px; display: flex; flex-direction: column; z-index: 998; box-shadow: 0 20px 50px rgba(0,0,0,.6); overflow: hidden; }
|
||
.chat-window.hidden { display: none; }
|
||
.chat-header { padding: 14px 18px; background: #0d1117; border-bottom: 1px solid #30363d; font-weight: 600; display: flex; justify-content: space-between; align-items: center; }
|
||
.chat-close { background: none; border: none; color: #8b949e; cursor: pointer; font-size: 1rem; }
|
||
.chat-close:hover { color: #f85149; }
|
||
.chat-messages { flex: 1; padding: 15px; overflow-y: auto; display: flex; flex-direction: column; gap: 8px; }
|
||
.msg { max-width: 85%; padding: 8px 12px; border-radius: 10px; font-size: .9rem; line-height: 1.4; white-space: pre-wrap; word-wrap: break-word; }
|
||
.msg.bot { background: #21262d; border: 1px solid #30363d; align-self: flex-start; }
|
||
.msg.user { background: #1f6feb; color: #fff; align-self: flex-end; }
|
||
.chat-form { display: flex; padding: 10px; border-top: 1px solid #30363d; background: #0d1117; gap: 8px; }
|
||
.chat-form input { flex: 1; padding: 10px 12px; background: #21262d; border: 1px solid #30363d; color: #fff; border-radius: 6px; outline: none; font-size: .9rem; }
|
||
.chat-form input:focus { border-color: #58a6ff; }
|
||
.chat-form button { padding: 10px 16px; background: #238636; color: #fff; border: none; border-radius: 6px; cursor: pointer; font-weight: bold; }
|
||
.chat-form button:hover { background: #2ea043; }
|
||
|
||
@media (max-width: 768px) {
|
||
body { padding: 10px; }
|
||
.cal-title { font-size: 1rem; }
|
||
.view-switcher button { padding: 6px 10px; font-size: .8rem; }
|
||
.chat-window { width: calc(100vw - 20px); right: 10px; bottom: 90px; height: 60vh; }
|
||
.month-cell { min-height: 70px; }
|
||
.month-event .event-title { display: none; }
|
||
.week-body { max-height: 400px; }
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="page">
|
||
<a href="/" class="back-link">← На главную</a>
|
||
<h1>Расписание</h1>
|
||
|
||
{% if status %}
|
||
<div class="status-banner status-{{ status.status }}">
|
||
<div class="status-title">
|
||
{% if status.status == 'on_place' %}На месте
|
||
{% elif status.status == 'delayed' %}Опаздываю на {{ status.delay_minutes }} мин
|
||
{% elif status.status == 'away' %}Вне вуза
|
||
{% elif status.status == 'cancelled' %}Отменено
|
||
{% else %}Свободен / Вне расписания{% endif %}
|
||
</div>
|
||
{% if status.message %}<div class="status-message">{{ status.message }}</div>{% endif %}
|
||
</div>
|
||
{% endif %}
|
||
|
||
<div class="calendar">
|
||
<div class="cal-toolbar">
|
||
<div class="cal-nav">
|
||
<button class="btn-icon" id="cal-prev">←</button>
|
||
<button class="btn-secondary" id="cal-today">Сегодня</button>
|
||
<button class="btn-icon" id="cal-next">→</button>
|
||
</div>
|
||
<h2 class="cal-title" id="cal-title">—</h2>
|
||
<div class="view-switcher">
|
||
<button data-view="day">День</button>
|
||
<button data-view="week" class="active">Неделя</button>
|
||
<button data-view="month">Месяц</button>
|
||
</div>
|
||
</div>
|
||
<div class="cal-filters" id="cal-filters">
|
||
<label class="filter-chip"><input type="checkbox" data-type="lecture" checked><span>📚 Лекции</span></label>
|
||
<label class="filter-chip"><input type="checkbox" data-type="workshop" checked><span>🛠 Мастер-классы</span></label>
|
||
<label class="filter-chip"><input type="checkbox" data-type="meeting" checked><span>👥 Встречи</span></label>
|
||
<label class="filter-chip"><input type="checkbox" data-type="personal" checked><span>🔒 Личное</span></label>
|
||
</div>
|
||
<div class="cal-body" id="cal-body"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="event-popup" class="event-popup hidden">
|
||
<span class="popup-close" id="popup-close">✕</span>
|
||
<h3 id="popup-title"></h3>
|
||
<div id="popup-meta"></div>
|
||
<div id="popup-desc"></div>
|
||
</div>
|
||
|
||
<button id="chat-toggle" class="chat-fab" aria-label="Открыть чат">💬</button>
|
||
<div id="chat-window" class="chat-window hidden">
|
||
<div class="chat-header">
|
||
<span>🤖 Ассистент</span>
|
||
<button id="chat-close" class="chat-close">✕</button>
|
||
</div>
|
||
<div class="chat-messages" id="chat-messages">
|
||
<div class="msg bot">Привет! Спроси меня: «Где сейчас?», «Когда следующая пара?», «Что на неделе?»</div>
|
||
</div>
|
||
<form id="chat-form" class="chat-form">
|
||
<input id="chat-input" placeholder="Ваш вопрос..." autocomplete="off">
|
||
<button type="submit">→</button>
|
||
</form>
|
||
</div>
|
||
|
||
<script>
|
||
const EVENTS = {{ events_json|tojson }};
|
||
const MSK_OFFSET_MS = 3 * 60 * 60 * 1000;
|
||
const state = { view: 'week', cursor: new Date(), filters: new Set(['lecture','workshop','meeting','personal']) };
|
||
|
||
function inMSK(d) { return new Date(d.getTime() + MSK_OFFSET_MS); }
|
||
function fromMSK(y, m, d, h, mn) { return new Date(Date.UTC(y, m, d, h||0, mn||0) - MSK_OFFSET_MS); }
|
||
function mskParts(d) {
|
||
const m = inMSK(d);
|
||
return { year: m.getUTCFullYear(), month: m.getUTCMonth(), day: m.getUTCDate(),
|
||
hour: m.getUTCHours(), minute: m.getUTCMinutes(), weekday: m.getUTCDay() };
|
||
}
|
||
function startOfMSKDay(d) { const p = mskParts(d); return fromMSK(p.year, p.month, p.day); }
|
||
function startOfMSKWeek(d) {
|
||
const sod = startOfMSKDay(d);
|
||
const dow = mskParts(sod).weekday;
|
||
return new Date(sod.getTime() - ((dow + 6) % 7) * 86400000);
|
||
}
|
||
function startOfMSKMonth(d) { const p = mskParts(d); return fromMSK(p.year, p.month, 1); }
|
||
function addDays(d, n) { return new Date(d.getTime() + n * 86400000); }
|
||
function addMonths(d, n) {
|
||
const p = mskParts(d);
|
||
const target = new Date(Date.UTC(p.year, p.month + n, 1));
|
||
return fromMSK(target.getUTCFullYear(), target.getUTCMonth(), 1);
|
||
}
|
||
function fmtTime(d) { const p = mskParts(d); return String(p.hour).padStart(2,'0')+':'+String(p.minute).padStart(2,'0'); }
|
||
function fmtDate(d) { const p = mskParts(d); return String(p.day).padStart(2,'0')+'.'+String(p.month+1).padStart(2,'0'); }
|
||
function fmtDateFull(d) {
|
||
const p = mskParts(d);
|
||
const days = ['вс','пн','вт','ср','чт','пт','сб'];
|
||
return days[p.weekday] + ', ' + String(p.day).padStart(2,'0') + '.' + String(p.month+1).padStart(2,'0') + '.' + p.year;
|
||
}
|
||
const MONTHS = ['Январь','Февраль','Март','Апрель','Май','Июнь','Июль','Август','Сентябрь','Октябрь','Ноябрь','Декабрь'];
|
||
const MONTHS_GEN = ['января','февраля','марта','апреля','мая','июня','июля','августа','сентября','октября','ноября','декабря'];
|
||
|
||
function getFilteredEvents() {
|
||
return EVENTS.filter(e => state.filters.has(e.type))
|
||
.map(e => Object.assign({}, e, { _start: new Date(e.start), _end: new Date(e.end) }));
|
||
}
|
||
function eventsForDay(dayStart, dayEnd) {
|
||
return getFilteredEvents().filter(e => e._start < dayEnd && e._end > dayStart).sort((a,b) => a._start - b._start);
|
||
}
|
||
function layoutOverlaps(events, dayStart, dayEnd) {
|
||
const clipped = events.map(e => {
|
||
const s = e._start < dayStart ? dayStart : e._start;
|
||
const en = e._end > dayEnd ? dayEnd : e._end;
|
||
return Object.assign({}, e, { _clipStart: s, _clipEnd: en });
|
||
});
|
||
const cols = []; const placed = [];
|
||
for (const ev of clipped) {
|
||
let col = -1;
|
||
for (let c = 0; c < cols.length; c++) {
|
||
if (!cols[c].some(o => ev._clipStart < o._clipEnd && ev._clipEnd > o._clipStart)) { col = c; break; }
|
||
}
|
||
if (col === -1) { col = cols.length; cols.push([]); }
|
||
cols[col].push(ev);
|
||
placed.push({ event: ev, col: col });
|
||
}
|
||
const total = cols.length || 1;
|
||
return placed.map(p => Object.assign({}, p, { leftPct: (p.col/total)*100, widthPct: (1/total)*100 }));
|
||
}
|
||
function escapeHtml(s) {
|
||
return String(s || '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||
}
|
||
|
||
function render() {
|
||
const body = document.getElementById('cal-body');
|
||
document.getElementById('cal-title').textContent = getTitle();
|
||
if (state.view === 'month') renderMonth(body);
|
||
else if (state.view === 'week') renderWeek(body);
|
||
else renderDay(body);
|
||
}
|
||
function getTitle() {
|
||
const p = mskParts(state.cursor);
|
||
if (state.view === 'month') return MONTHS[p.month] + ' ' + p.year;
|
||
if (state.view === 'week') {
|
||
const ws = startOfMSKWeek(state.cursor); const we = addDays(ws, 6);
|
||
const wp = mskParts(ws); const wep = mskParts(we);
|
||
if (wp.month === wep.month) return wp.day + '–' + wep.day + ' ' + MONTHS_GEN[wp.month] + ' ' + wp.year;
|
||
return wp.day + ' ' + MONTHS_GEN[wp.month] + ' – ' + wep.day + ' ' + MONTHS_GEN[wep.month] + ' ' + wep.year;
|
||
}
|
||
return fmtDateFull(state.cursor);
|
||
}
|
||
|
||
function renderMonth(container) {
|
||
const first = startOfMSKMonth(state.cursor);
|
||
const gridStart = startOfMSKWeek(first);
|
||
const days = ['Пн','Вт','Ср','Чт','Пт','Сб','Вс'];
|
||
const today = startOfMSKDay(new Date());
|
||
let html = '<div class="month-view"><div class="month-header">';
|
||
for (const d of days) html += '<div class="month-header-cell">' + d + '</div>';
|
||
html += '</div><div class="month-grid">';
|
||
for (let i = 0; i < 42; i++) {
|
||
const cs = addDays(gridStart, i);
|
||
const ce = addDays(cs, 1);
|
||
const p = mskParts(cs);
|
||
const isOther = p.month !== mskParts(first).month;
|
||
const isToday = cs.getTime() === today.getTime();
|
||
const evs = eventsForDay(cs, ce);
|
||
html += '<div class="month-cell' + (isOther ? ' other-month' : '') + (isToday ? ' today' : '') + '">';
|
||
html += '<div class="month-day-num">' + p.day + '</div>';
|
||
for (const ev of evs.slice(0, 3)) {
|
||
html += '<div class="month-event event-type-' + ev.type + '" data-event-id="' + ev.id + '"><span class="event-time">' + fmtTime(ev._start) + '</span> <span class="event-title">' + escapeHtml(ev.title) + '</span></div>';
|
||
}
|
||
if (evs.length > 3) html += '<div class="month-more">+' + (evs.length - 3) + ' ещё</div>';
|
||
html += '</div>';
|
||
}
|
||
html += '</div></div>';
|
||
container.innerHTML = html;
|
||
attachEventHandlers();
|
||
}
|
||
|
||
function renderWeek(container) {
|
||
const weekStart = startOfMSKWeek(state.cursor);
|
||
const today = startOfMSKDay(new Date());
|
||
let html = '<div class="week-view"><div class="week-header"><div></div>';
|
||
const names = ['Пн','Вт','Ср','Чт','Пт','Сб','Вс'];
|
||
for (let i = 0; i < 7; i++) {
|
||
const ds = addDays(weekStart, i);
|
||
const p = mskParts(ds);
|
||
const isToday = ds.getTime() === today.getTime();
|
||
html += '<div class="week-day-header' + (isToday ? ' today' : '') + '"><div class="day-name">' + names[i] + '</div><div class="day-num">' + p.day + '</div></div>';
|
||
}
|
||
html += '</div><div class="week-body"><div class="time-gutter-col">';
|
||
for (let h = 0; h < 24; h++) html += '<div class="hour-label">' + String(h).padStart(2,'0') + ':00</div>';
|
||
html += '</div>';
|
||
for (let i = 0; i < 7; i++) {
|
||
const ds = addDays(weekStart, i);
|
||
const de = addDays(ds, 1);
|
||
const evs = eventsForDay(ds, de);
|
||
const laid = layoutOverlaps(evs, ds, de);
|
||
html += '<div class="day-col">';
|
||
for (let h = 0; h < 24; h++) html += '<div class="hour-cell"></div>';
|
||
const dayMs = 86400000;
|
||
for (const p of laid) {
|
||
const top = ((p.event._clipStart - ds) / dayMs) * 100;
|
||
const height = Math.max(((p.event._clipEnd - p.event._clipStart) / dayMs) * 100, 1.5);
|
||
html += '<div class="week-event event-type-' + p.event.type + '" style="top:' + top + '%;height:' + height + '%;left:' + p.leftPct + '%;width:calc(' + p.widthPct + '% - 2px);" data-event-id="' + p.event.id + '">';
|
||
html += '<div class="we-time">' + fmtTime(p.event._start) + '–' + fmtTime(p.event._end) + '</div>';
|
||
html += '<div class="we-title">' + escapeHtml(p.event.title) + '</div>';
|
||
if (p.event.location) html += '<div class="we-loc">📍 ' + escapeHtml(p.event.location) + '</div>';
|
||
html += '</div>';
|
||
}
|
||
if (ds.getTime() === today.getTime()) {
|
||
const now = new Date();
|
||
if (now >= ds && now < de) {
|
||
const nowFrac = (now - ds) / dayMs;
|
||
html += '<div class="now-line" style="top:' + (nowFrac * 100) + '%;"></div>';
|
||
}
|
||
}
|
||
html += '</div>';
|
||
}
|
||
html += '</div></div>';
|
||
container.innerHTML = html;
|
||
attachEventHandlers();
|
||
if (!container.dataset.scrolled) {
|
||
const nowParts = mskParts(new Date());
|
||
const wb = container.querySelector('.week-body');
|
||
if (wb) wb.scrollTop = Math.max(0, (nowParts.hour - 1) * 40);
|
||
container.dataset.scrolled = '1';
|
||
}
|
||
}
|
||
|
||
function renderDay(container) {
|
||
const ds = startOfMSKDay(state.cursor);
|
||
const de = addDays(ds, 1);
|
||
const evs = eventsForDay(ds, de);
|
||
const laid = layoutOverlaps(evs, ds, de);
|
||
let html = '<div class="day-view"><div class="day-body"><div class="time-gutter-col">';
|
||
for (let h = 0; h < 24; h++) html += '<div class="hour-label">' + String(h).padStart(2,'0') + ':00</div>';
|
||
html += '</div><div class="day-col day-col-single">';
|
||
for (let h = 0; h < 24; h++) html += '<div class="hour-cell"></div>';
|
||
const dayMs = 86400000;
|
||
for (const p of laid) {
|
||
const top = ((p.event._clipStart - ds) / dayMs) * 100;
|
||
const height = Math.max(((p.event._clipEnd - p.event._clipStart) / dayMs) * 100, 1.5);
|
||
html += '<div class="week-event event-type-' + p.event.type + '" style="top:' + top + '%;height:' + height + '%;left:' + p.leftPct + '%;width:calc(' + p.widthPct + '% - 8px);" data-event-id="' + p.event.id + '">';
|
||
html += '<div class="we-time">' + fmtTime(p.event._start) + '–' + fmtTime(p.event._end) + '</div>';
|
||
html += '<div class="we-title">' + escapeHtml(p.event.title) + '</div>';
|
||
if (p.event.location) html += '<div class="we-loc">📍 ' + escapeHtml(p.event.location) + '</div>';
|
||
if (p.event.description) html += '<div class="we-desc">' + escapeHtml(p.event.description) + '</div>';
|
||
html += '</div>';
|
||
}
|
||
const now = new Date();
|
||
if (now >= ds && now < de) {
|
||
const nowFrac = (now - ds) / dayMs;
|
||
html += '<div class="now-line" style="top:' + (nowFrac * 100) + '%;"></div>';
|
||
}
|
||
html += '</div></div></div>';
|
||
container.innerHTML = html;
|
||
attachEventHandlers();
|
||
}
|
||
|
||
function attachEventHandlers() {
|
||
document.querySelectorAll('[data-event-id]').forEach(el => {
|
||
el.addEventListener('click', e => { e.stopPropagation(); showEventPopup(parseInt(el.dataset.eventId)); });
|
||
});
|
||
}
|
||
function showEventPopup(id) {
|
||
const ev = EVENTS.find(e => e.id === id);
|
||
if (!ev) return;
|
||
const start = new Date(ev.start); const end = new Date(ev.end);
|
||
const types = { lecture:'📚 Лекция', workshop:'🛠 Мастер-класс', meeting:'👥 Встреча', personal:'🔒 Личное' };
|
||
document.getElementById('popup-title').textContent = ev.title;
|
||
let meta = '<div>' + (types[ev.type] || ev.type) + '</div>';
|
||
meta += '<div>🕐 ' + fmtDate(start) + ' ' + fmtTime(start) + ' – ' + fmtTime(end) + '</div>';
|
||
if (ev.location) meta += '<div>📍 ' + escapeHtml(ev.location) + '</div>';
|
||
document.getElementById('popup-meta').innerHTML = meta;
|
||
document.getElementById('popup-desc').textContent = ev.description || '';
|
||
document.getElementById('event-popup').classList.remove('hidden');
|
||
}
|
||
|
||
document.getElementById('cal-prev').addEventListener('click', () => {
|
||
if (state.view === 'month') state.cursor = addMonths(state.cursor, -1);
|
||
else if (state.view === 'week') state.cursor = addDays(state.cursor, -7);
|
||
else state.cursor = addDays(state.cursor, -1);
|
||
render();
|
||
});
|
||
document.getElementById('cal-next').addEventListener('click', () => {
|
||
if (state.view === 'month') state.cursor = addMonths(state.cursor, 1);
|
||
else if (state.view === 'week') state.cursor = addDays(state.cursor, 7);
|
||
else state.cursor = addDays(state.cursor, 1);
|
||
render();
|
||
});
|
||
document.getElementById('cal-today').addEventListener('click', () => { state.cursor = new Date(); render(); });
|
||
document.querySelectorAll('.view-switcher button').forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
document.querySelectorAll('.view-switcher button').forEach(b => b.classList.remove('active'));
|
||
btn.classList.add('active');
|
||
state.view = btn.dataset.view;
|
||
render();
|
||
});
|
||
});
|
||
document.querySelectorAll('#cal-filters input').forEach(cb => {
|
||
cb.addEventListener('change', () => {
|
||
if (cb.checked) state.filters.add(cb.dataset.type);
|
||
else state.filters.delete(cb.dataset.type);
|
||
render();
|
||
});
|
||
});
|
||
|
||
document.getElementById('popup-close').addEventListener('click', () => {
|
||
document.getElementById('event-popup').classList.add('hidden');
|
||
});
|
||
document.addEventListener('click', e => {
|
||
const popup = document.getElementById('event-popup');
|
||
if (!popup.classList.contains('hidden') && !popup.contains(e.target) && !e.target.closest('[data-event-id]')) {
|
||
popup.classList.add('hidden');
|
||
}
|
||
});
|
||
|
||
const chatWindow = document.getElementById('chat-window');
|
||
document.getElementById('chat-toggle').addEventListener('click', () => chatWindow.classList.toggle('hidden'));
|
||
document.getElementById('chat-close').addEventListener('click', () => chatWindow.classList.add('hidden'));
|
||
document.getElementById('chat-form').addEventListener('submit', async e => {
|
||
e.preventDefault();
|
||
const input = document.getElementById('chat-input');
|
||
const text = input.value.trim(); if (!text) return;
|
||
addMsg(text, 'user'); input.value = '';
|
||
const loader = addMsg('...', 'bot');
|
||
try {
|
||
const res = await fetch('/api/assistant', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ message: text }),
|
||
});
|
||
const data = await res.json();
|
||
loader.textContent = data.reply || 'Ошибка';
|
||
} catch (e) { loader.textContent = 'Ошибка соединения'; }
|
||
});
|
||
function addMsg(text, sender) {
|
||
const div = document.createElement('div');
|
||
div.className = 'msg ' + sender; div.textContent = text;
|
||
const c = document.getElementById('chat-messages');
|
||
c.appendChild(div); c.scrollTop = c.scrollHeight;
|
||
return div;
|
||
}
|
||
|
||
render();
|
||
</script>
|
||
</body>
|
||
</html>
|