1425 lines
75 KiB
Plaintext
1425 lines
75 KiB
Plaintext
from playwright.sync_api import sync_playwright
|
||
from pathlib import Path
|
||
from datetime import datetime, timedelta
|
||
from logging.handlers import RotatingFileHandler
|
||
import json
|
||
import threading
|
||
import signal
|
||
import sys
|
||
import os
|
||
import logging
|
||
import urllib.request
|
||
import urllib.parse
|
||
import ssl
|
||
from flask import Flask, jsonify, Response, request
|
||
|
||
os.environ.setdefault("DISPLAY", ":99")
|
||
|
||
BASE_DIR = Path(__file__).resolve().parent
|
||
SESSION_FILE = BASE_DIR / "session.json"
|
||
SCHEDULE_FILE = BASE_DIR / "schedule.json"
|
||
LOG_FILE = BASE_DIR / "app.log"
|
||
CREDENTIALS_FILE = BASE_DIR / ".journal_credentials"
|
||
|
||
LOGIN_URL = "https://journal.top-academy.ru/ru/auth/login/index"
|
||
SCHEDULE_URL = "https://journal.top-academy.ru/ru/main/schedule/page"
|
||
API_BASE = "https://journal.top-academy.ru/api/v2/schedule/operations/get-month"
|
||
|
||
LOG_FORMAT = "%(asctime)s | %(levelname)-7s | %(message)s"
|
||
LOG_DATE = "%Y-%m-%d %H:%M:%S"
|
||
|
||
log = logging.getLogger("schedule")
|
||
log.setLevel(logging.DEBUG)
|
||
|
||
_console = logging.StreamHandler(sys.stdout)
|
||
_console.setLevel(logging.INFO)
|
||
_console.setFormatter(logging.Formatter(LOG_FORMAT, datefmt=LOG_DATE))
|
||
log.addHandler(_console)
|
||
|
||
_file = RotatingFileHandler(
|
||
str(LOG_FILE), maxBytes=5 * 1024 * 1024, backupCount=5, encoding="utf-8",
|
||
)
|
||
_file.setLevel(logging.DEBUG)
|
||
_file.setFormatter(logging.Formatter(LOG_FORMAT, datefmt=LOG_DATE))
|
||
log.addHandler(_file)
|
||
|
||
app = Flask(__name__)
|
||
|
||
fetch_status = {
|
||
"running": False,
|
||
"message": "",
|
||
"last_error": "",
|
||
"last_update": "",
|
||
}
|
||
status_lock = threading.Lock()
|
||
browser_lock = threading.Lock()
|
||
shutdown_event = threading.Event()
|
||
|
||
activity_log = []
|
||
activity_lock = threading.Lock()
|
||
|
||
|
||
def log_event(level, text):
|
||
"""Append a human-readable event shown on the site (кириллица)."""
|
||
try:
|
||
with activity_lock:
|
||
stamp = datetime.now().strftime("%d.%m %H:%M:%S")
|
||
activity_log.append({"time": stamp, "level": level, "text": text})
|
||
del activity_log[:-60]
|
||
except Exception as e:
|
||
log.error("Failed to log event: %s", e)
|
||
|
||
|
||
def set_status(**kwargs):
|
||
try:
|
||
with status_lock:
|
||
fetch_status.update(kwargs)
|
||
log.debug("Status updated: %s", kwargs)
|
||
except Exception as e:
|
||
log.error("Failed to set status: %s", e)
|
||
|
||
|
||
def safe_json_load(path):
|
||
try:
|
||
if not path.exists():
|
||
return []
|
||
with open(path, "r", encoding="utf-8") as f:
|
||
data = json.load(f)
|
||
log.debug("Loaded %d items from %s", len(data), path.name)
|
||
return data
|
||
except json.JSONDecodeError as e:
|
||
log.error("Corrupt JSON in %s: %s", path.name, e)
|
||
backup = path.with_suffix(".json.bak")
|
||
try:
|
||
path.rename(backup)
|
||
except Exception:
|
||
pass
|
||
return []
|
||
except Exception as e:
|
||
log.error("Failed to read %s: %s", path.name, e)
|
||
return []
|
||
|
||
|
||
def safe_json_save(path, data):
|
||
try:
|
||
tmp = path.with_suffix(".json.tmp")
|
||
with open(tmp, "w", encoding="utf-8") as f:
|
||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||
tmp.replace(path)
|
||
log.debug("Saved %d items to %s", len(data), path.name)
|
||
except Exception as e:
|
||
log.error("Failed to save %s: %s", path.name, e)
|
||
raise
|
||
|
||
|
||
def get_schedule():
|
||
log.info("Starting schedule fetch...")
|
||
if not browser_lock.acquire(timeout=5):
|
||
raise RuntimeError("Another update is already running.")
|
||
try:
|
||
return _get_schedule_inner()
|
||
finally:
|
||
browser_lock.release()
|
||
log.info("Lock released.")
|
||
|
||
|
||
def _merge_with_previous(new_items):
|
||
"""Merge fresh lessons with the last known cache.
|
||
|
||
Fresh items win for the same (date, lesson); older items that are absent
|
||
from the fresh fetch are kept as a fallback so already-published days are
|
||
not lost when the journal temporarily stops returning them.
|
||
"""
|
||
old_items = safe_json_load(SCHEDULE_FILE) or []
|
||
old_keys = {(x.get("date"), x.get("lesson")) for x in old_items}
|
||
new_keys = {(x.get("date"), x.get("lesson")) for x in new_items}
|
||
dropped = sorted({k for k in old_keys - new_keys})
|
||
added = sorted({k for k in new_keys - old_keys})
|
||
kept = [x for x in old_items if (x.get("date"), x.get("lesson")) not in new_keys]
|
||
merged = new_items + kept
|
||
if dropped:
|
||
dates_dropped = sorted({d for d, _ in dropped})
|
||
log_event("warning", f"Журнал вернул меньше: убраны даты {', '.join(dates_dropped)}, оставлены из кэша ({len(dropped)} ур.)")
|
||
if added:
|
||
dates_added = sorted({d for d, _ in added})
|
||
log_event("ok", f"Обнаружены новые занятия: {', '.join(dates_added)}")
|
||
return sorted(merged, key=lambda x: (x.get("date", ""), x.get("lesson", 0)))
|
||
|
||
|
||
def _load_cookies():
|
||
if not SESSION_FILE.exists():
|
||
return {}
|
||
try:
|
||
with open(SESSION_FILE, "r", encoding="utf-8") as f:
|
||
state = json.load(f)
|
||
cookies = {}
|
||
for c in state.get("cookies", []):
|
||
if "top-academy.ru" in c.get("domain", ""):
|
||
cookies[c["name"]] = c["value"]
|
||
return cookies
|
||
except Exception as e:
|
||
log.error("Failed to load cookies: %s", e)
|
||
return {}
|
||
|
||
|
||
def _fetch_schedule_direct():
|
||
cookies = _load_cookies()
|
||
if not cookies:
|
||
return None
|
||
|
||
cookie_header = "; ".join(f"{k}={v}" for k, v in cookies.items())
|
||
now = datetime.now()
|
||
months = []
|
||
for offset in range(-1, 2):
|
||
d = now + timedelta(days=offset * 30)
|
||
months.append(d.strftime("%Y-%m"))
|
||
|
||
all_data = []
|
||
ctx = ssl.create_default_context()
|
||
ctx.check_hostname = False
|
||
ctx.verify_mode = ssl.CERT_NONE
|
||
|
||
for month in sorted(set(months)):
|
||
url = f"{API_BASE}?month={month}"
|
||
try:
|
||
req = urllib.request.Request(url)
|
||
req.add_header("Cookie", cookie_header)
|
||
req.add_header("User-Agent", "Mozilla/5.0")
|
||
req.add_header("Accept", "application/json")
|
||
req.add_header("Referer", SCHEDULE_URL)
|
||
resp = urllib.request.urlopen(req, timeout=15, context=ctx)
|
||
data = json.loads(resp.read().decode("utf-8"))
|
||
if isinstance(data, list) and len(data) > 0:
|
||
all_data.extend(data)
|
||
log.info("API month %s: %d lessons.", month, len(data))
|
||
except urllib.error.HTTPError as e:
|
||
if e.code in (401, 403):
|
||
log.warning("Auth failed for month %s (HTTP %d).", month, e.code)
|
||
return None
|
||
except Exception as e:
|
||
log.warning("Request error for month %s: %s", month, e)
|
||
|
||
if not all_data:
|
||
return None
|
||
return sorted(all_data, key=lambda x: (x.get("date", ""), x.get("lesson", 0)))
|
||
|
||
|
||
def _fetch_schedule_browser():
|
||
log.info("Falling back to browser...")
|
||
with sync_playwright() as p:
|
||
browser = p.chromium.launch(headless=True, args=["--no-sandbox", "--disable-blink-features=AutomationControlled"])
|
||
try:
|
||
if SESSION_FILE.exists():
|
||
context = browser.new_context(
|
||
storage_state=str(SESSION_FILE),
|
||
user_agent="Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
|
||
)
|
||
set_status(message="Updating via browser...")
|
||
else:
|
||
set_status(message="No session available.")
|
||
browser.close()
|
||
return None
|
||
|
||
schedule_data = []
|
||
seen = set()
|
||
page = context.pages[0] if context.pages else context.new_page()
|
||
|
||
def handle_response(response):
|
||
try:
|
||
if "/api/v2/schedule/operations/get-month" not in response.url:
|
||
return
|
||
if response.status != 200:
|
||
return
|
||
data = response.json()
|
||
if isinstance(data, list):
|
||
for item in data:
|
||
key = (item.get("date"), item.get("lesson"))
|
||
if key not in seen:
|
||
seen.add(key)
|
||
schedule_data.append(item)
|
||
except Exception:
|
||
pass
|
||
|
||
page.on("response", handle_response)
|
||
page.goto(SCHEDULE_URL)
|
||
page.wait_for_load_state("networkidle")
|
||
page.wait_for_timeout(5000)
|
||
finally:
|
||
browser.close()
|
||
|
||
if not schedule_data:
|
||
return None
|
||
return sorted(schedule_data, key=lambda x: (x.get("date", ""), x.get("lesson", 0)))
|
||
|
||
|
||
def _web_relogin():
|
||
"""Renew the journal session using protected perms-600 credentials file."""
|
||
if not CREDENTIALS_FILE.exists():
|
||
log.warning("No credentials file; relogin skipped.")
|
||
return False
|
||
try:
|
||
with open(CREDENTIALS_FILE, "r", encoding="utf-8") as f:
|
||
lines = f.read().splitlines()
|
||
if len(lines) < 2:
|
||
log.warning("Credentials file malformed.")
|
||
return False
|
||
username, password = lines[0].strip(), lines[1].strip()
|
||
if not username or not password:
|
||
return False
|
||
except Exception as e:
|
||
log.error("Failed to read credentials: %s", e)
|
||
return False
|
||
|
||
try:
|
||
log.info("Relogin: opening login page...")
|
||
with sync_playwright() as p:
|
||
browser = p.chromium.launch(
|
||
headless=True,
|
||
args=["--no-sandbox", "--disable-blink-features=AutomationControlled"],
|
||
)
|
||
context = browser.new_context()
|
||
page = context.new_page()
|
||
page.goto(LOGIN_URL)
|
||
page.wait_for_load_state("networkidle")
|
||
page.wait_for_timeout(2000)
|
||
|
||
try:
|
||
page.fill('input[name="username"]', username)
|
||
page.fill('input[name="password"]', password)
|
||
page.wait_for_timeout(500)
|
||
page.click('button[type="submit"], input[type="submit"]')
|
||
page.wait_for_load_state("networkidle")
|
||
page.wait_for_timeout(4000)
|
||
except Exception as e:
|
||
log.warning("Relogin form step error: %s", e)
|
||
|
||
if "login" not in page.url:
|
||
context.storage_state(path=str(SESSION_FILE))
|
||
log.info("Relogin successful. Session renewed and saved.")
|
||
return True
|
||
else:
|
||
log.warning("Relogin failed: still on login page.")
|
||
return False
|
||
except Exception as e:
|
||
log.error("Relogin error: %s", e)
|
||
return False
|
||
|
||
|
||
def _retry_after_relogin():
|
||
log_event("warning", "Сессия журнала устарела — пытаюсь войти заново (автоматически)...")
|
||
if not _web_relogin():
|
||
log_event("error", "Не удалось заново войти в журнал. Проверь аккаунт.")
|
||
return None
|
||
log_event("info", "Вход в журнал выполнен заново. Повторяю загрузку расписания...")
|
||
result = _fetch_schedule_direct()
|
||
if result:
|
||
merged = _merge_with_previous(result)
|
||
safe_json_save(SCHEDULE_FILE, merged)
|
||
set_status(
|
||
last_update=datetime.now().strftime("%H:%M:%S"),
|
||
message=f"Готово: {len(merged)} занятий (переподключение).",
|
||
)
|
||
log.info("Fetch complete (relogin+http): %d lessons.", len(merged))
|
||
log_event("ok", f"Расписание обновлено после повторного входа: {len(merged)} занятий (данные журнала: {len(result)}).")
|
||
return merged
|
||
result = _fetch_schedule_browser()
|
||
if result:
|
||
merged = _merge_with_previous(result)
|
||
safe_json_save(SCHEDULE_FILE, merged)
|
||
set_status(
|
||
last_update=datetime.now().strftime("%H:%M:%S"),
|
||
message=f"Готово: {len(merged)} занятий (браузер).",
|
||
)
|
||
log.info("Fetch complete (relogin+browser): %d lessons.", len(merged))
|
||
log_event("ok", f"Расписание обновлено после повторного входа (браузер): {len(merged)} занятий (данные журнала: {len(result)}).")
|
||
return merged
|
||
log_event("error", "После повторного входа расписание так и не получилось загрузить.")
|
||
return None
|
||
|
||
|
||
def _get_schedule_inner():
|
||
set_status(message="Получаю расписание из журнала...")
|
||
log_event("info", "Начинаю обновление расписания...")
|
||
result = _fetch_schedule_direct()
|
||
if result:
|
||
merged = _merge_with_previous(result)
|
||
safe_json_save(SCHEDULE_FILE, merged)
|
||
set_status(
|
||
last_update=datetime.now().strftime("%H:%M:%S"),
|
||
message=f"Готово: {len(merged)} занятий.",
|
||
)
|
||
log.info("Fetch complete (HTTP): %d lessons.", len(merged))
|
||
log_event("ok", f"Журнал ответил (напрямую): {len(result)} занятий. Показано: {len(merged)}.")
|
||
return merged
|
||
|
||
log.info("Direct fetch failed, trying browser...")
|
||
log_event("warning", "Прямое соединение с журналом не сработало — открываю журнал в браузере...")
|
||
if not SESSION_FILE.exists():
|
||
log.info("No session file; going straight to relogin...")
|
||
return _retry_after_relogin()
|
||
|
||
result = _fetch_schedule_browser()
|
||
if result:
|
||
merged = _merge_with_previous(result)
|
||
safe_json_save(SCHEDULE_FILE, merged)
|
||
set_status(
|
||
last_update=datetime.now().strftime("%H:%M:%S"),
|
||
message=f"Готово: {len(merged)} занятий (браузер).",
|
||
)
|
||
log.info("Fetch complete (browser): %d lessons.", len(merged))
|
||
log_event("ok", f"Журнал открылся в браузере: {len(result)} занятий. Показано: {len(merged)}.")
|
||
return merged
|
||
|
||
log_event("error", "Браузер тоже не смог получить расписание из журнала.")
|
||
log.warning("Session likely expired; attempting relogin...")
|
||
return _retry_after_relogin()
|
||
|
||
|
||
def run_update():
|
||
log.info("Update thread started.")
|
||
try:
|
||
set_status(running=True, last_error="")
|
||
for attempt in range(1, 4):
|
||
try:
|
||
log.info("Attempt %d/3", attempt)
|
||
get_schedule()
|
||
break
|
||
except Exception as e:
|
||
log.warning("Attempt %d failed: %s", attempt, e, exc_info=True)
|
||
log_event("error", f"Попытка {attempt} не удалась: {e}")
|
||
if attempt < 3:
|
||
set_status(message=f"Попытка {attempt} не удалась, повтор...")
|
||
except Exception as e:
|
||
set_status(message=f"Ошибка: {e}", last_error=str(e))
|
||
log.error("All update attempts failed.", exc_info=True)
|
||
log_event("error", f"Обновление окончательно не удалось: {e}")
|
||
finally:
|
||
set_status(running=False)
|
||
log.info("Update thread finished.")
|
||
|
||
|
||
@app.route("/")
|
||
def index():
|
||
try:
|
||
return Response(INDEX_HTML, mimetype="text/html")
|
||
except Exception as e:
|
||
log.error("Failed to serve index: %s", e)
|
||
return "Internal Server Error", 500
|
||
|
||
|
||
@app.route("/api/schedule")
|
||
def api_schedule():
|
||
try:
|
||
data = safe_json_load(SCHEDULE_FILE)
|
||
return jsonify(sorted(data, key=lambda x: (x.get("date", ""), x.get("lesson", 0))))
|
||
except Exception as e:
|
||
log.error("API /schedule error: %s", e)
|
||
return jsonify([])
|
||
|
||
|
||
@app.route("/api/update", methods=["POST"])
|
||
def api_update():
|
||
try:
|
||
with status_lock:
|
||
if fetch_status["running"]:
|
||
return jsonify({"ok": False, "message": "Already updating"})
|
||
thread = threading.Thread(target=run_update, daemon=True)
|
||
thread.start()
|
||
log.info("Update triggered by user.")
|
||
return jsonify({"ok": True, "message": "Update started"})
|
||
except Exception as e:
|
||
log.error("API /update error: %s", e)
|
||
return jsonify({"ok": False, "message": str(e)})
|
||
|
||
|
||
@app.route("/api/status")
|
||
def api_status():
|
||
try:
|
||
with status_lock:
|
||
return jsonify(dict(fetch_status))
|
||
except Exception as e:
|
||
return jsonify({"running": False, "message": "", "last_error": ""})
|
||
|
||
|
||
@app.route("/api/logs")
|
||
def api_logs():
|
||
try:
|
||
with activity_lock:
|
||
return jsonify(list(activity_log[-30:]))
|
||
except Exception:
|
||
return jsonify([])
|
||
|
||
|
||
@app.errorhandler(404)
|
||
def not_found(e):
|
||
return jsonify({"error": "Not found"}), 404
|
||
|
||
|
||
@app.errorhandler(500)
|
||
def server_error(e):
|
||
log.error("500 error: %s", e)
|
||
return jsonify({"error": "Internal server error"}), 500
|
||
|
||
|
||
INDEX_HTML = r"""<!DOCTYPE html>
|
||
<html lang="ru">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||
<title>TRIRON — Расписание</title>
|
||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||
<style>
|
||
:root{
|
||
--bg:#edf0f6;--surface:#ffffff;--surface-2:#eef1f7;--surface-3:#e2e7f0;
|
||
--border:rgba(20,26,45,.09);--border-strong:rgba(20,26,45,.15);
|
||
--text:#182031;--text-2:#515a70;--text-3:#8b93a8;
|
||
--accent:#4f46e5;--accent-dark:#4338ca;--accent-soft:rgba(79,70,229,.09);--accent-softer:rgba(79,70,229,.05);
|
||
--cyan:#0891b2;--cyan-soft:rgba(8,145,178,.08);
|
||
--success:#15803d;--success-soft:rgba(21,128,61,.09);
|
||
--warn:#b45309;--warn-soft:rgba(180,83,9,.1);
|
||
--danger:#dc2626;--danger-soft:rgba(220,38,38,.08);
|
||
--shadow-sm:0 1px 2px rgba(20,26,45,.05),0 1px 3px rgba(20,26,45,.07);
|
||
--shadow-md:0 2px 4px rgba(20,26,45,.06),0 12px 30px rgba(20,26,45,.08);
|
||
--shadow-lg:0 4px 10px rgba(20,26,45,.07),0 20px 48px rgba(20,26,45,.12);
|
||
--sidebar-w:242px;--radius:14px;--radius-sm:9px;--radius-xs:6px;
|
||
--hero-glow:rgba(79,70,229,.12)
|
||
}
|
||
[data-theme="dark"]{
|
||
--bg:#0e1118;--surface:#171c29;--surface-2:#1f2636;--surface-3:#2a3246;
|
||
--border:rgba(255,255,255,.07);--border-strong:rgba(255,255,255,.15);
|
||
--text:#eceef4;--text-2:#a8b0c3;--text-3:#6f7890;
|
||
--accent:#8a93f9;--accent-dark:#a5adfb;--accent-soft:rgba(138,147,249,.14);--accent-softer:rgba(138,147,249,.07);
|
||
--cyan:#22d3ee;--cyan-soft:rgba(34,211,238,.1);
|
||
--success:#4ade80;--success-soft:rgba(74,222,128,.14);
|
||
--warn:#fbbf24;--warn-soft:rgba(251,191,36,.14);
|
||
--danger:#f87171;--danger-soft:rgba(248,113,113,.12);
|
||
--shadow-sm:0 1px 2px rgba(0,0,0,.3),0 1px 3px rgba(0,0,0,.38);
|
||
--shadow-md:0 2px 4px rgba(0,0,0,.4),0 12px 30px rgba(0,0,0,.45);
|
||
--shadow-lg:0 4px 10px rgba(0,0,0,.45),0 20px 48px rgba(0,0,0,.55);
|
||
--hero-glow:rgba(99,102,241,.18);
|
||
--grid-line:rgba(255,255,255,.035)
|
||
}
|
||
*{box-sizing:border-box;margin:0;padding:0}
|
||
html{scroll-behavior:smooth}
|
||
body{font-family:'Inter',system-ui,-apple-system,'Segoe UI',sans-serif;color:var(--text);font-size:15px;line-height:1.5;overflow-x:hidden;-webkit-font-smoothing:antialiased;min-height:100vh}
|
||
[data-theme="dark"] body{background:radial-gradient(1100px 520px at 90% -8%,rgba(99,102,241,.15),transparent 60%),radial-gradient(760px 420px at -6% 28%,rgba(34,211,238,.05),transparent 55%),var(--bg)}
|
||
[data-theme="light"] body{background:radial-gradient(1100px 520px at 90% -8%,rgba(99,102,241,.06),transparent 60%),radial-gradient(760px 420px at -6% 28%,rgba(8,145,178,.04),transparent 55%),var(--bg)}
|
||
a{color:var(--accent);text-decoration:none}
|
||
a:hover{text-decoration:underline}
|
||
button{font-family:inherit;cursor:pointer;color:inherit}
|
||
input,select,textarea{font-family:inherit;color:inherit}
|
||
:focus-visible{outline:2px solid var(--accent);outline-offset:2px;border-radius:5px}
|
||
|
||
/* SIDEBAR */
|
||
.sidebar{position:fixed;top:0;left:0;bottom:0;width:var(--sidebar-w);background:linear-gradient(180deg,var(--surface),var(--surface-2));border-right:1px solid var(--border);display:flex;flex-direction:column;z-index:100}
|
||
.sidebar-brand{padding:22px 20px 17px;border-bottom:1px solid var(--border)}
|
||
.sidebar-brand h1{font-size:18px;font-weight:800;letter-spacing:.14em;color:var(--text)}
|
||
.sidebar-brand h1 b{color:var(--accent)}
|
||
.sidebar-brand .sb-dot{display:inline-block;width:6px;height:6px;border-radius:50%;background:var(--accent);margin-left:2px;vertical-align:middle;animation:blink 2.2s ease-in-out infinite}
|
||
.sidebar-brand span{display:block;font-size:11px;color:var(--text-3);margin-top:3px;letter-spacing:.03em}
|
||
.sidebar-nav{flex:1;padding:12px 10px;overflow-y:auto}
|
||
.nav-item{display:flex;align-items:center;gap:11px;width:100%;padding:10px 12px;border:none;background:none;border-radius:var(--radius-sm);color:var(--text-2);font-size:13.5px;font-weight:500;text-align:left;transition:background .15s,color .15s}
|
||
.nav-item:hover{background:var(--accent-softer);color:var(--text)}
|
||
.nav-item.active{background:var(--accent-soft);color:var(--accent);font-weight:600}
|
||
.nav-item svg{width:17px;height:17px;flex-shrink:0;opacity:.75}
|
||
.nav-item.active svg{opacity:1}
|
||
.sidebar-foot{padding:12px 10px;border-top:1px solid var(--border)}
|
||
.theme-toggle{display:flex;align-items:center;gap:11px;width:100%;padding:10px 12px;border:none;background:none;border-radius:var(--radius-sm);color:var(--text-2);font-size:13px;font-weight:500;transition:background .15s,color .15s}
|
||
.theme-toggle:hover{background:var(--accent-softer);color:var(--text)}
|
||
.theme-toggle svg{width:16px;height:16px;opacity:.75}
|
||
.sidebar-src{padding:5px 12px 2px;font-size:10.5px;color:var(--text-3)}
|
||
|
||
/* MOBILE NAV */
|
||
.mobile-nav{display:none;position:fixed;bottom:0;left:0;right:0;z-index:100;background:var(--surface);border-top:1px solid var(--border);padding:4px 0 calc(4px + env(safe-area-inset-bottom,0px))}
|
||
.mobile-nav-inner{display:flex}
|
||
.mob-nav{flex:1;display:flex;flex-direction:column;align-items:center;gap:2px;padding:8px 4px;border:none;background:none;color:var(--text-3);font-size:10.5px;font-weight:500}
|
||
.mob-nav.active{color:var(--accent)}
|
||
.mob-nav svg{width:19px;height:19px}
|
||
|
||
/* MAIN */
|
||
.main{margin-left:var(--sidebar-w);min-height:100vh;padding:28px 36px 90px}
|
||
.main-inner{max-width:900px;margin:0 auto}
|
||
|
||
/* HEADER */
|
||
.header{margin-bottom:20px}
|
||
.header-row{display:flex;align-items:flex-start;justify-content:space-between;gap:16px}
|
||
.header-greeting{font-size:22px;font-weight:800;letter-spacing:-.02em;color:var(--text)}
|
||
.header-status{font-size:13px;color:var(--text-3);margin-top:3px;font-weight:500}
|
||
.header-actions{display:flex;flex-direction:column;align-items:flex-end;gap:5px}
|
||
.btn-refresh{display:inline-flex;align-items:center;gap:7px;padding:8px 15px;background:var(--surface);border:1px solid var(--border-strong);border-radius:var(--radius-sm);font-size:12.5px;font-weight:600;color:var(--text-2);transition:all .15s;box-shadow:var(--shadow-sm)}
|
||
.btn-refresh:hover{border-color:var(--accent);color:var(--accent)}
|
||
.btn-refresh:disabled{opacity:.7;cursor:default}
|
||
.btn-refresh.spin svg{animation:spin .7s linear infinite}
|
||
.btn-refresh svg{width:14px;height:14px}
|
||
@keyframes spin{to{transform:rotate(360deg)}}
|
||
.last-update{font-size:11px;color:var(--text-3);font-variant-numeric:tabular-nums}
|
||
|
||
/* SEARCH */
|
||
.search{position:relative;margin-top:14px}
|
||
.search input{width:100%;padding:10px 36px 10px 36px;border:1px solid var(--border-strong);background:var(--surface);border-radius:var(--radius-sm);font-size:13.5px;color:var(--text);transition:border .15s,box-shadow .15s;box-shadow:var(--shadow-sm)}
|
||
.search input::placeholder{color:var(--text-3)}
|
||
.search input:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-softer)}
|
||
.search .s-ic{position:absolute;left:11px;top:50%;transform:translateY(-50%);width:15px;height:15px;color:var(--text-3);pointer-events:none}
|
||
.search .s-kbd{position:absolute;right:10px;top:50%;transform:translateY(-50%);font-size:10px;color:var(--text-3);border:1px solid var(--border);border-radius:4px;padding:1px 6px;background:var(--surface-2);pointer-events:none}
|
||
.search-results{position:absolute;top:calc(100% + 6px);left:0;right:0;background:var(--surface);border:1px solid var(--border-strong);border-radius:var(--radius-sm);box-shadow:var(--shadow-lg);max-height:340px;overflow-y:auto;z-index:60;display:none}
|
||
.search-results.show{display:block}
|
||
.sr-hint{padding:9px 12px;font-size:11.5px;color:var(--text-3)}
|
||
.sr-item{display:block;width:100%;text-align:left;padding:9px 12px;border:none;border-bottom:1px solid var(--border);background:none;cursor:pointer;transition:background .12s}
|
||
.sr-item:last-child{border-bottom:none}
|
||
.sr-item:hover{background:var(--accent-softer)}
|
||
.sr-item b{display:block;font-size:13px;font-weight:600;color:var(--text)}
|
||
.sr-item mark{background:var(--warn-soft);color:var(--warn);border-radius:3px;padding:0 2px}
|
||
.sr-meta{display:flex;flex-wrap:wrap;gap:4px 14px;margin-top:2px;font-size:11.5px;color:var(--text-3)}
|
||
.sr-meta span{display:inline-flex;align-items:center;gap:4px}
|
||
.sr-meta svg{width:11px;height:11px;opacity:.6}
|
||
|
||
/* DAY STRIP */
|
||
.strip{margin:0 0 16px}
|
||
.strip-label{display:flex;align-items:baseline;justify-content:space-between;margin-bottom:8px}
|
||
.strip-label h2{font-size:12.5px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--text-3)}
|
||
.strip-label .strip-range{font-size:12px;color:var(--text-3);font-variant-numeric:tabular-nums}
|
||
.strip-grid{display:grid;grid-template-columns:repeat(7,1fr);gap:6px}
|
||
.strip-day{padding:9px 4px 8px;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-sm);text-align:center;transition:all .15s;box-shadow:var(--shadow-sm)}
|
||
.strip-day:hover{border-color:var(--accent);transform:translateY(-1px)}
|
||
.strip-day .sd-name{display:block;font-size:10.5px;text-transform:uppercase;letter-spacing:.05em;font-weight:600;color:var(--text-3)}
|
||
.strip-day .sd-num{display:block;font-size:17px;font-weight:700;color:var(--text);margin:1px 0;font-variant-numeric:tabular-nums}
|
||
.strip-day .sd-cnt{display:block;font-size:10px;font-weight:500;color:var(--text-3);font-variant-numeric:tabular-nums}
|
||
.strip-day.wknd{background:transparent;border-style:dashed;opacity:.8;box-shadow:none}
|
||
.strip-day.today{border-color:var(--border-strong);background:linear-gradient(180deg,var(--accent-softer),transparent);position:relative}
|
||
.strip-day.today .sd-name{color:var(--accent);font-weight:700}
|
||
.strip-day.today .sd-num{color:var(--accent-dark)}
|
||
[data-theme="dark"] .strip-day.today .sd-num{color:var(--accent)}
|
||
.strip-day.today .sd-cnt{color:var(--accent)}
|
||
.strip-day.today::after{content:'';position:absolute;left:12%;right:12%;bottom:0;height:2px;background:var(--accent);border-radius:2px 2px 0 0}
|
||
.strip-day.selected{background:var(--accent);border-color:var(--accent);color:#fff;box-shadow:0 4px 14px var(--accent-soft)}
|
||
.strip-day.selected .sd-name,.strip-day.selected .sd-num,.strip-day.selected .sd-cnt{color:#fff}
|
||
.strip-day.selected .sd-cnt{color:rgba(255,255,255,.85)}
|
||
.strip-day.selected::after{background:transparent}
|
||
|
||
/* MINI STATS */
|
||
.minim{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin:0 0 16px}
|
||
.mini{background:linear-gradient(180deg,var(--surface),var(--surface-2));border:1px solid var(--border);border-radius:var(--radius-sm);padding:11px 14px;box-shadow:var(--shadow-sm)}
|
||
.mini b{display:block;font-size:21px;font-weight:800;color:var(--text);font-variant-numeric:tabular-nums;line-height:1.1}
|
||
.mini b i{font-style:normal;color:var(--accent)}
|
||
.mini span{font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:var(--text-3)}
|
||
|
||
/* DAY HEAD */
|
||
.day-head{margin:4px 0 14px}
|
||
.day-head .day-title{font-size:13px;font-weight:600;color:var(--text-2)}
|
||
.day-head .day-title b{color:var(--text);font-size:15px;font-weight:800}
|
||
.day-head .day-count{font-size:12.5px;color:var(--text-3);margin-top:2px}
|
||
|
||
/* HERO / STATUS */
|
||
.status{position:relative;isolation:isolate;margin-bottom:16px;padding:22px 24px;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);box-shadow:var(--shadow-md);overflow:hidden}
|
||
.status::before{content:'';position:absolute;inset:0;z-index:-1;background:radial-gradient(640px 320px at 92% -20%,var(--hero-glow),transparent 62%);pointer-events:none}
|
||
.status::after{content:'';position:absolute;inset:0;z-index:-1;background-image:linear-gradient(var(--grid-line) 1px,transparent 1px),linear-gradient(90deg,var(--grid-line) 1px,transparent 1px);background-size:112px 112px;opacity:.55;-webkit-mask-image:radial-gradient(circle at 90% 0,black,transparent 58%);mask-image:radial-gradient(circle at 90% 0,black,transparent 58%);pointer-events:none}
|
||
.status::marker{color:transparent}
|
||
.status.st-live{background:linear-gradient(135deg,var(--accent-softer),var(--surface) 52%);border-color:var(--border-strong);border-left:3px solid var(--accent);box-shadow:var(--shadow-md)}
|
||
.status.st-live::before{background:radial-gradient(640px 320px at 92% -20%,rgba(99,102,241,.2),transparent 62%)}
|
||
[data-theme="dark"] .status.st-live::before{background:radial-gradient(640px 320px at 92% -20%,rgba(99,102,241,.3),transparent 62%)}
|
||
.status.st-next{background:linear-gradient(135deg,var(--warn-soft),var(--surface) 50%);border-left:3px solid var(--warn)}
|
||
.status.st-done{background:linear-gradient(135deg,var(--success-soft),var(--surface) 55%);border-left:3px solid var(--success)}
|
||
.status.st-weekend{border-left:3px solid var(--text-3)}
|
||
.status.st-quiet{border-left:3px solid var(--accent)}
|
||
.st-top{display:flex;align-items:center;gap:8px;margin-bottom:10px;flex-wrap:wrap}
|
||
.st-badge{display:inline-flex;align-items:center;gap:6px;font-size:11px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;padding:3px 10px;border-radius:999px}
|
||
.st-badge i{width:6px;height:6px;border-radius:50%;background:currentColor}
|
||
.st-badge.b-live{color:var(--accent);background:var(--accent-soft)}
|
||
.st-badge.b-live i{animation:blink 1.8s ease-in-out infinite}
|
||
.st-badge.b-next{color:var(--warn);background:var(--warn-soft)}
|
||
.st-badge.b-done{color:var(--success);background:var(--success-soft)}
|
||
.st-badge.b-quiet{color:var(--text-3);background:var(--surface-2)}
|
||
@keyframes blink{0%,100%{opacity:1}50%{opacity:.25}}
|
||
.st-day{font-size:11.5px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:var(--text-3);margin-bottom:6px}
|
||
.st-day b{color:var(--accent);font-weight:800}
|
||
.st-time{font-size:24px;font-weight:800;letter-spacing:-.02em;color:var(--text);font-variant-numeric:tabular-nums}
|
||
.st-subj{font-size:17px;font-weight:700;color:var(--text);margin-top:3px;line-height:1.35}
|
||
.st-meta{display:flex;flex-wrap:wrap;gap:6px 18px;margin-top:9px;font-size:13px;color:var(--text-2)}
|
||
.st-meta span{display:inline-flex;align-items:center;gap:6px}
|
||
.st-meta svg{width:13px;height:13px;opacity:.55}
|
||
.st-countdown{margin-top:13px;font-size:13px;font-weight:600;color:var(--accent);font-variant-numeric:tabular-nums}
|
||
.st-progress{margin-top:9px;height:5px;background:var(--surface-3);border-radius:999px;overflow:hidden}
|
||
.st-progress div{height:100%;background:linear-gradient(90deg,var(--accent),var(--cyan));border-radius:999px;transition:width .8s ease}
|
||
.st-nextline{margin-top:14px;padding-top:11px;border-top:1px dashed var(--border-strong);font-size:12.5px;color:var(--text-2);display:flex;align-items:center;gap:8px}
|
||
.st-nextline b{color:var(--text);font-weight:700}
|
||
.st-nextline .tl-badge{margin-left:auto}
|
||
.st-msg{font-size:15px;font-weight:700;color:var(--text)}
|
||
.st-sub{font-size:12.5px;color:var(--text-3);margin-top:3px}
|
||
.st-art{position:absolute;right:16px;bottom:10px;width:110px;height:110px;color:var(--accent);opacity:.14;pointer-events:none;z-index:-1}
|
||
|
||
/* DAY PROGRESS */
|
||
.day-progress{display:flex;align-items:center;gap:12px;margin:0 0 18px;padding:0 2px}
|
||
.dp-bar{flex:1;height:5px;background:var(--surface-3);border-radius:999px;overflow:hidden}
|
||
.dp-bar div{height:100%;background:linear-gradient(90deg,var(--success),var(--accent));border-radius:999px;transition:width .7s ease}
|
||
.dp-label{font-size:11.5px;color:var(--text-3);white-space:nowrap;font-variant-numeric:tabular-nums}
|
||
|
||
/* TIMELINE */
|
||
.timeline{display:flex;flex-direction:column}
|
||
.header-tl{display:flex;align-items:baseline;justify-content:space-between;margin:0 0 10px}
|
||
.header-tl span{font-size:12.5px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--text-3)}
|
||
.header-tl small{font-size:11.5px;color:var(--text-3)}
|
||
.tl{display:grid;grid-template-columns:56px 14px 1fr;gap:6px 8px;margin-bottom:10px;align-items:stretch}
|
||
.tl-time{padding-top:14px;text-align:right;font-size:12.5px;font-weight:700;color:var(--text);font-variant-numeric:tabular-nums}
|
||
.tl-line{position:relative}
|
||
.tl-line::before{content:'';position:absolute;left:6px;top:0;bottom:-10px;width:1px;background:var(--surface-3)}
|
||
.tl:last-child .tl-line::before{bottom:0}
|
||
.tl-line .dot{position:absolute;left:2px;top:14px;width:9px;height:9px;border-radius:50%;background:var(--surface-3);border:2px solid var(--surface);box-shadow:0 0 0 1px var(--border-strong)}
|
||
.tl-card{padding:12px 15px;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-sm);box-shadow:var(--shadow-sm);cursor:pointer;transition:all .15s;position:relative}
|
||
.tl-card:hover{border-color:var(--accent);box-shadow:var(--shadow-md);transform:translateY(-1px)}
|
||
.tl-top{display:flex;align-items:center;gap:8px;margin-bottom:4px}
|
||
.tl-para{display:inline-flex;align-items:center;gap:7px;font-size:10px;font-weight:700;color:var(--text-3);text-transform:uppercase;letter-spacing:.05em;font-variant-numeric:tabular-nums}
|
||
.mk{display:inline-flex;align-items:center;justify-content:center;width:16px;height:16px;border-radius:50%;font-size:9.5px;line-height:1;color:var(--text-3);border:1px solid var(--border-strong);background:var(--surface-2)}
|
||
.mk.done{color:var(--success);border-color:var(--success);background:var(--success-soft)}
|
||
.mk.cur{color:var(--accent);border-color:var(--accent);background:var(--accent-soft)}
|
||
.mk.up{color:var(--text-3)}
|
||
.tl-range{margin-left:auto;font-size:11px;color:var(--text-3);font-variant-numeric:tabular-nums}
|
||
.tl-subj{font-size:13.5px;font-weight:600;color:var(--text);line-height:1.35}
|
||
.tl-teacher{font-size:12px;color:var(--text-2);margin-top:2px}
|
||
.tl-room{display:inline-block;margin-top:5px;font-size:11px;font-weight:600;color:var(--accent);background:var(--accent-softer);border:1px solid var(--accent-soft);padding:1px 9px;border-radius:999px}
|
||
.tl-badge{font-size:10px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;padding:2px 9px;border-radius:999px;flex-shrink:0}
|
||
.tl-badge.tl-live{color:var(--accent);background:var(--accent-soft)}
|
||
.tl-badge.tl-next{color:var(--warn);background:var(--warn-soft)}
|
||
.tl.current .tl-line .dot{background:var(--accent);border-color:var(--surface);box-shadow:0 0 0 1px var(--accent),0 0 0 5px var(--accent-softer)}
|
||
.tl.current .tl-line::before{background:var(--accent)}
|
||
.tl.current .tl-card{border-color:var(--accent);border-left:3px solid var(--accent);background:linear-gradient(135deg,var(--accent-softer),var(--surface) 60%);box-shadow:var(--shadow-md)}
|
||
.tl.current .tl-card:hover{border-color:var(--accent)}
|
||
.tl.current .tl-subj{color:var(--accent-dark)}
|
||
[data-theme="dark"] .tl.current .tl-subj{color:var(--accent)}
|
||
.tl.past .tl-card{opacity:.55}
|
||
.tl.past .tl-card:hover{opacity:.95}
|
||
.tl.upcoming .tl-line .dot{background:var(--surface)}
|
||
.tl-time.past{color:var(--text-3)}
|
||
|
||
/* EMPTY */
|
||
.empty{padding:30px 20px;text-align:center;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);box-shadow:var(--shadow-sm)}
|
||
.empty .e-t{font-size:14px;font-weight:700;color:var(--text)}
|
||
.empty .e-s{font-size:12.5px;color:var(--text-3);margin-top:3px}
|
||
|
||
/* TOMORROW */
|
||
.tomorrow{margin-top:24px;position:relative;background:linear-gradient(180deg,var(--surface),var(--surface-2));border:1px solid var(--border);border-left:3px solid var(--accent);border-radius:var(--radius);box-shadow:var(--shadow-sm);overflow:hidden}
|
||
.tomorrow .t-head{display:flex;align-items:baseline;justify-content:space-between;gap:10px;padding:13px 16px 9px}
|
||
.tomorrow .t-head span{font-size:12px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;color:var(--accent)}
|
||
.tomorrow .t-head small{font-size:12px;color:var(--text-3);font-variant-numeric:tabular-nums}
|
||
.tomorrow .t-rowc{padding:0}
|
||
.tom-row{display:flex;align-items:center;gap:12px;padding:8px 16px;border-top:1px solid var(--border);cursor:pointer;transition:background .12s}
|
||
.tom-row:first-child{border-top:none}
|
||
.tom-row:hover{background:var(--accent-softer)}
|
||
.tom-time{font-size:12px;font-weight:700;color:var(--text);font-variant-numeric:tabular-nums;width:38px;flex-shrink:0}
|
||
.tom-subj{font-size:13px;font-weight:500;color:var(--text);flex:1;min-width:0}
|
||
.tom-note{font-size:12.5px;color:var(--text-3);padding:12px 16px}
|
||
.tom-btn{display:block;width:100%;margin-top:2px;padding:10px 16px;border:none;border-top:1px solid var(--border);background:var(--accent-soft);color:var(--accent);font-size:12.5px;font-weight:600;text-align:center;transition:background .12s}
|
||
.tom-btn:hover{background:var(--accent-softer)}
|
||
.tom-btn:active{transform:translateY(1px)}
|
||
|
||
/* WEEK PAGE */
|
||
.week-days{display:flex;flex-direction:column;gap:10px;margin-bottom:18px}
|
||
.wd{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-sm);box-shadow:var(--shadow-sm);overflow:hidden}
|
||
.wd.today{border-color:var(--accent)}
|
||
.wd-head{display:flex;align-items:baseline;gap:8px;padding:9px 14px;background:var(--surface-2);border-bottom:1px solid var(--border)}
|
||
.wd-head b{font-size:13px;font-weight:700;color:var(--text)}
|
||
.wd-head span{font-size:11.5px;color:var(--text-3)}
|
||
.wd-head .wd-cnt{margin-left:auto;font-size:11px;font-weight:600;color:var(--text-3)}
|
||
.wd.today .wd-head{background:var(--accent-softer)}
|
||
.wd.today .wd-head b{color:var(--accent)}
|
||
.wd-empty{padding:10px 14px;font-size:12px;color:var(--text-3)}
|
||
|
||
/* ERROR */
|
||
.error-state{padding:16px 20px;margin-bottom:16px;background:var(--danger-soft);border:1px solid var(--danger);border-radius:var(--radius);box-shadow:var(--shadow-sm)}
|
||
.error-state-title{font-size:13.5px;font-weight:700;color:var(--danger)}
|
||
.error-state-sub{font-size:12.5px;color:var(--text-2);margin:4px 0 10px}
|
||
.btn-retry{padding:6px 14px;background:var(--danger);color:#fff;border:none;border-radius:var(--radius-xs);font-size:12.5px;font-weight:600}
|
||
.btn-retry:hover{filter:brightness(1.06)}
|
||
|
||
/* FOOTER */
|
||
.footer{margin-top:32px;padding-top:16px;border-top:1px solid var(--border);display:flex;align-items:center;justify-content:space-between;font-size:11px;color:var(--text-3);flex-wrap:wrap;gap:6px}
|
||
.footer kbd{background:var(--surface-2);border:1px solid var(--border);border-radius:4px;padding:0 5px;font-size:10px;font-family:inherit}
|
||
|
||
/* LOG PANEL */
|
||
.log-panel{margin-top:14px;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-sm);box-shadow:var(--shadow-sm);overflow:hidden}
|
||
.log-head{display:flex;align-items:center;gap:8px;width:100%;padding:11px 14px;border:none;background:none;color:var(--text-2);font-size:12.5px;font-weight:600;text-align:left}
|
||
.log-head:hover{background:var(--accent-softer);color:var(--text)}
|
||
.log-head svg{width:14px;height:14px;opacity:.7}
|
||
.log-badge{margin-left:4px;background:var(--accent-soft);color:var(--accent);border-radius:999px;padding:0 8px;font-size:10px;font-weight:700}
|
||
.log-toggle{margin-left:auto;color:var(--text-3);font-size:10px;transition:transform .2s}
|
||
.log-panel.open .log-toggle{transform:rotate(180deg)}
|
||
.log-list{display:none;max-height:260px;overflow-y:auto;border-top:1px solid var(--border);padding:6px 0}
|
||
.log-panel.open .log-list{display:block}
|
||
.log-item{display:flex;gap:9px;padding:5px 14px;font-size:12px;line-height:1.45;color:var(--text-2)}
|
||
.log-item .log-time{color:var(--text-3);flex-shrink:0;font-size:10.5px;padding-top:1px}
|
||
.log-item .log-dot{flex-shrink:0;width:6px;height:6px;border-radius:50%;margin-top:4px}
|
||
.log-item.ok .log-dot{background:var(--success)}
|
||
.log-item.warning .log-dot{background:var(--warn)}
|
||
.log-item.error .log-dot{background:var(--danger)}
|
||
.log-item.info .log-dot{background:var(--accent)}
|
||
.log-empty{padding:12px 14px;font-size:12px;color:var(--text-3);text-align:center}
|
||
|
||
/* ANIM */
|
||
@keyframes fadeUp{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:none}}
|
||
.anim{animation:fadeUp .32s ease-out both}
|
||
@keyframes fadeIn{from{opacity:0}to{opacity:1}}
|
||
.fadein{animation:fadeIn .25s ease-out both}
|
||
|
||
/* MODAL */
|
||
.modal-backdrop{position:fixed;inset:0;background:rgba(10,12,20,.55);z-index:200;opacity:0;pointer-events:none;transition:opacity .2s}
|
||
.modal-backdrop.show{opacity:1;pointer-events:auto}
|
||
.modal{position:fixed;top:50%;left:50%;transform:translate(-50%,-50%) scale(.97);z-index:201;background:var(--surface);border:1px solid var(--border-strong);border-radius:var(--radius);padding:24px;width:92%;max-width:430px;box-shadow:var(--shadow-lg);opacity:0;pointer-events:none;transition:all .2s ease-out}
|
||
.modal.show{opacity:1;transform:translate(-50%,-50%) scale(1);pointer-events:auto}
|
||
.modal-close{position:absolute;top:10px;right:10px;width:28px;height:28px;border-radius:50%;border:none;background:var(--surface-2);color:var(--text-2);font-size:15px;display:flex;align-items:center;justify-content:center;transition:all .15s}
|
||
.modal-close:hover{background:var(--surface-3);color:var(--text)}
|
||
.modal-title{font-size:16px;font-weight:700;color:var(--text);padding-right:24px}
|
||
.modal-sub{font-size:12px;color:var(--text-3);margin-top:2px}
|
||
.modal-body{margin-top:14px}
|
||
.modal-row{display:flex;align-items:center;gap:10px;padding:8px 0;border-bottom:1px solid var(--border);font-size:13px}
|
||
.modal-row:last-child{border-bottom:none}
|
||
.modal-row svg{width:15px;height:15px;color:var(--text-3);flex-shrink:0}
|
||
.modal-row span{color:var(--text-2)}
|
||
.modal-row strong{color:var(--text);font-weight:600;margin-left:auto;text-align:right}
|
||
|
||
/* TOAST */
|
||
.toast{position:fixed;bottom:22px;right:22px;padding:11px 18px;background:var(--surface);border:1px solid var(--border-strong);border-radius:var(--radius-sm);font-size:13px;color:var(--text);box-shadow:var(--shadow-lg);z-index:1000;opacity:0;transform:translateY(12px);pointer-events:none;transition:all .3s}
|
||
.toast.show{opacity:1;transform:none}
|
||
.toast.success{border-color:var(--success)}
|
||
.toast.error{border-color:var(--danger)}
|
||
|
||
/* SKELETON */
|
||
.skeleton{position:relative;overflow:hidden;background:var(--surface-2);border-radius:var(--radius-sm)}
|
||
.skeleton::after{content:'';position:absolute;inset:0;background:linear-gradient(90deg,transparent,rgba(255,255,255,.06),transparent);animation:shimmer 2s infinite}
|
||
@keyframes shimmer{to{transform:translateX(100%)}}
|
||
.sk-status{height:104px;margin-bottom:16px;border-radius:var(--radius)}
|
||
.sk-tl{height:52px;margin-bottom:10px}
|
||
|
||
/* RESPONSIVE */
|
||
@media(max-width:820px){
|
||
.main{margin-left:0;padding:18px 14px 84px}
|
||
.mobile-nav{display:block}
|
||
.sidebar{display:none}
|
||
.header-row{align-items:flex-start}
|
||
.header-greeting{font-size:18px}
|
||
.strip-grid{gap:4px}
|
||
.strip-day{padding:7px 2px 6px}
|
||
.strip-day .sd-num{font-size:15px}
|
||
.tl{grid-template-columns:42px 12px 1fr}
|
||
.tl-time{font-size:11.5px}
|
||
.st-time{font-size:20px}
|
||
.st-subj{font-size:15px}
|
||
.minim{grid-template-columns:repeat(2,1fr);gap:8px}
|
||
.st-art{right:6px;bottom:0;width:84px;height:84px}
|
||
.search-results{max-height:280px}
|
||
}
|
||
@media(prefers-reduced-motion:reduce){
|
||
*,*::before,*::after{animation-duration:.01ms!important;animation-iteration-count:1!important;transition-duration:.01ms!important}
|
||
html{scroll-behavior:auto}
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<nav class="sidebar" id="sidebar">
|
||
<div class="sidebar-brand">
|
||
<h1>TR<b>I</b>RON<span class="sb-dot"></span></h1>
|
||
<span>Персональное расписание</span>
|
||
</div>
|
||
<nav class="sidebar-nav">
|
||
<button class="nav-item active" data-page="today" onclick="switchPage('today')">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
|
||
Расписание
|
||
</button>
|
||
<button class="nav-item" data-page="week" onclick="switchPage('week')">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg>
|
||
Неделя
|
||
</button>
|
||
</nav>
|
||
<div class="sidebar-foot">
|
||
<button class="theme-toggle" id="themeBtn" onclick="toggleTheme()" aria-label="Сменить тему">
|
||
<svg id="themeIcon" 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>
|
||
<span id="themeText">Тёмная тема</span>
|
||
</button>
|
||
<div class="sidebar-src">journal.top-academy.ru</div>
|
||
</div>
|
||
</nav>
|
||
|
||
<nav class="mobile-nav">
|
||
<div class="mobile-nav-inner">
|
||
<button class="mob-nav active" data-page="today" onclick="switchPage('today')">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
|
||
Сегодня
|
||
</button>
|
||
<button class="mob-nav" data-page="week" onclick="switchPage('week')">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg>
|
||
Неделя
|
||
</button>
|
||
<button class="mob-nav" id="mobTheme" onclick="toggleTheme()">
|
||
<svg id="mobThemeIcon" 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>
|
||
</nav>
|
||
|
||
<main class="main">
|
||
<div class="main-inner">
|
||
<header class="header">
|
||
<div class="header-row">
|
||
<div>
|
||
<div class="header-greeting" id="greeting"></div>
|
||
<div class="header-status" id="headerStatus"></div>
|
||
</div>
|
||
<div class="header-actions">
|
||
<button class="btn-refresh" id="refreshBtn" onclick="doUpdate()" aria-label="Обновить расписание" title="Обновить расписание (R)">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg>
|
||
<span id="refreshText">Обновить</span>
|
||
</button>
|
||
<div class="last-update" id="lastUpdate"></div>
|
||
</div>
|
||
</div>
|
||
<div class="search">
|
||
<svg class="s-ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||
<input id="searchInput" type="text" placeholder="Поиск: предмет, преподаватель, кабинет" autocomplete="off">
|
||
<span class="s-kbd" id="searchKbd">/</span>
|
||
<div class="search-results" id="searchResults"></div>
|
||
</div>
|
||
</header>
|
||
|
||
<div id="errorBox"></div>
|
||
<div id="pageContent"></div>
|
||
|
||
<footer class="footer">
|
||
<span>TRIRON · источник TOP Academy</span>
|
||
<div><kbd>/</kbd> поиск · <kbd>R</kbd> обновить · <kbd>T</kbd> сегодня · <kbd>Esc</kbd> закрыть</div>
|
||
</footer>
|
||
|
||
<div class="log-panel" id="logPanel">
|
||
<button class="log-head" onclick="toggleLogs()">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
|
||
Журнал обновлений
|
||
<span class="log-badge" id="logBadge">–</span>
|
||
<span class="log-toggle">▼</span>
|
||
</button>
|
||
<div class="log-list" id="logList"><div class="log-empty">Загружаю...</div></div>
|
||
</div>
|
||
</div>
|
||
</main>
|
||
|
||
<div class="toast" id="toast"></div>
|
||
<div class="modal-backdrop" id="modalBackdrop" onclick="closeModal()"></div>
|
||
<div class="modal" id="modal">
|
||
<button class="modal-close" onclick="closeModal()">×</button>
|
||
<div class="modal-title" id="modalTitle"></div>
|
||
<div class="modal-sub" id="modalSub"></div>
|
||
<div class="modal-body" id="modalBody"></div>
|
||
</div>
|
||
|
||
<script>
|
||
const WD=["Воскресенье","Понедельник","Вторник","Среда","Четверг","Пятница","Суббота"];
|
||
const WD_SHORT=["Вс","Пн","Вт","Ср","Чт","Пт","Сб"];
|
||
const MONTHS=["января","февраля","марта","апреля","мая","июня","июля","августа","сентября","октября","ноября","декабря"];
|
||
|
||
let ALL=[],PAGE="today",LOADING=true,ERROR=null,LAST_UPDATE="";
|
||
let SELECTED_DAY=null;
|
||
|
||
function pd(n){return String(n).padStart(2,"0")}
|
||
function tds(d){return d.getFullYear()+"-"+pd(d.getMonth()+1)+"-"+pd(d.getDate())}
|
||
function parseDate(s){const p=s.split("-");return new Date(+p[0],+p[1]-1,+p[2])}
|
||
function dateStr(d){return pd(d.getDate())+" "+MONTHS[d.getMonth()]}
|
||
function dayOfWeekStr(d){return WD[d.getDay()]}
|
||
function pt(t){const p=t.split(":");return (+p[0])*60+(+p[1])}
|
||
function esc(s){const d=document.createElement("div");d.textContent=s==null?"":String(s);return d.innerHTML}
|
||
function cleanSubject(s){return (s||"").replace(/\s+/g," ").trim()}
|
||
function roomShort(r){const m=String(r||"").match(/\d+/);return m?m[0]:String(r||"").trim()}
|
||
function nowMin(){const n=new Date();return n.getHours()*60+n.getMinutes()}
|
||
function todayStr(){const n=new Date();return tds(n)}
|
||
function isWeekend(d){return d.getDay()===0||d.getDay()===6}
|
||
function fmtDur(m){if(m<60)return m+" мин";const h=Math.floor(m/60);const mm=m%60;return mm?h+" ч "+mm+" мин":h+" ч"}
|
||
function plural(n){const a=n%100;const b=n%10;if(a>=11&&a<=14)return"занятий";if(b===1)return"занятие";if(b>=2&&b<=4)return"занятия";return"занятий"}
|
||
function plural2(n){const a=n%100;const b=n%10;if(a>=11&&a<=14)return"пар";if(b===1)return"пара";if(b>=2&&b<=4)return"пары";return"пар"}
|
||
|
||
function lessonsOf(ds){return ALL.filter(l=>l.date===ds).sort((a,b)=>a.lesson-b.lesson)}
|
||
function getCurrentLesson(){
|
||
const nm=nowMin();const ls=lessonsOf(SELECTED_DAY);
|
||
if(SELECTED_DAY!==todayStr())return null;
|
||
for(const l of ls){const s=pt(l.started_at),e=pt(l.finished_at);if(s<=nm&&nm<e)return l}
|
||
return null
|
||
}
|
||
function getNextLesson(){
|
||
const nm=nowMin();const ls=lessonsOf(SELECTED_DAY);const cur=getCurrentLesson();
|
||
if(SELECTED_DAY!==todayStr())return null;
|
||
for(const l of ls){if(l===cur)continue;const s=pt(l.started_at);if(s>nm)return l}
|
||
return null
|
||
}
|
||
function weekRange(){
|
||
const now=new Date();const ws=new Date(now);ws.setDate(now.getDate()-((now.getDay()+6)%7));
|
||
const we=new Date(ws);we.setDate(ws.getDate()+6);
|
||
return[ws,we]
|
||
}
|
||
function weekLessons(){
|
||
const[ws]=weekRange();let n=0,mins=0;
|
||
for(let i=0;i<7;i++){const d=new Date(ws);d.setDate(ws.getDate()+i);lessonsOf(tds(d)).forEach(l=>{n++;mins+=Math.max(30,pt(l.finished_at)-pt(l.started_at))})}
|
||
return{n,mins}
|
||
}
|
||
|
||
function updateHeader(){
|
||
const ts=todayStr();
|
||
const heading=PAGE==="week"?"Неделя":(SELECTED_DAY&&SELECTED_DAY!==ts?"Просмотр дня":"Сегодня");
|
||
document.getElementById("greeting").textContent=heading;
|
||
let status="";
|
||
if(SELECTED_DAY&&SELECTED_DAY!==ts){document.getElementById("headerStatus").textContent="Просмотр: "+dayOfWeekStr(parseDate(SELECTED_DAY))+", "+dateStr(parseDate(SELECTED_DAY));return}
|
||
const cur=getCurrentLesson();const nxt=getNextLesson();const ls=lessonsOf(ts);
|
||
if(cur){const rem=Math.max(0,pt(cur.finished_at)-nowMin());status="Идёт пара · осталось "+fmtDur(rem)}
|
||
else if(nxt){const wait=Math.max(0,pt(nxt.started_at)-nowMin());status="Следующая через "+fmtDur(wait)}
|
||
else if(ls.length){status="На сегодня всё"}
|
||
else if(isWeekend(new Date())){status="Выходные"}
|
||
else{status="Пока нет расписания"}
|
||
document.getElementById("headerStatus").textContent=status;
|
||
}
|
||
|
||
function switchPage(p){
|
||
PAGE=p;
|
||
document.querySelectorAll(".nav-item,.mob-nav").forEach(b=>b.classList.toggle("active",b.dataset.page===p));
|
||
if(p==="today")SELECTED_DAY=null;
|
||
render();
|
||
}
|
||
|
||
function toggleTheme(){
|
||
const dark=document.documentElement.getAttribute("data-theme")==="dark";
|
||
const next=dark?"light":"dark";
|
||
document.documentElement.setAttribute("data-theme",next);
|
||
localStorage.setItem("triron-theme",next);
|
||
updateThemeUI(next);
|
||
}
|
||
|
||
function updateThemeUI(theme){
|
||
const isDark=theme==="dark";
|
||
const t=document.getElementById("themeText");if(t)t.textContent=isDark?"Светлая тема":"Тёмная тема";
|
||
const ic=document.getElementById("themeIcon");if(ic)ic.innerHTML=isDark?'<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"/>':'<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>';
|
||
const mi=document.getElementById("mobThemeIcon");if(mi)mi.innerHTML=document.getElementById("themeIcon").innerHTML;
|
||
}
|
||
|
||
function stripHtml(){
|
||
const[ws,we]=weekRange();const ts=todayStr();
|
||
let h='<div class="strip"><div class="strip-label"><h2>Неделя</h2><span class="strip-range">'+dateStr(ws)+' — '+dateStr(we)+'</span></div><div class="strip-grid">';
|
||
for(let i=0;i<7;i++){
|
||
const d=new Date(ws);d.setDate(ws.getDate()+i);const ds=tds(d);const cnt=lessonsOf(ds).length;
|
||
const wk=isWeekend(d);const isToday=ds===ts;const isSel=ds===SELECTED_DAY;
|
||
let cls="strip-day"+(wk?" wknd":"")+(isToday?" today":"")+(isSel?" selected":"");
|
||
h+='<button class="'+cls+'" onclick="selectDay(\''+ds+'\')"><span class="sd-name">'+WD_SHORT[d.getDay()]+'</span><span class="sd-num">'+d.getDate()+'</span><span class="sd-cnt">'+(cnt?cnt+" "+plural2(cnt):"—")+'</span></button>';
|
||
}
|
||
h+='</div></div>';
|
||
return h;
|
||
}
|
||
|
||
function selectDay(ds){
|
||
SELECTED_DAY=ds;
|
||
render();
|
||
}
|
||
|
||
function renderMiniStats(){
|
||
const ts=todayStr();const tc=lessonsOf(ts).length;
|
||
const done=lessonsOf(ts).filter(l=>pt(l.finished_at)<=nowMin()).length;
|
||
const left=Math.max(0,tc-done);
|
||
const w=weekLessons();
|
||
return '<div class="minim">'
|
||
+'<div class="mini"><b>'+tc+'</b><span>Сегодня</span></div>'
|
||
+'<div class="mini"><b><i>'+left+'</i></b><span>Осталось</span></div>'
|
||
+'<div class="mini"><b>'+w.n+'</b><span>За неделю</span></div>'
|
||
+'<div class="mini"><b>'+(w.mins>=60?Math.round(w.mins/60):0)+'</b><span>Часов занятий</span></div>'
|
||
+'</div>';
|
||
}
|
||
|
||
const ST_ART_SVG='<svg class="st-art" viewBox="0 0 120 120" fill="none" stroke="currentColor" stroke-width="1.2"><rect x="30" y="10" width="60" height="78" rx="8"/><line x1="30" y1="28" x2="90" y2="28"/><circle cx="44" cy="46" r="2.5"/><circle cx="60" cy="46" r="2.5"/><circle cx="76" cy="46" r="2.5"/><circle cx="44" cy="64" r="2.5"/><circle cx="60" cy="64" r="2.5"/><circle cx="76" cy="64" r="2.5"/><line x1="47" y1="7" x2="47" y2="17"/><line x1="73" y1="7" x2="73" y2="17"/><path d="M26 98l10-10M36 98L26 88M94 106l8-8M94 98l8 8"/></svg>';
|
||
|
||
function renderStatus(){
|
||
const nm=nowMin();const ts=todayStr();const isSelToday=SELECTED_DAY===ts;
|
||
const ls=lessonsOf(SELECTED_DAY);const cur=getCurrentLesson();const nxt=getNextLesson();
|
||
const d=parseDate(SELECTED_DAY);const wk=isWeekend(d);
|
||
const dayTitle=dayOfWeekStr(d)+", "+dateStr(d);
|
||
|
||
let cls="status";let inner="";
|
||
if(wk&&!ls.length&&isSelToday){
|
||
cls+=" st-weekend";
|
||
inner='<div class="st-day"><b>'+dayOfWeekStr(d).toUpperCase()+'</b> · '+dateStr(d).toUpperCase()+'</div>'
|
||
+'<div class="st-top"><span class="st-badge b-quiet">Выходные</span></div>'
|
||
+'<div class="st-msg">Сегодня занятий нет</div>'
|
||
+'<div class="st-sub">День свободный — отдохни и подготовься к парам</div>'
|
||
+ST_ART_SVG;
|
||
}
|
||
else if(!ls.length){
|
||
cls+=" st-weekend";
|
||
inner='<div class="st-day"><b>'+dayOfWeekStr(d).toUpperCase()+'</b> · '+dateStr(d).toUpperCase()+'</div>'
|
||
+'<div class="st-top"><span class="st-badge b-quiet">'+(wk?"Выходные":"Пока нет расписания")+'</span></div>'
|
||
+'<div class="st-msg">'+(wk?"Выходные":"Пока нет расписания")+'</div>'
|
||
+'<div class="st-sub">'+(wk?"Занятий нет":"Расписание для этого дня ещё не появилось или день свободный")+'</div>'
|
||
+ST_ART_SVG;
|
||
}
|
||
else if(isSelToday&&cur){
|
||
cls+=" st-live";
|
||
const rem=Math.max(0,pt(cur.finished_at)-nm);const tot=Math.max(1,pt(cur.finished_at)-pt(cur.started_at));const pr=Math.max(0,Math.min(100,(nm-pt(cur.started_at))/tot*100));
|
||
inner='<div class="st-day"><b>'+dayOfWeekStr(d).toUpperCase()+'</b> · '+dateStr(d).toUpperCase()+'</div>'
|
||
+'<div class="st-top"><span class="st-badge b-live"><i></i>Идёт сейчас</span><span class="st-badge b-quiet">Пара '+cur.lesson+'</span></div>'
|
||
+'<div class="st-time">'+cur.started_at+' — '+cur.finished_at+'</div>'
|
||
+'<div class="st-subj">'+esc(cleanSubject(cur.subject_name))+'</div>'
|
||
+'<div class="st-meta"><span><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>'+esc(cur.teacher_name||"—")+'</span>'
|
||
+(cur.room_name?'<span><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>Кабинет '+esc(roomShort(cur.room_name))+'</span>':"")
|
||
+'</div>'
|
||
+'<div class="st-countdown">Осталось '+fmtDur(rem)+'</div>'
|
||
+'<div class="st-progress"><div style="width:'+pr+'%"></div></div>'
|
||
+(nxt?'<div class="st-nextline">Следующая<span><b>'+nxt.started_at+'</b></span><span>'+esc(cleanSubject(nxt.subject_name))+'</span><span class="tl-badge tl-next">'+nxt.lesson+' пара</span></div>':"");
|
||
}
|
||
else if(isSelToday&&nxt){
|
||
cls+=" st-next";
|
||
const wait=Math.max(0,pt(nxt.started_at)-nm);
|
||
inner='<div class="st-day"><b>'+dayOfWeekStr(d).toUpperCase()+'</b> · '+dateStr(d).toUpperCase()+'</div>'
|
||
+'<div class="st-top"><span class="st-badge b-next">Следующая пара</span><span class="st-badge b-quiet">Пара '+nxt.lesson+'</span></div>'
|
||
+'<div class="st-time">'+nxt.started_at+' — '+nxt.finished_at+'</div>'
|
||
+'<div class="st-subj">'+esc(cleanSubject(nxt.subject_name))+'</div>'
|
||
+'<div class="st-meta"><span><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>'+esc(nxt.teacher_name||"—")+'</span>'
|
||
+(nxt.room_name?'<span><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>Кабинет '+esc(roomShort(nxt.room_name))+'</span>':"")
|
||
+'</div>'
|
||
+'<div class="st-countdown">Начало через '+fmtDur(wait)+'</div>';
|
||
}
|
||
else if(isSelToday){
|
||
cls+=" st-done";
|
||
inner='<div class="st-day"><b>'+dayOfWeekStr(d).toUpperCase()+'</b> · '+dateStr(d).toUpperCase()+'</div>'
|
||
+'<div class="st-top"><span class="st-badge b-done">На сегодня всё</span></div>'
|
||
+'<div class="st-msg">Сегодня было '+ls.length+' '+plural(ls.length)+'</div>'
|
||
+'<div class="st-sub">Последняя пара закончилась в '+ls[ls.length-1].finished_at+'</div>';
|
||
}
|
||
else{
|
||
cls+=" st-quiet";
|
||
const first=ls[0];
|
||
inner='<div class="st-day"><b>'+dayOfWeekStr(d).toUpperCase()+'</b> · '+dateStr(d).toUpperCase()+'</div>'
|
||
+'<div class="st-top"><span class="st-badge b-quiet">'+ls.length+' '+plural(ls.length)+'</span></div>'
|
||
+'<div class="st-msg">'+dayTitle+'</div>'
|
||
+'<div class="st-sub">Первая пара в '+first.started_at+'</div>'
|
||
+ST_ART_SVG;
|
||
}
|
||
|
||
return '<div class="'+cls+'">'+inner+'</div>';
|
||
}
|
||
|
||
function renderTimeline(){
|
||
const nm=nowMin();const ts=todayStr();const ls=lessonsOf(SELECTED_DAY);
|
||
if(!ls.length)return "";
|
||
const cur=getCurrentLesson();const nxt=getNextLesson();
|
||
let h='<div class="header-tl"><span>Расписание дня</span><small>'+ls.length+' '+plural(ls.length)+'</small></div><div class="timeline">';
|
||
ls.forEach((l,idx)=>{
|
||
const s=pt(l.started_at),e=pt(l.finished_at);
|
||
let state="upcoming";
|
||
if(SELECTED_DAY<ts)state="past";
|
||
else if(SELECTED_DAY===ts){if(cur===l)state="current";else if(e<=nm)state="past"}
|
||
const tt=state==="past"?"past":"";
|
||
let mk='<span class="mk">○</span>';
|
||
let badge="";
|
||
if(state==="current"){mk='<span class="mk cur">→</span>';badge='<span class="tl-badge tl-live">Идёт сейчас</span>'}
|
||
else if(state==="upcoming"&&nxt===l&&SELECTED_DAY===ts)badge='<span class="tl-badge tl-next">Следующая</span>';
|
||
else if(state==="past")mk='<span class="mk done">✓</span>';
|
||
h+='<div class="tl '+state+' anim" style="animation-delay:'+(idx*40)+'ms">'
|
||
+'<div class="tl-time '+tt+'">'+l.started_at+'</div>'
|
||
+'<div class="tl-line"><span class="dot"></span></div>'
|
||
+'<div class="tl-card" onclick="openLesson(\''+esc(JSON.stringify({s:cleanSubject(l.subject_name),t:l.teacher_name,r:l.room_name,p:l.lesson,d:l.date,a:l.started_at+' — '+l.finished_at}).replace(/"/g,'"'))+'\')">'
|
||
+'<div class="tl-top"><span class="tl-para"><span class="mk">'+mk+'</span>'+l.lesson+' пара</span><span class="tl-range">'+l.started_at+' — '+l.finished_at+'</span>'+badge+'</div>'
|
||
+'<div class="tl-subj">'+esc(cleanSubject(l.subject_name))+'</div>'
|
||
+'<div class="tl-teacher">'+esc(l.teacher_name||"")+'</div>'
|
||
+(l.room_name?'<span class="tl-room">Кабинет '+esc(roomShort(l.room_name))+'</span>':"")
|
||
+'</div></div>';
|
||
});
|
||
return h+'</div>';
|
||
}
|
||
|
||
function renderDayProgress(){
|
||
const nm=nowMin();const ls=lessonsOf(SELECTED_DAY);
|
||
if(SELECTED_DAY!==todayStr()||!ls.length)return "";
|
||
const done=ls.filter(l=>pt(l.finished_at)<=nm).length;
|
||
const pct=Math.round(done/ls.length*100);
|
||
const left=ls.length-done;
|
||
return '<div class="day-progress"><div class="dp-bar"><div style="width:'+pct+'%"></div></div><div class="dp-label">'+done+' из '+ls.length+' · осталось '+left+'</div></div>';
|
||
}
|
||
|
||
function renderTomorrow(){
|
||
const now=new Date();const td=new Date(now);td.setDate(now.getDate()+1);const ds=tds(td);const ls=lessonsOf(ds);
|
||
const wk=isWeekend(td);
|
||
let body;
|
||
if(wk)body='<div class="tom-note">Выходные — занятий нет</div>';
|
||
else if(!ls.length)body='<div class="tom-note">Пока нет расписания</div>';
|
||
else body=ls.map(l=>'<div class="tom-row" onclick="openLesson(\''+esc(JSON.stringify({s:cleanSubject(l.subject_name),t:l.teacher_name,r:l.room_name,p:l.lesson,d:l.date,a:l.started_at+' — '+l.finished_at}).replace(/"/g,'"'))+'\')"><span class="tom-time">'+l.started_at+'</span><span class="tom-subj">'+esc(cleanSubject(l.subject_name))+'</span><span class="tl-badge tl-next" style="margin-left:auto">'+roomShort(l.room_name)+'</span></div>').join("");
|
||
const cnt=ls.length?ls.length+' '+plural2(ls.length):'';
|
||
return '<div class="tomorrow fadein"><div class="t-head"><span>Завтра</span><small>'+dayOfWeekStr(td)+', '+dateStr(td)+(cnt?' · '+cnt:'')+'</small></div><div class="t-rowc">'+body+'</div><button class="tom-btn" onclick="selectDay(\''+ds+'\')">→ Посмотреть расписание</button></div>';
|
||
}
|
||
|
||
function emptyState(title,sub){
|
||
return '<div class="empty"><div class="e-t">'+title+'</div><div class="e-s">'+sub+'</div></div>';
|
||
}
|
||
|
||
function renderToday(){
|
||
SELECTED_DAY=SELECTED_DAY||todayStr();
|
||
let h=stripHtml();
|
||
if(LOADING){h+='<div class="sk-status skeleton"></div><div class="sk-tl skeleton"></div><div class="sk-tl skeleton"></div>';return h}
|
||
h+=renderMiniStats();
|
||
h+=renderStatus();
|
||
h+=renderDayProgress();
|
||
h+=renderTimeline()||(isWeekend(parseDate(SELECTED_DAY))?emptyState("Выходные","Сегодня занятий нет"):emptyState("Пока нет расписания","Расписание для этого дня ещё не появилось"));
|
||
if(SELECTED_DAY===todayStr())h+=renderTomorrow();
|
||
return h;
|
||
}
|
||
|
||
function renderWeek(){
|
||
SELECTED_DAY=SELECTED_DAY||todayStr();
|
||
const[ws]=weekRange();
|
||
let h=stripHtml();
|
||
if(LOADING){h+='<div class="sk-tl skeleton"></div><div class="sk-tl skeleton"></div>';return h}
|
||
let wc=0;for(let i=0;i<7;i++){const d=new Date(ws);d.setDate(ws.getDate()+i);wc+=lessonsOf(tds(d)).length}
|
||
h+='<div class="week-days">';
|
||
for(let i=0;i<7;i++){
|
||
const d=new Date(ws);d.setDate(ws.getDate()+i);const ds=tds(d);const c=lessonsOf(ds);const wk=isWeekend(d);
|
||
let head='<div class="wd'+(ds===todayStr()?" today":"")+'"><div class="wd-head"><b>'+WD_SHORT[d.getDay()]+'</b><span>'+pd(d.getDate())+'</span>'+(c.length?'<span class="wd-cnt">'+c.length+' '+plural(c.length)+'</span>':"")+'</div>';
|
||
if(!c.length)head+='<div class="wd-empty">'+(wk?"Выходные":"Пока нет расписания")+'</div>';
|
||
else head+='<div class="wd-row">' + c.map(l=>'<div class="tom-row" onclick="openLesson(\''+esc(JSON.stringify({s:cleanSubject(l.subject_name),t:l.teacher_name,r:l.room_name,p:l.lesson,d:l.date,a:l.started_at+" — "+l.finished_at}).replace(/"/g,'"'))+'\')"><span class="tom-time">'+l.started_at+'</span><span class="tom-subj">'+esc(cleanSubject(l.subject_name))+'</span></div>').join("")+'</div>';
|
||
h+=head+'</div>';
|
||
}
|
||
h+='</div>';
|
||
h+=renderStatus();
|
||
h+=renderTimeline().replace('Расписание дня',dayOfWeekStr(parseDate(SELECTED_DAY))+', '+dateStr(parseDate(SELECTED_DAY)));
|
||
return h;
|
||
}
|
||
|
||
function render(){
|
||
updateHeader();
|
||
let html="";
|
||
if(PAGE==="week")html=renderWeek();
|
||
else html=renderToday();
|
||
const box=document.getElementById("errorBox");
|
||
if(ERROR){
|
||
box.innerHTML='<div class="error-state anim"><div class="error-state-title">Не удалось обновить расписание</div><div class="error-state-sub">'+esc(ERROR)+'</div><button class="btn-retry" onclick="doUpdate()">Повторить</button></div>';
|
||
}else box.innerHTML="";
|
||
document.getElementById("pageContent").innerHTML=html;
|
||
timeAgo();
|
||
}
|
||
|
||
function timeAgo(){
|
||
const el=document.getElementById("lastUpdate");
|
||
if(!LAST_UPDATE){el.textContent="";return}
|
||
const now=new Date();
|
||
const p=LAST_UPDATE.split(":").map(Number);
|
||
const then=new Date(now);then.setHours(p[0],p[1],p[2]||0,0);
|
||
let diff=Math.round((now-then)/1000);
|
||
if(diff<60)el.textContent="Обновлено только что";
|
||
else if(diff<3600)el.textContent="Обновлено "+Math.floor(diff/60)+" мин назад";
|
||
else el.textContent="Обновлено "+Math.floor(diff/3600)+" ч назад";
|
||
}
|
||
|
||
function toast(m,type){
|
||
const el=document.getElementById("toast");
|
||
el.textContent=m;el.className="toast "+(type||"")+" show";
|
||
setTimeout(()=>el.className="toast",3500);
|
||
}
|
||
|
||
function highlight(text,q){
|
||
const t=String(text||"");const qn=String(q||"").trim().toLowerCase().replace(/\s+/g,"");
|
||
if(!qn)return esc(t);
|
||
let norm="";
|
||
for(let k=0;k<t.length;k++){if(!/\s/.test(t[k]))norm+=t[k].toLowerCase()}
|
||
const i=norm.indexOf(qn);
|
||
if(i<0)return esc(t);
|
||
const idx=[];let count=0;
|
||
for(let k=0;k<t.length;k++){
|
||
if(/\s/.test(t[k]))continue;
|
||
if(count>=i&&count<i+qn.length)idx.push(k);
|
||
count++;
|
||
}
|
||
const s0=idx[0],s1=idx[idx.length-1];
|
||
return esc(t.slice(0,s0))+'<mark>'+esc(t.slice(s0,s1+1))+'</mark>'+esc(t.slice(s1+1));
|
||
}
|
||
|
||
function renderSearchResults(q){
|
||
const box=document.getElementById("searchResults");
|
||
const qi=String(q||"").trim().toLowerCase().replace(/\s+/g,"");
|
||
if(!qi){box.classList.remove("show");box.innerHTML="";return}
|
||
const res=[];
|
||
ALL.forEach(l=>{
|
||
const subj=cleanSubject(l.subject_name),teach=(l.teacher_name||""),room=String(l.room_name||"");
|
||
const hay=((subj+" "+teach+" "+room).toLowerCase()).replace(/\s+/g,"");
|
||
if(hay.indexOf(qi)>=0)res.push(l);
|
||
});
|
||
if(!res.length){box.innerHTML='<div class="sr-hint">Ничего не найдено по запросу «'+esc(q)+'»</div>';box.classList.add("show");return}
|
||
box.innerHTML=res.slice(0,12).map(l=>{
|
||
const payload=esc(JSON.stringify({s:cleanSubject(l.subject_name),t:l.teacher_name,r:l.room_name,p:l.lesson,d:l.date,a:l.started_at+' — '+l.finished_at}).replace(/"/g,'"'));
|
||
return '<button class="sr-item fadein" onclick="searchPick(\''+payload+'\')">'
|
||
+'<b>'+highlight(cleanSubject(l.subject_name),qi)+'</b>'
|
||
+'<span class="sr-meta"><span><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>'+dateStr(parseDate(l.date))+' · '+WD_SHORT[parseDate(l.date).getDay()]+'</span>'
|
||
+'<span><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>'+l.started_at+'</span>'
|
||
+'<span><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>'+esc(roomShort(l.room_name))+'</span></span>'
|
||
+'</button>';
|
||
}).join("");
|
||
box.classList.add("show");
|
||
}
|
||
|
||
function openSearchBox(){
|
||
const i=document.getElementById("searchInput");
|
||
i.focus();i.select();
|
||
renderSearchResults(i.value);
|
||
}
|
||
function closeSearchBox(){
|
||
const i=document.getElementById("searchInput");
|
||
const box=document.getElementById("searchResults");
|
||
i.value="";box.classList.remove("show");box.innerHTML="";
|
||
i.blur();
|
||
}
|
||
function searchPick(json){
|
||
try{
|
||
const d=JSON.parse(json.replace(/"/g,'"'));
|
||
SELECTED_DAY=d.d;PAGE="today";
|
||
document.querySelectorAll(".nav-item,.mob-nav").forEach(b=>b.classList.toggle("active",b.dataset.page==="today"));
|
||
closeSearchBox();
|
||
render();
|
||
openLesson(json);
|
||
}catch(e){}
|
||
}
|
||
|
||
function openLesson(json){
|
||
try{
|
||
const d=JSON.parse(json.replace(/"/g,'"'));
|
||
document.getElementById("modalTitle").textContent=d.s;
|
||
document.getElementById("modalSub").textContent=(d.d?d.d+" · ":"")+"Пара "+d.p;
|
||
document.getElementById("modalBody").innerHTML=
|
||
'<div class="modal-row"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg><span>Время</span><strong>'+d.a+'</strong></div>'
|
||
+'<div class="modal-row"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg><span>Преподаватель</span><strong>'+esc(d.t||"—")+'</strong></div>'
|
||
+'<div class="modal-row"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg><span>Кабинет</span><strong>'+esc(d.r||"—")+'</strong></div>';
|
||
document.getElementById("modalBackdrop").classList.add("show");
|
||
document.getElementById("modal").classList.add("show");
|
||
}catch(e){}
|
||
}
|
||
|
||
function closeModal(){
|
||
document.getElementById("modalBackdrop").classList.remove("show");
|
||
document.getElementById("modal").classList.remove("show");
|
||
}
|
||
|
||
async function load(){
|
||
LOADING=true;render();
|
||
try{
|
||
const r=await fetch("/api/schedule");
|
||
if(!r.ok)throw new Error("HTTP "+r.status);
|
||
const data=await r.json();
|
||
ALL=Array.isArray(data)?data:[];
|
||
LOADING=false;render();
|
||
}catch(e){
|
||
LOADING=false;ERROR=e.message;render();
|
||
toast("Ошибка загрузки","error");
|
||
}
|
||
}
|
||
|
||
async function pollStatus(){
|
||
try{
|
||
const r=await fetch("/api/status");
|
||
const s=await r.json();
|
||
const btn=document.getElementById("refreshBtn");
|
||
const txt=document.getElementById("refreshText");
|
||
if(s.running){btn.classList.add("spin");btn.disabled=true;txt.textContent="Обновляется...";}
|
||
else{btn.classList.remove("spin");btn.disabled=false;txt.textContent="Обновить";}
|
||
if(s.last_update){LAST_UPDATE=s.last_update;timeAgo();}
|
||
if(s.last_error){ERROR=s.last_error;render();}
|
||
}catch(e){}
|
||
}
|
||
|
||
async function doUpdate(){
|
||
try{
|
||
const r=await fetch("/api/update",{method:"POST"});
|
||
const j=await r.json();
|
||
if(!j.ok){toast(j.message,"error");return}
|
||
toast("Обновление запущено","success");
|
||
const t=setInterval(async()=>{
|
||
await pollStatus();
|
||
await loadLogs(true);
|
||
const btn=document.getElementById("refreshBtn");
|
||
if(!btn.classList.contains("spin")){clearInterval(t);await load();await loadLogs(true);}
|
||
},1500);
|
||
}catch(e){toast("Ошибка","error")}
|
||
}
|
||
|
||
function toggleLogs(){
|
||
const p=document.getElementById("logPanel");
|
||
p.classList.toggle("open");
|
||
if(p.classList.contains("open"))loadLogs(true);
|
||
}
|
||
|
||
async function loadLogs(force){
|
||
try{
|
||
const panel=document.getElementById("logPanel");
|
||
const body=document.getElementById("logList");
|
||
const badge=document.getElementById("logBadge");
|
||
if(!panel||(!panel.classList.contains("open")&&!force))return;
|
||
const r=await fetch("/api/logs");
|
||
const logs=await r.json();
|
||
badge.textContent=logs.length||"0";
|
||
if(!logs.length){body.innerHTML='<div class="log-empty">Пока записей нет — обновите расписание</div>';return}
|
||
body.innerHTML=logs.slice().reverse().map(e=>{
|
||
const lvl=e.level==="ok"?"ok":e.level==="warning"?"warning":e.level==="error"?"error":"info";
|
||
return '<div class="log-item '+lvl+'"><span class="log-dot"></span><span class="log-time">'+esc(e.time)+'</span><span class="log-text">'+esc(e.text)+'</span></div>';
|
||
}).join("");
|
||
}catch(e){}
|
||
}
|
||
|
||
document.addEventListener("keydown",e=>{
|
||
if(e.key==="Escape"){if(document.getElementById("searchResults").classList.contains("show")||document.activeElement===document.getElementById("searchInput")){closeSearchBox();e.preventDefault();}else closeModal();return}
|
||
if(e.target.matches("input")||e.target.matches("textarea"))return;
|
||
if(e.key==="/"&&!e.ctrlKey&&!e.metaKey&&!e.altKey){e.preventDefault();openSearchBox();return}
|
||
if(e.key.toLowerCase()==="r"&&!e.ctrlKey&&!e.metaKey){e.preventDefault();doUpdate()}
|
||
if(e.key.toLowerCase()==="t"&&!e.ctrlKey&&!e.metaKey){e.preventDefault();switchPage("today")}
|
||
});
|
||
|
||
document.getElementById("searchInput").addEventListener("input",e=>renderSearchResults(e.target.value));
|
||
document.getElementById("searchInput").addEventListener("focus",()=>{renderSearchResults(document.getElementById("searchInput").value)});
|
||
document.addEventListener("click",e=>{
|
||
const s=document.querySelector(".search");
|
||
if(s&&!s.contains(e.target))document.getElementById("searchResults").classList.remove("show");
|
||
});
|
||
|
||
(function(){
|
||
const saved=localStorage.getItem("triron-theme");
|
||
const pref=window.matchMedia("(prefers-color-scheme:dark)").matches?"dark":"light";
|
||
const theme=saved||pref;
|
||
document.documentElement.setAttribute("data-theme",theme);
|
||
updateThemeUI(theme);
|
||
})();
|
||
|
||
setInterval(timeAgo,30000);
|
||
setInterval(()=>{if(PAGE==="today"){updateHeader();render();}},35000);
|
||
updateHeader();
|
||
load();
|
||
loadLogs(true);
|
||
setInterval(pollStatus,10000);
|
||
setInterval(()=>loadLogs(true),30000);
|
||
</script>
|
||
</body>
|
||
</html></html>"""
|
||
|
||
|
||
def handle_signal(signum, frame):
|
||
log.info("Signal %s received. Shutting down...", signum)
|
||
try:
|
||
os._exit(0)
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
signal.signal(signal.SIGINT, handle_signal)
|
||
signal.signal(signal.SIGTERM, handle_signal)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
log.info("=" * 50)
|
||
log.info("Schedule Web Server starting...")
|
||
log.info("Session file: %s (exists: %s)", SESSION_FILE, SESSION_FILE.exists())
|
||
log.info("Schedule file: %s (exists: %s)", SCHEDULE_FILE, SCHEDULE_FILE.exists())
|
||
log.info("Log file: %s", LOG_FILE)
|
||
log.info("Server: http://127.0.0.1:5000")
|
||
log.info("=" * 50)
|
||
log_event("info", "Сервис запущен. Обновление расписания выполняется автоматически.")
|
||
|
||
try:
|
||
app.run(host="0.0.0.0", port=5000, debug=False)
|
||
except Exception as e:
|
||
log.critical("Server crashed: %s", e, exc_info=True)
|
||
finally:
|
||
log.info("Server stopped.")
|
||
logging.shutdown()
|