diff --git a/app.py b/app.py index c08da9e..fca0d87 100644 --- a/app.py +++ b/app.py @@ -27,6 +27,8 @@ from urllib.parse import quote, urlencode import traceback import contextlib import os +import html +import re from parsers.parser_game import run_parser_game from parsers.parser_players import run_parser_players from parsers.parser_schedule import run_parser_schedule @@ -1924,6 +1926,230 @@ async def list_vmix_clients(): return {"items": clients} + +INFO_TEMPLATE_PATH = BASE_DIR / "templates" / "info.html" + + +def _clean_update_text(value: str | None) -> str: + return (value or "").strip() + + +def _escape_text(value: str | None) -> str: + return html.escape(_clean_update_text(value), quote=True) + + +def _split_lines(value: str | None) -> list[str]: + return [line.strip() for line in (value or "").splitlines() if line.strip()] + + +def _format_update_date(value: str | None) -> str: + value = _clean_update_text(value) + if re.fullmatch(r"\d{4}-\d{2}-\d{2}", value): + try: + return datetime.strptime(value, "%Y-%m-%d").strftime("%d.%m.%Y") + except ValueError: + return value + return value + + +def _paragraphs_html(value: str | None) -> str: + lines = _split_lines(value) + if not lines: + return "

Описание не заполнено.

" + + return "\n".join(f"

{_escape_text(line)}

" for line in lines) + + +def _normalize_update_image_path(value: str | None) -> str: + path = _clean_update_text(value) + if not path: + return "" + + # Удобный вариант для админки: можно ввести только имя файла, + # например "no photo.png" — оно превратится в /static/docs/no photo.png. + if path.startswith("static/"): + path = "/" + path + elif path.startswith("docs/"): + path = "/static/" + path + elif not path.startswith(("/static/", "http://", "https://")): + path = "/static/docs/" + path.lstrip("/") + + return path + + +def _image_html(value: str | None, alt: str | None = None) -> str: + path = _normalize_update_image_path(value) + if not path: + return "" + + return f'\n {_escape_text(alt or ' + + +def _build_update_block( + version: str, + date: str, + operator_actions: str, + item_badges: list[str], + item_titles: list[str], + item_texts: list[str], + item_images: list[str] | None = None, +) -> str: + badge_meta = { + "new": ("new", "Новое"), + "fix": ("fix", "Исправлено"), + "improve": ("improve", "Улучшено"), + "important": ("important", "Важно"), + } + + version_html = _escape_text(version) + date_html = _escape_text(date) + + actions = _split_lines(operator_actions) + if actions: + actions_html = "\n".join(f"
  • {_escape_text(action)}
  • " for action in actions) + else: + actions_html = "
  • Ознакомиться с обновлением перед работой с матчем.
  • " + + item_images = item_images or [] + + update_items: list[str] = [] + max_len = max(len(item_titles), len(item_texts), len(item_badges), len(item_images), 1) + + for index in range(max_len): + title = _clean_update_text(item_titles[index] if index < len(item_titles) else "") + text = _clean_update_text(item_texts[index] if index < len(item_texts) else "") + badge = _clean_update_text(item_badges[index] if index < len(item_badges) else "new") + image = _clean_update_text(item_images[index] if index < len(item_images) else "") + + if not title and not text: + continue + + badge_class, badge_label = badge_meta.get(badge, badge_meta["new"]) + item_title_html = _escape_text(title or "Без названия") + item_text_html = _paragraphs_html(text) + _image_html(image, title or "Скриншот обновления") + + update_items.append( + f'''
    + {badge_label} + +
    +
    {item_title_html}
    + +
    + {item_text_html} +
    +
    +
    ''' + ) + + if not update_items: + update_items.append( + '''
    + Новое + +
    +
    Обновление системы
    + +
    +

    Описание не заполнено.

    +
    +
    +
    ''' + ) + + items_html = "\n".join(update_items) + + return f''' +
    + +
    +
    +
    +
    Версия {version_html}
    +
    {date_html}
    +
    +
    +
    + +
    +
    Что сделать оператору
    +
      + {actions_html} +
    +
    + +
    +{items_html} +
    +
    +
    +
    +'''.rstrip() + + +def _update_latest_summary(html_text: str, version: str, date: str, item_titles: list[str]) -> str: + titles = [title.strip() for title in item_titles if title and title.strip()] + if not titles: + titles = ["Добавлено новое обновление системы."] + + list_items = "\n".join(f"
  • {_escape_text(title)}
  • " for title in titles[:5]) + summary_html = f'''
    +
    Последние изменения: версия {_escape_text(version)} от {_escape_text(date)}
    + +
    ''' + + pattern = re.compile(r'
    .*?
    ', re.S) + return pattern.sub(summary_html, html_text, count=1) + + +def add_update_to_info_template( + version: str, + date: str, + operator_actions: str, + item_badges: list[str], + item_titles: list[str], + item_texts: list[str], + item_images: list[str] | None = None, +) -> None: + version = _clean_update_text(version) + date = _format_update_date(date) + + if not version: + raise ValueError("Укажите номер версии.") + if not date: + raise ValueError("Укажите дату обновления.") + + html_text = INFO_TEMPLATE_PATH.read_text(encoding="utf-8") + insert_marker = '
    ' + + if insert_marker not in html_text: + raise RuntimeError("Не найден блок updates-timeline в templates/info.html.") + + # У старых карточек убираем отметку latest, чтобы зелёная лента NEW была только у свежего обновления. + html_text = html_text.replace('update-card latest', 'update-card') + + new_update_html = _build_update_block( + version=version, + date=date, + operator_actions=operator_actions, + item_badges=item_badges, + item_titles=item_titles, + item_texts=item_texts, + item_images=item_images, + ) + + html_text = html_text.replace( + insert_marker, + insert_marker + "\n" + new_update_html, + 1, + ) + html_text = _update_latest_summary(html_text, version, date, item_titles) + + INFO_TEMPLATE_PATH.write_text(html_text, encoding="utf-8") + + @app.get("/info", response_class=HTMLResponse) def info_page(request: Request): return templates.TemplateResponse(name="info.html", request=request, context={}) @@ -1934,6 +2160,8 @@ def render_admin_db_index( parser_output: str | None = None, parser_name: str | None = None, parser_success: bool | None = None, + update_output: str | None = None, + update_success: bool | None = None, ): return templates.TemplateResponse( name="admin_db_index.html", @@ -1943,6 +2171,8 @@ def render_admin_db_index( "parser_output": parser_output, "parser_name": parser_name, "parser_success": parser_success, + "update_output": update_output, + "update_success": update_success, }, ) @@ -2002,6 +2232,45 @@ def admin_db_run_parser(request: Request, parser_name: str = Form(...)): ) + +@app.post("/admin/db/add-update", response_class=HTMLResponse) +def admin_db_add_update( + request: Request, + version: str = Form(...), + date: str = Form(...), + operator_actions: str = Form(default=""), + item_badge: list[str] = Form(default=[]), + item_title: list[str] = Form(default=[]), + item_text: list[str] = Form(default=[]), + item_image: list[str] = Form(default=[]), +): + current_user = getattr(request.state, "current_user", None) + if not current_user or current_user.get("role") != "admin": + return RedirectResponse(url="/login", status_code=303) + + try: + add_update_to_info_template( + version=version, + date=date, + operator_actions=operator_actions, + item_badges=item_badge, + item_titles=item_title, + item_texts=item_text, + item_images=item_image, + ) + return render_admin_db_index( + request, + update_output=f"Обновление версии {version.strip()} добавлено в templates/info.html и будет отображаться сверху на странице /info.", + update_success=True, + ) + except Exception: + return render_admin_db_index( + request, + update_output="Ошибка при добавлении обновления:\n" + traceback.format_exc(), + update_success=False, + ) + + @app.post("/admin/db/create-account", response_class=HTMLResponse) def admin_db_create_account( request: Request, diff --git a/static/docs/no photo.png b/static/docs/no photo.png new file mode 100644 index 0000000..ac7dcdf Binary files /dev/null and b/static/docs/no photo.png differ diff --git a/static/docs/scorebug with title.png b/static/docs/scorebug with title.png new file mode 100644 index 0000000..25edbeb Binary files /dev/null and b/static/docs/scorebug with title.png differ diff --git a/templates/admin_db_index.html b/templates/admin_db_index.html index 837749c..8842916 100644 --- a/templates/admin_db_index.html +++ b/templates/admin_db_index.html @@ -150,7 +150,8 @@ pointer-events: none; } - .account-modal { + .account-modal, + .update-modal { position: fixed; inset: 0; display: none; @@ -161,11 +162,13 @@ padding: 20px; } - .account-modal.active { + .account-modal.active, + .update-modal.active { display: flex; } - .account-modal-card { + .account-modal-card, + .update-modal-card { width: min(100%, 460px); background: rgba(12, 17, 26, 0.98); border: 1px solid rgba(0, 255, 136, 0.28); @@ -174,13 +177,21 @@ padding: 24px; } - .account-modal-title { + .update-modal-card { + width: min(100%, 760px); + max-height: 92vh; + overflow-y: auto; + } + + .account-modal-title, + .update-modal-title { font-size: 22px; font-weight: 700; margin-bottom: 8px; } - .account-modal-subtitle { + .account-modal-subtitle, + .update-modal-subtitle { color: var(--muted); margin-bottom: 18px; line-height: 1.5; @@ -200,7 +211,8 @@ } .field-input, - .field-select { + .field-select, + .field-textarea { width: 100%; min-height: 44px; border-radius: 12px; @@ -212,12 +224,75 @@ outline: none; } + .field-textarea { + min-height: 96px; + padding: 12px 14px; + resize: vertical; + line-height: 1.45; + font-family: Arial, sans-serif; + } + .field-input:focus, - .field-select:focus { + .field-select:focus, + .field-textarea:focus { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(0, 255, 136, 0.12); } + .update-form-grid { + display: grid; + grid-template-columns: 1fr 180px; + gap: 14px; + } + + .update-items-editor { + display: grid; + gap: 14px; + } + + .update-item-form { + display: grid; + grid-template-columns: 160px minmax(0, 1fr); + gap: 12px; + padding: 14px; + border: 1px solid var(--border); + border-radius: 14px; + background: rgba(255, 255, 255, 0.025); + } + + .update-item-text, + .update-item-image { + grid-column: 1 / -1; + } + + .helper-text { + color: var(--muted); + font-size: 12px; + line-height: 1.45; + } + + .add-row-btn { + min-height: 38px; + padding: 0 14px; + border-radius: 999px; + border: 1px dashed rgba(0, 255, 136, 0.45); + background: rgba(0, 255, 136, 0.06); + color: var(--accent); + font-weight: 700; + cursor: pointer; + } + + .add-row-btn:hover { + background: rgba(0, 255, 136, 0.12); + } + + @media (max-width: 720px) { + .update-form-grid, + .update-item-form { + grid-template-columns: 1fr; + } + } + .modal-actions { display: flex; gap: 12px; @@ -308,6 +383,7 @@
    +
    {% if account_output %} @@ -339,6 +415,21 @@
    {{ parser_output }}
    {% endif %} + + {% if update_output %} +
    +
    Добавление обновления
    +
    + Статус: + {% if update_success %} + успешно + {% else %} + ошибка + {% endif %} +
    +
    {{ update_output }}
    +
    + {% endif %} @@ -376,6 +467,78 @@ + + +