Docs: rewrite README with clear Docker and Docker Compose instructions; refactor app into package (auth, admin, pages, db); keep single entrypoint app.py

This commit is contained in:
2025-09-04 10:48:04 +03:00
parent cc767dcf46
commit f4e1bc8a95
8 changed files with 386 additions and 453 deletions

118
README.md
View File

@@ -1,51 +1,101 @@
# Flask UUID Pages Admin (SQLite) # ForMe — мини CMS на Flask (SQLite)
Минималистичное Flaskприложение с SQLite: Небольшое приложение для публикации HTMLстраниц по UUID.
- Админка доступна только пользователю `ruslan` с паролем `utOgbZ09ruslan` (по умолчанию).
- В админке можно вставлять HTML и публиковать страницы.
- Для каждой страницы генерируется UUIDссылка `/p/<uuid>`.
- Неавторизованный пользователь не видит админку и главную страницу (404). Страницы по UUID доступны всем, у кого есть ссылка.
## Запуск локально Возможности
- Авторизация администратора (логин/пароль из переменных окружения).
- Публикация HTML со свободной разметкой; генерация ссылки `/p/<uuid>`.
- Редактирование и удаление опубликованных страниц в админке.
- На опубликованных страницах у авторизованных отображается плавающая кнопка “Админка”.
- Водяной знак “Made by Ruslan” в левом верхнем углу опубликованных страниц.
1. Создайте и активируйте виртуальное окружение (опционально): Важно: функционал экспорта в PDF/Word удалён — код и зависимости очищены.
Windows PowerShell: ## Локальный запуск
```powershell 1) Создать и активировать виртуальное окружение (Windows PowerShell):
python -m venv .venv
.venv\\Scripts\\Activate.ps1
```
2. Установите зависимости: ```powershell
python -m venv .venv
.venv\Scripts\Activate.ps1
```
```powershell 2) Установить зависимости:
pip install -r requirements.txt
```
3. (Опционально) Переопределите настройки через переменные окружения: ```powershell
pip install -r requirements.txt
```
```powershell 3) (Опционально) Настроить переменные окружения:
$env:SECRET_KEY = "your-strong-secret-key"
$env:ADMIN_USERNAME = "ruslan"
$env:ADMIN_PASSWORD = "utOgbZ09ruslan"
```
4. Запустите сервер разработки: ```powershell
$env:SECRET_KEY = "your-strong-secret"
$env:ADMIN_USERNAME = "ruslan"
$env:ADMIN_PASSWORD = "utOgbZ09ruslan"
# путь для БД по умолчанию: ./app.db
```
```powershell 4) Запустить приложение:
python app.py
``` ```powershell
python app.py
```
5) Открыть http://localhost:5000
## Маршруты ## Маршруты
- `/login` — форма входа в админку. - `/login`вход
- `/admin` — админ‑панель (только для авторизованных, иначе 404). - `/admin` — панель публикации и список страниц (только для авторизованных)
- `/p/<uuid>` — просмотр опубликованной страницы по UUID. - `/p/<uuid>` — просмотр опубликованной страницы
- `/` — редирект в админку для авторизованных, иначе 404. - `/` — редиректит на `/login` или `/admin`
## Замечания по безопасности ## Запуск в Docker
- HTML хранится и отдаётся как есть. Предполагается, что контент вводит доверенный админ. В проекте есть `Dockerfile` (gunicorn) и `docker-compose.yml` с томом для БД.
- В продакшне обязательно задайте `SECRET_KEY` и используйте HTTPS; настройте защищённые cookies.
Вариант A. Docker (без compose)
```bash
docker build -t forme .
docker run --name forme-web -p 5000:5000 \
-e SECRET_KEY=prod-secret \
-e ADMIN_USERNAME=ruslan \
-e ADMIN_PASSWORD=utOgbZ09ruslan \
-e DATABASE=/data/app.db \
-v forme_appdb:/data \
-d forme
```
Вариант B. Docker Compose
```bash
docker compose build
docker compose up -d
```
По умолчанию:
- Приложение доступно на `http://localhost:5000`
- Переменные окружения можно переопределить через `.env`
- База хранится в томе `appdb` по пути контейнера `/data/app.db`
## Структура проекта
```
app.py # точка входа
app/
__init__.py # create_app + регистрация блюпринтов
db.py # инициализация/доступ к SQLite
auth.py # логин/логаут и context processor
admin.py # админка: создать/редактировать/удалить
pages.py # публичные страницы / и /p/<uuid>
templates/
base.html, login.html, admin.html, edit.html
```
## Заметки по продакшену
- Обязательно задайте `SECRET_KEY` через переменные окружения.
- Смените дефолтные `ADMIN_USERNAME/ADMIN_PASSWORD`.
- Вынесите БД в том/персистентный диск.

338
app.py
View File

@@ -1,340 +1,8 @@
import os from app import create_app
import sqlite3
import uuid as uuid_lib
from datetime import datetime
from flask import (
Flask,
g,
render_template,
request,
redirect,
url_for,
session,
abort,
flash,
Response,
send_file,
)
from io import BytesIO
import re
import unicodedata
from urllib.parse import quote as urlquote
from xhtml2pdf import pisa # type: ignore
from docx import Document # type: ignore
from htmldocx import HtmlToDocx # type: ignore
def create_app():
app = Flask(__name__)
# Basic config
app.config["SECRET_KEY"] = os.environ.get(
"SECRET_KEY",
# For production, override via env var. This default is for local/dev only.
"dev-secret-change-me",
)
app.config["DATABASE"] = os.path.join(os.path.dirname(__file__), "app.db")
# Admin credentials (can be overridden via env)
app.config["ADMIN_USERNAME"] = os.environ.get("ADMIN_USERNAME", "ruslan")
app.config["ADMIN_PASSWORD"] = os.environ.get("ADMIN_PASSWORD", "utOgbZ09ruslan")
# --------------------
# Database helpers
# --------------------
def get_db():
if "db" not in g:
g.db = sqlite3.connect(app.config["DATABASE"]) # type: ignore[attr-defined]
g.db.row_factory = sqlite3.Row
return g.db
def close_db(e=None):
db = g.pop("db", None)
if db is not None:
db.close()
def init_db():
db = get_db()
db.execute(
"""
CREATE TABLE IF NOT EXISTS pages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
uuid TEXT NOT NULL UNIQUE,
title TEXT NOT NULL DEFAULT '',
html TEXT NOT NULL,
created_at TEXT NOT NULL
);
"""
)
# Migration: add 'title' column for older DB versions
try:
cols = {row[1] for row in db.execute("PRAGMA table_info(pages)").fetchall()}
if "title" not in cols:
db.execute("ALTER TABLE pages ADD COLUMN title TEXT NOT NULL DEFAULT ''")
except Exception:
pass
db.commit()
@app.before_request
def _before_request():
# Ensure DB exists
init_db()
@app.teardown_appcontext
def _teardown_appcontext(_=None):
close_db()
# --------------------
# Auth helpers
# --------------------
def is_logged_in() -> bool:
return bool(session.get("logged_in"))
def login_required():
if not is_logged_in():
# For non-auth users: show nothing (404), not even an admin hint
abort(404)
@app.context_processor
def inject_auth():
return {"logged_in": is_logged_in()}
# --------------------
# Routes
# --------------------
@app.route("/")
def index():
# Redirect unauthenticated users to login; authenticated users to admin.
if not is_logged_in():
return redirect(url_for("login"))
return redirect(url_for("admin"))
@app.route("/login", methods=["GET", "POST"])
def login():
if request.method == "POST":
username = request.form.get("username", "")
password = request.form.get("password", "")
if (
username == app.config["ADMIN_USERNAME"]
and password == app.config["ADMIN_PASSWORD"]
):
session["logged_in"] = True
flash("Успешный вход в админку.", "success")
return redirect(url_for("admin"))
flash("Неверные имя пользователя или пароль.", "error")
return render_template("login.html")
@app.route("/logout", methods=["POST"])
def logout():
session.clear()
# After logout, nothing is visible. Return 404-like behavior.
abort(404)
@app.route("/admin", methods=["GET", "POST"])
def admin():
login_required()
db = get_db()
if request.method == "POST":
title = request.form.get("title", "").strip()
html = request.form.get("html", "").strip()
if not html:
flash("HTML не может быть пустым.", "error")
else:
uid = uuid_lib.uuid4().hex
db.execute(
"INSERT INTO pages (uuid, title, html, created_at) VALUES (?, ?, ?, ?)",
(uid, title, html, datetime.utcnow().isoformat(timespec="seconds")),
)
db.commit()
flash("Страница опубликована.", "success")
return redirect(url_for("admin"))
pages = db.execute(
"SELECT id, uuid, title, created_at FROM pages ORDER BY id DESC"
).fetchall()
base_url = request.host_url.rstrip("/")
return render_template("admin.html", pages=pages, base_url=base_url)
@app.route("/admin/edit/<int:pid>", methods=["GET", "POST"])
def admin_edit(pid: int):
login_required()
db = get_db()
if request.method == "POST":
title = request.form.get("title", "").strip()
html = request.form.get("html", "").strip()
if not html:
flash("HTML не может быть пустым.", "error")
else:
db.execute(
"UPDATE pages SET title = ?, html = ? WHERE id = ?",
(title, html, pid),
)
db.commit()
flash("Страница обновлена.", "success")
return redirect(url_for("admin"))
row = db.execute(
"SELECT id, uuid, title, html, created_at FROM pages WHERE id = ?",
(pid,),
).fetchone()
if row is None:
abort(404)
return render_template("edit.html", page=row)
@app.route("/admin/delete/<int:pid>", methods=["POST"])
def admin_delete(pid: int):
login_required()
db = get_db()
cur = db.execute("DELETE FROM pages WHERE id = ?", (pid,))
db.commit()
if cur.rowcount:
flash("Страница удалена.", "success")
else:
flash("Страница не найдена.", "error")
return redirect(url_for("admin"))
@app.route("/p/<string:uid>")
def view_page(uid: str):
db = get_db()
row = db.execute("SELECT html, title FROM pages WHERE uuid = ?", (uid,)).fetchone()
if row is None:
abort(404)
# Show a floating admin button for authenticated users, otherwise serve raw HTML
html: str = row["html"]
# Inject <title> tag for better page naming
try:
page_title = row["title"]
except Exception:
page_title = ""
if page_title:
lower = html.lower()
if "</head>" in lower:
i = lower.rfind("</head>")
html = html[:i] + f"<title>{page_title}</title>" + html[i:]
elif "<html" in lower:
if "<body" in lower:
j = lower.find("<body")
html = html[:j] + f"<head><meta charset=\"utf-8\"><title>{page_title}</title></head>" + html[j:]
else:
html = f"<head><meta charset=\"utf-8\"><title>{page_title}</title></head>" + html
if is_logged_in():
admin_url = url_for("admin")
toolbar = (
'<div style="position:fixed;top:16px;right:16px;z-index:2147483647;">'
f'<a href="{admin_url}" '
'style="background:linear-gradient(135deg,#111,#333);color:#fff;padding:10px 14px;'
'border-radius:10px;box-shadow:0 8px 20px rgba(0,0,0,0.25);'
'text-decoration:none;font-weight:600;letter-spacing:.2px;'
'font-family:system-ui,-apple-system,Segoe UI,Roboto,Arial,sans-serif;"'
'>Админка</a></div>'
)
lower = html.lower()
if "</body>" in lower:
idx = lower.rfind("</body>")
html = html[:idx] + toolbar + html[idx:]
else:
html = html + toolbar
# Ensure watermark exists on published pages (top-left)
wm = (
"<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=Pacifico&display=swap\" rel=\"stylesheet\">"
"<div style=\"position:fixed;top:16px;left:16px;z-index:2147483647;"
"font-family:'Pacifico',cursive;letter-spacing:.2px;color:rgba(255,255,255,.92);"
"text-shadow:0 2px 8px rgba(0,0,0,.35);pointer-events:none;user-select:none;\">"
"Made by Ruslan"
"</div>"
)
# Export buttons (visible to everyone). If admin overlay exists, offset a bit.
top_offset = "60px" if is_logged_in() else "16px"
pdf_url = url_for("export_pdf", uid=uid)
docx_url = url_for("export_docx", uid=uid)
exports = (
f'<div style="position:fixed;top:{top_offset};right:16px;z-index:2147483647;display:flex;gap:8px;">'
f'<a href="{pdf_url}" style="background:#2b2f3b;color:#fff;padding:8px 10px;border-radius:8px;text-decoration:none;font-weight:600;">PDF</a>'
f'<a href="{docx_url}" style="background:#2b2f3b;color:#fff;padding:8px 10px;border-radius:8px;text-decoration:none;font-weight:600;">Word</a>'
'</div>'
)
overlays = wm + exports
lower_all = html.lower()
if "</body>" in lower_all:
i2 = lower_all.rfind("</body>")
html = html[:i2] + overlays + html[i2:]
else:
html = html + overlays
return Response(html, mimetype="text/html; charset=utf-8")
def _fetch_page(uid: str):
db = get_db()
row = db.execute("SELECT title, html FROM pages WHERE uuid = ?", (uid,)).fetchone()
if row is None:
abort(404)
return row
def _sanitize_html_for_pdf(html: str) -> str:
# xhtml2pdf плохо переносит современный CSS; вычищаем стили/скрипты
html = re.sub(r"<style[^>]*>.*?</style>", "", html, flags=re.I | re.S)
html = re.sub(r"\sstyle=(\"|\')(.*?)\1", "", html, flags=re.I | re.S)
html = re.sub(r"<script[^>]*>.*?</script>", "", html, flags=re.I | re.S)
return html
def _wrap_html_for_export(title: str, html: str) -> str:
head_title = f"<title>{title}</title>" if title else ""
return (
"<!doctype html><html lang='ru'><head><meta charset='utf-8'>" + head_title +
"<style>body{font-family:Arial,Helvetica,sans-serif;}</style></head><body>" +
html + "</body></html>"
)
def _safe_download_name(title: str, uid: str, ext: str) -> str:
base = (title or f"page-{uid[:8]}").strip()
ascii_base = unicodedata.normalize("NFKD", base).encode("ascii", "ignore").decode("ascii")
ascii_base = re.sub(r"[^A-Za-z0-9_.-]+", "_", ascii_base).strip("_") or f"page-{uid[:8]}"
return f"{ascii_base}.{ext}"
@app.route("/p/<string:uid>.pdf")
def export_pdf(uid: str):
row = _fetch_page(uid)
title = row["title"] or f"page-{uid[:8]}"
cleaned = _sanitize_html_for_pdf(row["html"])
html_doc = _wrap_html_for_export(title, cleaned)
out = BytesIO()
pisa.CreatePDF(src=html_doc, dest=out)
out.seek(0)
filename = _safe_download_name(title, uid, "pdf")
# send_file properly sets Content-Disposition with download_name
return send_file(out, mimetype="application/pdf", as_attachment=True, download_name=filename)
@app.route("/p/<string:uid>.docx")
def export_docx(uid: str):
row = _fetch_page(uid)
title = row["title"] or f"page-{uid[:8]}"
html_doc = _wrap_html_for_export(title, row["html"])
doc = Document()
HtmlToDocx().add_html_to_document(html_doc, doc)
out = BytesIO()
doc.save(out)
out.seek(0)
filename = _safe_download_name(title, uid, "docx")
return send_file(
out,
mimetype="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
as_attachment=True,
download_name=filename,
)
# Optional: simple 404 page to keep things minimal
@app.errorhandler(404)
def not_found(_):
return ("", 404)
return app
app = create_app() app = create_app()
if __name__ == "__main__": if __name__ == "__main__":
# Run development server import os
app.run(host="0.0.0.0", port=int(os.environ.get("PORT", 5000)), debug=True) app.run(host="0.0.0.0", port=int(os.environ.get("PORT", 5000)), debug=True)

34
app/__init__.py Normal file
View File

@@ -0,0 +1,34 @@
import os
from flask import Flask
from . import db
from .auth import auth_bp
from .admin import admin_bp
from .pages import pages_bp
def create_app() -> Flask:
app = Flask(__name__)
# Basic config
app.config["SECRET_KEY"] = os.environ.get(
"SECRET_KEY",
"dev-secret-change-me",
)
app.config["DATABASE"] = os.environ.get(
"DATABASE",
os.path.join(os.path.dirname(os.path.dirname(__file__)), "app.db"),
)
app.config["ADMIN_USERNAME"] = os.environ.get("ADMIN_USERNAME", "ruslan")
app.config["ADMIN_PASSWORD"] = os.environ.get("ADMIN_PASSWORD", "utOgbZ09ruslan")
# Init DB hooks
db.init_app(app)
# Register blueprints
app.register_blueprint(auth_bp)
app.register_blueprint(admin_bp)
app.register_blueprint(pages_bp)
return app

73
app/admin.py Normal file
View File

@@ -0,0 +1,73 @@
import uuid as uuid_lib
from datetime import datetime
from flask import Blueprint, render_template, request, redirect, url_for, flash
from .db import get_db
from .auth import login_required
admin_bp = Blueprint("admin", __name__, url_prefix="/admin")
@admin_bp.route("/", methods=["GET", "POST"])
def index():
login_required()
db = get_db()
if request.method == "POST":
title = request.form.get("title", "").strip()
html = request.form.get("html", "").strip()
if not html:
flash("HTML не может быть пустым.", "error")
else:
uid = uuid_lib.uuid4().hex
db.execute(
"INSERT INTO pages (uuid, title, html, created_at) VALUES (?, ?, ?, ?)",
(uid, title, html, datetime.utcnow().isoformat(timespec="seconds")),
)
db.commit()
flash("Страница опубликована.", "success")
return redirect(url_for("admin.index"))
pages = db.execute(
"SELECT id, uuid, title, created_at FROM pages ORDER BY id DESC"
).fetchall()
from flask import request as _request # local import
base_url = _request.host_url.rstrip("/")
return render_template("admin.html", pages=pages, base_url=base_url)
@admin_bp.route("/edit/<int:pid>", methods=["GET", "POST"])
def edit(pid: int):
login_required()
db = get_db()
if request.method == "POST":
title = request.form.get("title", "").strip()
html = request.form.get("html", "").strip()
if not html:
flash("HTML не может быть пустым.", "error")
else:
db.execute("UPDATE pages SET title = ?, html = ? WHERE id = ?", (title, html, pid))
db.commit()
flash("Страница обновлена.", "success")
return redirect(url_for("admin.index"))
row = db.execute("SELECT id, uuid, title, html, created_at FROM pages WHERE id = ?", (pid,)).fetchone()
if row is None:
from flask import abort as _abort
_abort(404)
return render_template("edit.html", page=row)
@admin_bp.route("/delete/<int:pid>", methods=["POST"])
def delete(pid: int):
login_required()
db = get_db()
cur = db.execute("DELETE FROM pages WHERE id = ?", (pid,))
db.commit()
if cur.rowcount:
flash("Страница удалена.", "success")
else:
flash("Страница не найдена.", "error")
return redirect(url_for("admin.index"))

41
app/auth.py Normal file
View File

@@ -0,0 +1,41 @@
from flask import Blueprint, current_app, render_template, request, redirect, url_for, session, abort, flash
auth_bp = Blueprint("auth", __name__)
def is_logged_in() -> bool:
return bool(session.get("logged_in"))
def login_required():
if not is_logged_in():
abort(404)
@auth_bp.app_context_processor
def inject_auth():
return {"logged_in": is_logged_in()}
@auth_bp.route("/login", methods=["GET", "POST"])
def login():
if request.method == "POST":
username = request.form.get("username", "")
password = request.form.get("password", "")
if (
username == current_app.config["ADMIN_USERNAME"]
and password == current_app.config["ADMIN_PASSWORD"]
):
session["logged_in"] = True
flash("Вход выполнен", "success")
return redirect(url_for("admin.index"))
flash("Неверные логин или пароль", "error")
return render_template("login.html")
@auth_bp.route("/logout", methods=["POST"])
def logout():
session.clear()
abort(404)

50
app/db.py Normal file
View File

@@ -0,0 +1,50 @@
import sqlite3
from datetime import datetime
from flask import g, current_app
def get_db():
if "db" not in g:
g.db = sqlite3.connect(current_app.config["DATABASE"]) # type: ignore[attr-defined]
g.db.row_factory = sqlite3.Row
return g.db
def close_db(_=None):
db = g.pop("db", None)
if db is not None:
db.close()
def init_db():
db = get_db()
db.execute(
"""
CREATE TABLE IF NOT EXISTS pages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
uuid TEXT NOT NULL UNIQUE,
title TEXT NOT NULL DEFAULT '',
html TEXT NOT NULL,
created_at TEXT NOT NULL
);
"""
)
# Migrate: ensure title exists
try:
cols = {row[1] for row in db.execute("PRAGMA table_info(pages)").fetchall()}
if "title" not in cols:
db.execute("ALTER TABLE pages ADD COLUMN title TEXT NOT NULL DEFAULT ''")
except Exception:
pass
db.commit()
def init_app(app):
@app.before_request
def _before_request(): # noqa: D401
init_db()
@app.teardown_appcontext
def _teardown(_=None): # noqa: D401
close_db()

76
app/pages.py Normal file
View File

@@ -0,0 +1,76 @@
from flask import Blueprint, redirect, url_for, abort, Response
from .db import get_db
from .auth import is_logged_in
pages_bp = Blueprint("pages", __name__)
@pages_bp.route("/")
def home():
if not is_logged_in():
return redirect(url_for("auth.login"))
return redirect(url_for("admin.index"))
@pages_bp.route("/p/<string:uid>")
def view_page(uid: str):
db = get_db()
row = db.execute("SELECT html, title FROM pages WHERE uuid = ?", (uid,)).fetchone()
if row is None:
abort(404)
html: str = row["html"]
page_title = row["title"] or ""
if page_title:
lower = html.lower()
if "</head>" in lower:
i = lower.rfind("</head>")
html = html[:i] + f"<title>{page_title}</title>" + html[i:]
elif "<html" in lower:
if "<body" in lower:
j = lower.find("<body")
html = html[:j] + f"<head><meta charset=\"utf-8\"><title>{page_title}</title></head>" + html[j:]
else:
html = f"<head><meta charset=\"utf-8\"><title>{page_title}</title></head>" + html
# Admin button only for logged-in users
if is_logged_in():
admin_url = url_for("admin.index")
toolbar = (
'<div style="position:fixed;top:16px;right:16px;z-index:2147483647;">'
f'<a href="{admin_url}" '
'style="background:linear-gradient(135deg,#111,#333);color:#fff;padding:10px 14px;'
'border-radius:10px;box-shadow:0 8px 20px rgba(0,0,0,0.25);'
'text-decoration:none;font-weight:600;letter-spacing:.2px;'
'font-family:system-ui,-apple-system,Segoe UI,Roboto,Arial,sans-serif;"'
'>Админка</a></div>'
)
lower = html.lower()
if "</body>" in lower:
idx = lower.rfind("</body>")
html = html[:idx] + toolbar + html[idx:]
else:
html = html + toolbar
# Watermark for everyone (top-left)
wm = (
"<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=Pacifico&display=swap\" rel=\"stylesheet\">"
"<div style=\"position:fixed;top:16px;left:16px;z-index:2147483647;"
"font-family:'Pacifico',cursive;letter-spacing:.2px;color:rgba(255,255,255,.92);"
"text-shadow:0 2px 8px rgba(0,0,0,.35);pointer-events:none;user-select:none;\">"
"Made by Ruslan"
"</div>"
)
lower_all = html.lower()
if "</body>" in lower_all:
i2 = lower_all.rfind("</body>")
html = html[:i2] + wm + html[i2:]
else:
html = html + wm
return Response(html, mimetype="text/html; charset=utf-8")

View File

@@ -3,110 +3,50 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ title or 'ForMe' }}</title>
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Pacifico&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Pacifico&display=swap" rel="stylesheet">
<title>{{ title or 'Админка' }}</title>
<style> <style>
:root { :root {
--bg: #0b0e13; --bg: #0b0e13; --bg-soft: #11161f; --card: #121825; --text: #e6edf3; --muted: #97a3b6;
--bg-soft: #11161f; --accent: #4f8cff; --accent-2: #7a5cff; --ring: rgba(79,140,255,.45);
--card: #121825;
--text: #e6edf3;
--muted: #97a3b6;
--accent: #4f8cff;
--accent-2: #7a5cff;
--danger: #ff5c7a;
--success: #2ecc71;
--ring: rgba(79,140,255,.45);
} }
html, body { height: 100%; } html, body { height: 100%; }
body { body { margin: 0; font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif;
margin: 0; color: var(--text); background: radial-gradient(1200px 400px at 10% -10%, rgba(79,140,255,0.12), transparent 60%),
font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif; radial-gradient(1000px 400px at 100% 0%, rgba(122,92,255,0.10), transparent 60%), var(--bg); }
color: var(--text);
background:
radial-gradient(1200px 400px at 10% -10%, rgba(79,140,255,0.12), transparent 60%),
radial-gradient(1000px 400px at 100% 0%, rgba(122,92,255,0.10), transparent 60%),
var(--bg);
}
.container { max-width: 980px; margin: 0 auto; padding: 24px; } .container { max-width: 980px; margin: 0 auto; padding: 24px; }
header { position: sticky; top:0; z-index:1000; display:flex; align-items:center; justify-content:space-between;
header { padding:14px 18px; margin:0 0 18px 0; background: linear-gradient(180deg, rgba(18,24,37,.85), rgba(18,24,37,.65));
position: sticky; top: 0; z-index: 1000; backdrop-filter: saturate(1.2) blur(8px); border:1px solid rgba(255,255,255,.06); border-radius:14px; box-shadow:0 10px 30px rgba(0,0,0,.35); }
display: flex; align-items: center; justify-content: space-between; nav a { margin-left: 10px; }
padding: 14px 18px; margin: 0 0 18px 0; .card { background: var(--card); border: 1px solid rgba(255,255,255,.06); border-radius: 16px; padding: 20px; box-shadow: 0 12px 40px rgba(0,0,0,.35); }
background: linear-gradient(180deg, rgba(18,24,37,.85), rgba(18,24,37,.65)); .muted { color: var(--muted); font-size: .92rem; }
backdrop-filter: saturate(1.2) blur(8px);
border: 1px solid rgba(255,255,255,.06);
border-radius: 14px;
box-shadow: 0 10px 30px rgba(0,0,0,.35);
}
header nav { }
.card {
background: var(--card);
border: 1px solid rgba(255,255,255,.06);
border-radius: 16px; padding: 20px;
box-shadow: 0 12px 40px rgba(0,0,0,.35);
}
.muted { color: var(--muted); font-size: 0.92rem; }
.row { margin-bottom: 1rem; } .row { margin-bottom: 1rem; }
.btn { display:inline-block; padding:10px 14px; border:none; cursor:pointer; text-decoration:none; color:#fff; font-weight:600; letter-spacing:.2px;
.btn { background: linear-gradient(135deg, var(--accent), var(--accent-2)); border-radius:10px; box-shadow:0 10px 25px rgba(79,140,255,.35); }
display: inline-block; padding: 10px 14px; border: none; cursor: pointer; .btn.secondary { background: linear-gradient(135deg,#2a2f3a,#394356); box-shadow:none; }
text-decoration: none; color: #fff; font-weight: 600; letter-spacing: .2px; input[type=text], input[type=password], textarea { width:100%; color:var(--text); background:var(--bg-soft); border:1px solid rgba(255,255,255,.06);
background: linear-gradient(135deg, var(--accent), var(--accent-2)); border-radius:12px; padding:10px 12px; outline:none; }
border-radius: 10px; box-shadow: 0 10px 25px rgba(79,140,255,.35);
transition: transform .06s ease, box-shadow .15s ease;
}
.btn:hover { transform: translateY(-1px); box-shadow: 0 14px 30px rgba(79,140,255,.45); }
.btn.secondary { background: linear-gradient(135deg,#2a2f3a,#394356); box-shadow: none; }
input[type="text"], input[type="password"], textarea {
width: 100%; color: var(--text); background: var(--bg-soft);
border: 1px solid rgba(255,255,255,.06);
border-radius: 12px; padding: 10px 12px; outline: none;
box-shadow: inset 0 0 0 1px transparent;
}
input:focus, textarea:focus { box-shadow: 0 0 0 4px var(--ring); } input:focus, textarea:focus { box-shadow: 0 0 0 4px var(--ring); }
textarea { min-height: 260px; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } textarea { min-height:260px; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
.flash { padding:10px 12px; margin-bottom:1rem; border-radius:10px; border:1px solid rgba(255,255,255,.08); }
.flash { padding: 10px 12px; margin-bottom: 1rem; border-radius: 10px; border: 1px solid rgba(255,255,255,.08); }
.flash.success { background: rgba(46,204,113,.12); color: #bbf7d0; } .flash.success { background: rgba(46,204,113,.12); color: #bbf7d0; }
.flash.error { background: rgba(255,92,122,.12); color: #fecaca; } .flash.error { background: rgba(255,92,122,.12); color: #fecaca; }
table { width:100%; border-collapse: collapse; background: var(--card); border-radius:12px; overflow:hidden; }
table { width: 100%; border-collapse: collapse; background: var(--card); border-radius: 12px; overflow: hidden; } th, td { text-align:left; padding:10px 12px; border-bottom:1px solid rgba(255,255,255,.06); }
th, td { text-align: left; padding: 10px 12px; border-bottom: 1px solid rgba(255,255,255,.06); } thead th { background: rgba(255,255,255,.04); font-weight:600; }
thead th { background: rgba(255,255,255,.04); font-weight: 600; }
tbody tr:hover { background: rgba(255,255,255,.02); } tbody tr:hover { background: rgba(255,255,255,.02); }
nav a { margin-left: 10px; }
/* Hide global Admin button in header; use page overlay instead */
header nav a[href*="/admin"] { display: none !important; }
/* Replace garbled logout text with a visible label */
header nav form button.btn { position: relative; }
header nav form button.btn::after { content: 'Выйти'; }
/* Hide Admin link in header (we use page overlay on published pages) */
header nav a[href*="/admin"] { display: none !important; }
/* Force readable label for logout button regardless of encoding */
header nav form button.btn { font-size: 0; }
header nav form button.btn::after { content: 'Выйти'; font-size: 14px; }
</style> </style>
</head> </head>
<body> <body>
<div class="container"> <div class="container">
<header> <header>
<div><strong>{{ title or 'Админка' }}</strong></div> <div><strong>{{ title or 'ForMe' }}</strong></div>
<nav> <nav>
{% if logged_in %} {% if logged_in %}
<a class="btn secondary" href="{{ url_for('admin') }}">Админка</a> <form style="display:inline" method="post" action="{{ url_for('auth.logout') }}">
<form style="display:inline" method="post" action="{{ url_for('logout') }}">
<button class="btn" type="submit">Выйти</button> <button class="btn" type="submit">Выйти</button>
</form> </form>
{% endif %} {% endif %}
@@ -127,3 +67,4 @@
</div> </div>
</body> </body>
</html> </html>