Admin: add edit page (GET/POST), live preview; list shows Edit button; inject page title + watermark on public pages; clean header (only one logout); alignment via flex; add Dockerfile and docker-compose.yml; add gunicorn
This commit is contained in:
13
.dockerignore
Normal file
13
.dockerignore
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
.git
|
||||||
|
.venv
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
*.db
|
||||||
|
*.sqlite
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
node_modules/
|
||||||
|
*.log
|
||||||
|
|
||||||
17
Dockerfile
Normal file
17
Dockerfile
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# System deps (optional): none needed for sqlite
|
||||||
|
|
||||||
|
COPY requirements.txt ./
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt gunicorn
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
ENV FLASK_ENV=production
|
||||||
|
ENV PORT=5000
|
||||||
|
EXPOSE 5000
|
||||||
|
|
||||||
|
CMD ["gunicorn", "-b", "0.0.0.0:5000", "app:app", "--workers", "2", "--threads", "4", "--timeout", "60"]
|
||||||
|
|
||||||
36
app.py
36
app.py
@@ -148,6 +148,26 @@ def create_app():
|
|||||||
base_url = request.host_url.rstrip("/")
|
base_url = request.host_url.rstrip("/")
|
||||||
return render_template("admin.html", pages=pages, base_url=base_url)
|
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"])
|
@app.route("/admin/delete/<int:pid>", methods=["POST"])
|
||||||
def admin_delete(pid: int):
|
def admin_delete(pid: int):
|
||||||
login_required()
|
login_required()
|
||||||
@@ -201,6 +221,22 @@ def create_app():
|
|||||||
html = html[:idx] + toolbar + html[idx:]
|
html = html[:idx] + toolbar + html[idx:]
|
||||||
else:
|
else:
|
||||||
html = html + toolbar
|
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>'
|
||||||
|
)
|
||||||
|
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")
|
return Response(html, mimetype="text/html; charset=utf-8")
|
||||||
|
|
||||||
# Optional: simple 404 page to keep things minimal
|
# Optional: simple 404 page to keep things minimal
|
||||||
|
|||||||
19
docker-compose.yml
Normal file
19
docker-compose.yml
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
version: "3.9"
|
||||||
|
|
||||||
|
services:
|
||||||
|
web:
|
||||||
|
build: .
|
||||||
|
container_name: forme-web
|
||||||
|
ports:
|
||||||
|
- "5000:5000"
|
||||||
|
environment:
|
||||||
|
- SECRET_KEY=${SECRET_KEY:-prod-secret}
|
||||||
|
- ADMIN_USERNAME=${ADMIN_USERNAME:-ruslan}
|
||||||
|
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-utOgbZ09ruslan}
|
||||||
|
volumes:
|
||||||
|
- appdb:/app/app.db
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
appdb: {}
|
||||||
|
|
||||||
@@ -1 +1,2 @@
|
|||||||
Flask>=2.3,<3.0
|
Flask>=2.3,<3.0
|
||||||
|
gunicorn>=21.2
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{% extends 'base.html' %}
|
{% extends 'base.html' %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h1>Публикация HTML-страниц</h1>
|
<h1>Публикация HTML-страницы</h1>
|
||||||
|
|
||||||
<form method="post">
|
<form method="post">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
@@ -11,8 +11,10 @@
|
|||||||
<label for="html">HTML-код</label>
|
<label for="html">HTML-код</label>
|
||||||
<textarea id="html" name="html" placeholder="<h1>Заголовок</h1>\n<p>Мой контент...</p>" required></textarea>
|
<textarea id="html" name="html" placeholder="<h1>Заголовок</h1>\n<p>Мой контент...</p>" required></textarea>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn" type="submit">Опубликовать</button>
|
<div class="row">
|
||||||
<span class="muted">После публикации создаётся ссылка с UUID.</span>
|
<button class="btn" type="submit">Опубликовать</button>
|
||||||
|
<span class="muted" style="margin-left:10px;">После публикации создаётся ссылка с UUID.</span>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div class="row" style="margin-top: 1.25rem;">
|
<div class="row" style="margin-top: 1.25rem;">
|
||||||
@@ -42,7 +44,8 @@
|
|||||||
<td><a href="{{ base_url }}/p/{{ p.uuid }}" target="_blank">{{ base_url }}/p/{{ p.uuid }}</a></td>
|
<td><a href="{{ base_url }}/p/{{ p.uuid }}" target="_blank">{{ base_url }}/p/{{ p.uuid }}</a></td>
|
||||||
<td>{{ p.created_at }}</td>
|
<td>{{ p.created_at }}</td>
|
||||||
<td>
|
<td>
|
||||||
<form method="post" action="{{ url_for('admin_delete', pid=p.id) }}" onsubmit="return confirm('Удалить страницу #' + {{ p.id }} + '?');">
|
<a class="btn secondary" href="{{ url_for('admin_edit', pid=p.id) }}" style="margin-right:6px;">Редактировать</a>
|
||||||
|
<form style="display:inline" method="post" action="{{ url_for('admin_delete', pid=p.id) }}" onsubmit="return confirm('Удалить страницу #{{ p.id }}?');">
|
||||||
<button class="btn secondary" type="submit">Удалить</button>
|
<button class="btn secondary" type="submit">Удалить</button>
|
||||||
</form>
|
</form>
|
||||||
</td>
|
</td>
|
||||||
@@ -51,8 +54,9 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
{% else %}
|
{% else %}
|
||||||
<p class="muted">Страниц пока нет.</p>
|
<p class="muted">Пока нет опубликованных страниц.</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
(function(){
|
(function(){
|
||||||
const htmlEl = document.getElementById('html');
|
const htmlEl = document.getElementById('html');
|
||||||
@@ -63,7 +67,7 @@
|
|||||||
const doc = iframe.contentDocument || iframe.contentWindow.document;
|
const doc = iframe.contentDocument || iframe.contentWindow.document;
|
||||||
const t = (titleEl && titleEl.value) ? `<title>${titleEl.value}</title>` : '';
|
const t = (titleEl && titleEl.value) ? `<title>${titleEl.value}</title>` : '';
|
||||||
doc.open();
|
doc.open();
|
||||||
doc.write(`<!doctype html><html lang="ru"><head><meta charset="utf-8">${t}</head><body>${htmlEl.value}</body></html>`);
|
doc.write(`<!doctype html><html lang=\"ru\"><head><meta charset=\"utf-8\">${t}</head><body>${htmlEl.value}</body></html>`);
|
||||||
doc.close();
|
doc.close();
|
||||||
}
|
}
|
||||||
if (htmlEl){
|
if (htmlEl){
|
||||||
|
|||||||
@@ -45,7 +45,7 @@
|
|||||||
box-shadow: 0 10px 30px rgba(0,0,0,.35);
|
box-shadow: 0 10px 30px rgba(0,0,0,.35);
|
||||||
}
|
}
|
||||||
|
|
||||||
header nav { position: absolute; top: 14px; right: 18px; }
|
header nav { }
|
||||||
|
|
||||||
.card {
|
.card {
|
||||||
background: var(--card);
|
background: var(--card);
|
||||||
@@ -92,9 +92,11 @@
|
|||||||
header nav form button.btn { position: relative; }
|
header nav form button.btn { position: relative; }
|
||||||
header nav form button.btn::after { content: 'Выйти'; }
|
header nav form button.btn::after { content: 'Выйти'; }
|
||||||
|
|
||||||
.watermark { position: fixed; bottom: 14px; right: 16px; z-index: 1000;
|
/* Hide Admin link in header (we use page overlay on published pages) */
|
||||||
font-family: 'Pacifico', cursive; font-size: 20px; color: rgba(255,255,255,.85);
|
header nav a[href*="/admin"] { display: none !important; }
|
||||||
text-shadow: 0 2px 8px rgba(0,0,0,.35); user-select: none; pointer-events: none; }
|
/* 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>
|
||||||
@@ -123,6 +125,5 @@
|
|||||||
{% block content %}{% endblock %}
|
{% block content %}{% endblock %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="watermark">Made by Ruslan</div>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
42
templates/edit.html
Normal file
42
templates/edit.html
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
{% block content %}
|
||||||
|
<h1>Редактирование страницы #{{ page.id }}</h1>
|
||||||
|
|
||||||
|
<form method="post">
|
||||||
|
<div class="row">
|
||||||
|
<label for="title">Название страницы</label>
|
||||||
|
<input type="text" id="title" name="title" value="{{ page.title }}" />
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<label for="html">HTML-код</label>
|
||||||
|
<textarea id="html" name="html" required>{{ page.html }}</textarea>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<button class="btn" type="submit">Сохранить</button>
|
||||||
|
<a class="btn secondary" href="{{ url_for('admin') }}" style="margin-left:8px;">Отмена</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="row" style="margin-top: 1.25rem;">
|
||||||
|
<div class="muted" style="margin-bottom: .5rem;">Моментальный предпросмотр</div>
|
||||||
|
<iframe id="preview" style="width:100%; height:320px; border:1px solid rgba(255,255,255,.08); border-radius:12px; background:#fff;"></iframe>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function(){
|
||||||
|
const htmlEl = document.getElementById('html');
|
||||||
|
const titleEl = document.getElementById('title');
|
||||||
|
const iframe = document.getElementById('preview');
|
||||||
|
function render(){
|
||||||
|
const doc = iframe.contentDocument || iframe.contentWindow.document;
|
||||||
|
const t = (titleEl && titleEl.value) ? `<title>${titleEl.value}</title>` : '';
|
||||||
|
doc.open();
|
||||||
|
doc.write(`<!doctype html><html lang=\"ru\"><head><meta charset=\"utf-8\">${t}</head><body>${htmlEl.value}</body></html>`);
|
||||||
|
doc.close();
|
||||||
|
}
|
||||||
|
htmlEl.addEventListener('input', render);
|
||||||
|
if (titleEl) titleEl.addEventListener('input', render);
|
||||||
|
render();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user