307 lines
16 KiB
HTML
307 lines
16 KiB
HTML
<!doctype html>
|
|
<html lang="ru">
|
|
<head>
|
|
<meta charset="UTF-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
<title>Редактор новостей — MONT</title>
|
|
<meta name="robots" content="noindex, nofollow" />
|
|
<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=Manrope:wght@400;600;700;800&display=swap" rel="stylesheet">
|
|
<style>
|
|
:root { --brand: #1f4ea3; --brand2: #3978e0; --bg: #eef4ff; }
|
|
* { box-sizing: border-box; }
|
|
body { margin: 0; font-family: Manrope, sans-serif; background: var(--bg); color: #15203b; min-height: 100vh; }
|
|
.wrap { width: min(860px, calc(100% - 32px)); margin: 0 auto; padding: 28px 0 60px; }
|
|
.top { display: flex; align-items: center; justify-content: space-between; margin-bottom: 24px; flex-wrap: wrap; gap: 10px; }
|
|
.top strong { font-size: 20px; font-weight: 800; color: #1a3e79; }
|
|
.top small { font-size: 13px; color: #7a9bc0; }
|
|
.btn { display: inline-flex; align-items: center; gap: 6px; font-size: 13px; font-weight: 700;
|
|
padding: 8px 18px; border-radius: 10px; border: none; cursor: pointer; font-family: Manrope, sans-serif; transition: .15s; }
|
|
.btn-primary { background: linear-gradient(135deg, var(--brand), var(--brand2)); color: #fff; }
|
|
.btn-primary:hover { opacity: .88; }
|
|
.btn-danger { background: #fff0f0; color: #c0392b; border: 1px solid #fcc; }
|
|
.btn-danger:hover { background: #ffe0e0; }
|
|
.btn-logout { background: #f0f5ff; color: #526079; border: 1px solid #c8d8f7; }
|
|
.alert { padding: 10px 16px; border-radius: 10px; font-size: 13px; font-weight: 600; margin-bottom: 16px; }
|
|
.alert.ok { background: #edfaf3; color: #1a6b40; border: 1px solid #b3e8cc; }
|
|
.alert.error { background: #fff0f0; color: #c0392b; border: 1px solid #fcc; }
|
|
.box { background: #fff; border-radius: 18px; border: 1px solid #dae6ff; box-shadow: 0 8px 28px rgba(16,43,95,.08); padding: 24px 28px; margin-bottom: 20px; }
|
|
.box h3 { font-size: 16px; font-weight: 800; color: #1a3e79; margin: 0 0 16px; }
|
|
input[type=text], textarea {
|
|
width: 100%; padding: 10px 14px; border: 1px solid #cfd9f0; border-radius: 10px;
|
|
font-size: 14px; font-family: Manrope, sans-serif; color: #1a3060; outline: none; transition: .2s;
|
|
}
|
|
input[type=text]:focus, textarea:focus { border-color: #5b91f6; box-shadow: 0 0 0 3px rgba(91,145,246,.14); }
|
|
textarea { resize: vertical; min-height: 160px; }
|
|
.field { margin-bottom: 14px; }
|
|
.field label { display: block; font-size: 12px; font-weight: 700; color: #526079; margin-bottom: 5px; letter-spacing: .2px; }
|
|
.upload-zone {
|
|
border: 2px dashed #c8d8f7; border-radius: 12px; padding: 18px;
|
|
text-align: center; cursor: pointer; transition: .2s; background: #f8fbff;
|
|
position: relative;
|
|
}
|
|
.upload-zone:hover, .upload-zone.dragover { border-color: #5b91f6; background: #eef4ff; }
|
|
.upload-zone input[type=file] { position: absolute; inset: 0; opacity: 0; cursor: pointer; width: 100%; height: 100%; }
|
|
.upload-zone .uz-label { font-size: 13px; color: #7a9bc0; font-weight: 600; pointer-events: none; }
|
|
.upload-zone .uz-label span { color: var(--brand); }
|
|
.img-preview { max-width: 100%; max-height: 200px; border-radius: 10px; margin-top: 10px; display: none; object-fit: cover; }
|
|
.current-img { width: 100%; max-height: 160px; object-fit: cover; border-radius: 10px; margin-bottom: 8px; }
|
|
.news-list { display: flex; flex-direction: column; gap: 12px; }
|
|
.news-item { border: 1px solid #dae6ff; border-radius: 12px; padding: 14px 18px; background: #f8fbff; }
|
|
.news-item-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; margin-bottom: 8px; }
|
|
.news-item-title { font-size: 15px; font-weight: 800; color: #1a3e79; }
|
|
.news-item-meta { font-size: 12px; color: #8aa0c0; }
|
|
.news-item-actions { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
|
|
.badge-pub { font-size: 11px; font-weight: 700; padding: 3px 10px; border-radius: 999px; background: #edfaf3; color: #1a6b40; border: 1px solid #b3e8cc; }
|
|
.badge-unpub { font-size: 11px; font-weight: 700; padding: 3px 10px; border-radius: 999px; background: #fff8e6; color: #7a5500; border: 1px solid #f0d080; }
|
|
details summary { cursor: pointer; font-size: 13px; font-weight: 700; color: var(--brand); margin-top: 8px; }
|
|
details .edit-form { margin-top: 12px; padding-top: 12px; border-top: 1px solid #e0eaff; }
|
|
.pagination { display: flex; justify-content: center; align-items: center; gap: 6px; margin-top: 20px; flex-wrap: wrap; }
|
|
.pagination a, .pagination span {
|
|
display: inline-flex; align-items: center; justify-content: center;
|
|
width: 34px; height: 34px; border-radius: 8px;
|
|
font-size: 13px; font-weight: 700; text-decoration: none;
|
|
border: 1px solid #c8d8f7; background: #fff; color: #1f4ea3; transition: .15s;
|
|
}
|
|
.pagination a:hover { background: #eef4ff; }
|
|
.pagination span.current { background: linear-gradient(135deg,#1f4ea3,#3978e0); color: #fff; border-color: transparent; }
|
|
.pagination span.dots { background: none; border-color: transparent; color: #9ab0d0; }
|
|
.login-box { max-width: 360px; margin: 60px auto; background: #fff; border-radius: 20px; border: 1px solid #dae6ff; box-shadow: 0 12px 40px rgba(16,43,95,.1); padding: 32px; }
|
|
.login-box h2 { font-size: 20px; font-weight: 800; color: #1a3e79; margin: 0 0 20px; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="wrap">
|
|
|
|
{% if not is_news_editor %}
|
|
<div class="login-box">
|
|
<h2>Вход для редактора новостей</h2>
|
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
|
{% for cat, msg in messages %}
|
|
<div class="alert {{ cat }}">{{ msg }}</div>
|
|
{% endfor %}
|
|
{% endwith %}
|
|
<form method="post">
|
|
<input type="hidden" name="action" value="news_login" />
|
|
<div class="field"><label>Логин</label><input type="text" name="username" required autofocus /></div>
|
|
<div class="field"><label>Пароль</label><input type="text" name="password" required /></div>
|
|
<button class="btn btn-primary" type="submit" style="width:100%;justify-content:center;">Войти</button>
|
|
</form>
|
|
</div>
|
|
|
|
{% else %}
|
|
<div class="top">
|
|
<div>
|
|
<strong>Редактор новостей MONT</strong><br>
|
|
<small>{{ admin_login }}</small>
|
|
</div>
|
|
<div style="display:flex;gap:8px;">
|
|
<a href="/" style="text-decoration:none;"><button class="btn btn-logout" type="button">На сайт</button></a>
|
|
<form method="post" style="margin:0;">
|
|
<input type="hidden" name="action" value="logout" />
|
|
<button class="btn btn-logout" type="submit">Выйти</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
|
{% for cat, msg in messages %}
|
|
<div class="alert {{ cat }}">{{ msg }}</div>
|
|
{% endfor %}
|
|
{% endwith %}
|
|
|
|
<div class="box">
|
|
<h3>Добавить новость</h3>
|
|
<form method="post" enctype="multipart/form-data">
|
|
<input type="hidden" name="action" value="create_news" />
|
|
<div class="field"><label>Заголовок *</label><input type="text" name="title" placeholder="Заголовок новости" required /></div>
|
|
<div class="field">
|
|
<label>Картинка (jpg, png, webp)</label>
|
|
<div class="upload-zone" id="uzCreate">
|
|
<input type="file" name="image" accept=".jpg,.jpeg,.png,.webp,.gif" onchange="previewImg(this,'prevCreate')" />
|
|
<div class="uz-label">Перетащите файл, <span>выберите</span> или кликните в «Заголовок» и нажмите Ctrl+V</div>
|
|
</div>
|
|
<img id="prevCreate" class="img-preview" alt="" />
|
|
</div>
|
|
<div class="field">
|
|
<label>Текст новости * (разделяйте абзацы пустой строкой)</label>
|
|
<textarea name="body" placeholder="Текст новости..."></textarea>
|
|
</div>
|
|
<button class="btn btn-primary" type="submit">Опубликовать</button>
|
|
</form>
|
|
</div>
|
|
|
|
<div class="box">
|
|
<h3>Все новости</h3>
|
|
{% if all_news %}
|
|
<div class="news-list">
|
|
{% for n in all_news %}
|
|
<div class="news-item">
|
|
<div class="news-item-head">
|
|
<div>
|
|
<div class="news-item-title">{{ n.title }}</div>
|
|
<div class="news-item-meta">{{ n.created_at[:10] }} · /news/{{ n.slug }}</div>
|
|
</div>
|
|
<div class="news-item-actions">
|
|
<form method="post" style="margin:0;display:flex;align-items:center;gap:6px;">
|
|
<input type="hidden" name="action" value="toggle_published" />
|
|
<input type="hidden" name="news_id" value="{{ n.id }}" />
|
|
<input type="checkbox" name="published" id="tog_{{ n.id }}"
|
|
{% if n.published %}checked{% endif %}
|
|
onchange="this.form.submit()"
|
|
style="width:16px;height:16px;cursor:pointer;accent-color:#1f4ea3;" />
|
|
<label for="tog_{{ n.id }}" style="font-size:12px;font-weight:700;cursor:pointer;
|
|
color:{% if n.published %}#1a6b40{% else %}#7a5500{% endif %};">
|
|
{% if n.published %}Опубликована{% else %}Скрыта{% endif %}
|
|
</label>
|
|
</form>
|
|
<form method="post" onsubmit="return confirm('Удалить новость?')">
|
|
<input type="hidden" name="action" value="delete_news" />
|
|
<input type="hidden" name="news_id" value="{{ n.id }}" />
|
|
<button class="btn btn-danger" type="submit" style="padding:5px 12px;font-size:12px;">Удалить</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
<details>
|
|
<summary>Редактировать</summary>
|
|
<div class="edit-form">
|
|
<form method="post" enctype="multipart/form-data">
|
|
<input type="hidden" name="action" value="update_news" />
|
|
<input type="hidden" name="news_id" value="{{ n.id }}" />
|
|
<div class="field"><label>Заголовок</label><input type="text" name="title" value="{{ n.title }}" required /></div>
|
|
<div class="field">
|
|
<label>Картинка (оставьте пустым, чтобы не менять)</label>
|
|
{% if n.image %}
|
|
<img src="/static/{{ n.image }}" class="current-img" alt="" />
|
|
{% endif %}
|
|
<div class="upload-zone">
|
|
<input type="file" name="image" accept=".jpg,.jpeg,.png,.webp,.gif" onchange="previewImg(this,'prev_{{ n.id }}')" />
|
|
<div class="uz-label">Перетащите файл, <span>выберите</span> или кликните в «Заголовок» и нажмите Ctrl+V</div>
|
|
</div>
|
|
<img id="prev_{{ n.id }}" class="img-preview" alt="" />
|
|
</div>
|
|
<div class="field">
|
|
<label>Текст</label>
|
|
<textarea name="body">{{ n.body if n.body is defined else '' }}</textarea>
|
|
</div>
|
|
<div class="field">
|
|
<label>Дата публикации (ДД.ММ.ГГГГ ЧЧ:ММ)</label>
|
|
<input type="text" name="created_at_edit"
|
|
value="{{ n.created_at[8:10] }}.{{ n.created_at[5:7] }}.{{ n.created_at[:4] }} {{ n.created_at[11:16] }}"
|
|
placeholder="29.05.2026 14:30" style="width:200px;" />
|
|
</div>
|
|
<div class="field" style="display:flex;align-items:center;gap:8px;">
|
|
<input type="checkbox" name="published" id="pub_{{ n.id }}" {% if n.published %}checked{% endif %} style="width:auto;" />
|
|
<label for="pub_{{ n.id }}" style="margin:0;font-size:13px;">Опубликована</label>
|
|
</div>
|
|
<button class="btn btn-primary" type="submit">Сохранить</button>
|
|
</form>
|
|
</div>
|
|
</details>
|
|
</div>
|
|
{% endfor %}
|
|
</div>
|
|
{% else %}
|
|
<p style="color:#8aa0c0;font-size:14px;">Новостей пока нет.</p>
|
|
{% endif %}
|
|
|
|
{% if total_adm_pages > 1 %}
|
|
<div class="pagination">
|
|
{% if adm_page > 1 %}
|
|
<a href="?page={{ adm_page - 1 }}">‹</a>
|
|
{% endif %}
|
|
{% for p in range(1, total_adm_pages + 1) %}
|
|
{% if p == adm_page %}
|
|
<span class="current">{{ p }}</span>
|
|
{% elif p == 1 or p == total_adm_pages or (p >= adm_page - 2 and p <= adm_page + 2) %}
|
|
<a href="?page={{ p }}">{{ p }}</a>
|
|
{% elif p == adm_page - 3 or p == adm_page + 3 %}
|
|
<span class="dots">…</span>
|
|
{% endif %}
|
|
{% endfor %}
|
|
{% if adm_page < total_adm_pages %}
|
|
<a href="?page={{ adm_page + 1 }}">›</a>
|
|
{% endif %}
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
{% endif %}
|
|
|
|
</div>
|
|
<script>
|
|
function previewImg(input, previewId) {
|
|
const prev = document.getElementById(previewId);
|
|
if (!prev) return;
|
|
if (input.files && input.files[0]) {
|
|
const reader = new FileReader();
|
|
reader.onload = e => { prev.src = e.target.result; prev.style.display = 'block'; };
|
|
reader.readAsDataURL(input.files[0]);
|
|
}
|
|
}
|
|
|
|
function applyFileToZone(file, zone) {
|
|
if (!file || !file.type.startsWith('image/')) return false;
|
|
const input = zone.querySelector('input[type=file]');
|
|
if (!input) return false;
|
|
const dt = new DataTransfer();
|
|
dt.items.add(file);
|
|
input.files = dt.files;
|
|
input.dispatchEvent(new Event('change'));
|
|
zone.classList.add('dragover');
|
|
setTimeout(() => zone.classList.remove('dragover'), 600);
|
|
return true;
|
|
}
|
|
|
|
function getActiveZone() {
|
|
// Prefer zone inside an open <details>
|
|
const openDetails = document.querySelector('details[open]');
|
|
if (openDetails) {
|
|
const z = openDetails.querySelector('.upload-zone');
|
|
if (z) return z;
|
|
}
|
|
// Fallback: create form zone
|
|
return document.getElementById('uzCreate');
|
|
}
|
|
|
|
document.querySelectorAll('.upload-zone').forEach(zone => {
|
|
zone.addEventListener('dragover', e => { e.preventDefault(); zone.classList.add('dragover'); });
|
|
zone.addEventListener('dragleave', () => zone.classList.remove('dragover'));
|
|
zone.addEventListener('drop', e => {
|
|
e.preventDefault(); zone.classList.remove('dragover');
|
|
const input = zone.querySelector('input[type=file]');
|
|
if (input && e.dataTransfer.files.length) {
|
|
input.files = e.dataTransfer.files;
|
|
input.dispatchEvent(new Event('change'));
|
|
}
|
|
});
|
|
// Mark as active on click/focus
|
|
zone.addEventListener('mouseenter', () => zone.dataset.lastActive = Date.now());
|
|
});
|
|
|
|
function showToast(msg) {
|
|
const hint = document.createElement('div');
|
|
hint.textContent = msg;
|
|
hint.style.cssText = 'position:fixed;bottom:24px;left:50%;transform:translateX(-50%);background:#1f4ea3;color:#fff;padding:10px 22px;border-radius:10px;font-size:13px;font-weight:700;z-index:9999;box-shadow:0 4px 16px rgba(0,0,0,.2);pointer-events:none;';
|
|
document.body.appendChild(hint);
|
|
setTimeout(() => hint.remove(), 2500);
|
|
}
|
|
|
|
// Intercept paste in any field: if clipboard has image — apply to upload zone
|
|
document.addEventListener('paste', e => {
|
|
const items = Array.from((e.clipboardData || {}).items || []);
|
|
showToast('paste: ' + items.map(i=>i.type).join(', ') || 'нет items');
|
|
const imgItem = items.find(i => i.type.startsWith('image/'));
|
|
if (!imgItem) return;
|
|
const file = imgItem.getAsFile();
|
|
if (!file) { showToast('getAsFile вернул null'); return; }
|
|
const zone = getActiveZone();
|
|
if (!zone) { showToast('зона не найдена'); return; }
|
|
e.preventDefault();
|
|
applyFileToZone(file, zone);
|
|
showToast('📋 Картинка вставлена из буфера');
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>
|