Initial parser admin implementation
This commit is contained in:
@@ -0,0 +1,338 @@
|
||||
{% extends "base.html" %}
|
||||
{% block body %}
|
||||
<div class="page-head">
|
||||
<div>
|
||||
<h1>Редакторская</h1>
|
||||
<div class="muted">Посты после AI-рерайта: быстрая проверка, правка и принятие.</div>
|
||||
</div>
|
||||
<div class="status-tabs">
|
||||
{% for st in status_options %}
|
||||
<a class="tab {% if st.value in editorial_statuses %}active{% endif %}" href="/editor?editorial_status={{ st.value }}">
|
||||
{{ st.label }} <span>{{ counts.get(st.value, 0) }}</span>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="catalog-layout editor-catalog">
|
||||
<section class="catalog-main">
|
||||
<div class="panel list-toolbar">
|
||||
<div class="muted">Всего: {{ pagination.total }}</div>
|
||||
<form class="row compact-controls" method="get" action="/editor">
|
||||
{% for item in preserved_query %}
|
||||
{% if item.key != "per_page" and item.key != "sort" %}
|
||||
<input type="hidden" name="{{ item.key }}" value="{{ item.value }}">
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
<label><span>Сортировка</span>
|
||||
<select name="sort" data-autosubmit>
|
||||
<option value="rewritten_desc" {% if sort == "rewritten_desc" %}selected{% endif %}>рерайт: новые</option>
|
||||
<option value="rewritten_asc" {% if sort == "rewritten_asc" %}selected{% endif %}>рерайт: старые</option>
|
||||
<option value="posted_desc" {% if sort == "posted_desc" %}selected{% endif %}>VK: новые</option>
|
||||
<option value="posted_asc" {% if sort == "posted_asc" %}selected{% endif %}>VK: старые</option>
|
||||
<option value="score_desc" {% if sort == "score_desc" %}selected{% endif %}>оценка: выше</option>
|
||||
<option value="score_asc" {% if sort == "score_asc" %}selected{% endif %}>оценка: ниже</option>
|
||||
</select>
|
||||
</label>
|
||||
<label><span>На странице</span>
|
||||
<select name="per_page" data-autosubmit>
|
||||
{% for n in [10,25,50,100] %}
|
||||
<option value="{{ n }}" {% if pagination.per_page == n %}selected{% endif %}>{{ n }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="editor-list">
|
||||
{% for p in posts %}
|
||||
<article class="editor-card">
|
||||
<div class="editor-media">
|
||||
<div class="media-strip">
|
||||
{% for m in p.media_items %}
|
||||
{% if m.type == "photo" and m.url %}
|
||||
<button class="media-thumb" type="button" data-gallery-src="{{ m.url }}" data-gallery-title="#{{ p.id }} · фото {{ loop.index }}" title="{{ m.status }}">
|
||||
<img src="{{ m.url }}" alt="photo">
|
||||
</button>
|
||||
{% elif m.url %}
|
||||
<a class="media-thumb media-video" href="{{ m.url }}" target="_blank" title="{{ m.error or m.status }}"><span>▶</span></a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="muted small-help">{{ p.media_count or 0 }} медиа</div>
|
||||
</div>
|
||||
|
||||
<div class="editor-main">
|
||||
<div class="editor-meta">
|
||||
<a href="{{ p.original_url }}" target="_blank">{{ p.source_name }}</a>
|
||||
<span>#{{ p.review_source_tag or p.source_tag or "source" }}</span>
|
||||
<span>{{ p.posted_at_fmt or p.created_at_fmt }}</span>
|
||||
</div>
|
||||
<div class="editor-title-row">
|
||||
<span class="pill {% if p.editorial_status == 'accepted' %}ok{% elif p.editorial_status == 'rejected' %}bad{% elif p.editorial_status == 'regenerating' %}warn{% endif %}">
|
||||
{{ p.editorial_status_label }}
|
||||
</span>
|
||||
<span class="pill">{{ p.review_category or "без категории" }}</span>
|
||||
<span class="muted">AI {{ p.qualification_score or "?" }}/10</span>
|
||||
</div>
|
||||
<div class="editor-text">{{ p.review_text }}</div>
|
||||
<details class="source-compare">
|
||||
<summary>Исходник и AI-заметки</summary>
|
||||
<div class="compare-grid">
|
||||
<div>
|
||||
<div class="hint-label">Оригинал</div>
|
||||
<div class="raw-full-text">{{ p.raw_text }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="hint-label">Заметки</div>
|
||||
<div>{{ p.rewrite_notes or p.qualification_reason or "—" }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<div class="editor-actions">
|
||||
<form method="post" action="/editor/{{ p.id }}/accept">
|
||||
<input type="hidden" name="csrf_token" value="{{ user.csrf_token }}">
|
||||
<button class="btn primary" type="submit">Принять</button>
|
||||
</form>
|
||||
<button class="btn" type="button" data-open-editor="edit-{{ p.id }}">Редактировать</button>
|
||||
<form method="post" action="/editor/{{ p.id }}/reject" onsubmit="return confirm('Отклонить пост #{{ p.id }}?');">
|
||||
<input type="hidden" name="csrf_token" value="{{ user.csrf_token }}">
|
||||
<button class="btn danger" type="submit">Отклонить</button>
|
||||
</form>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<dialog class="editor-dialog" id="edit-{{ p.id }}">
|
||||
<div class="dialog-head">
|
||||
<div>
|
||||
<h2>Пост #{{ p.id }}</h2>
|
||||
<div class="muted"><a href="{{ p.original_url }}" target="_blank">{{ p.source_name }}</a> · {{ p.posted_at_fmt or p.created_at_fmt }}</div>
|
||||
</div>
|
||||
<button class="icon-btn" type="button" data-close-dialog="edit-{{ p.id }}">×</button>
|
||||
</div>
|
||||
<form method="post" action="/editor/{{ p.id }}/save" class="edit-form dialog-grid" enctype="multipart/form-data">
|
||||
<input type="hidden" name="csrf_token" value="{{ user.csrf_token }}">
|
||||
<aside class="context-pane">
|
||||
<div class="media-strip detail-media">
|
||||
{% for m in p.media_items %}
|
||||
<div class="editable-media" data-media-id="{{ m.id }}">
|
||||
{% if m.type == "photo" and m.url %}
|
||||
<button class="media-thumb detail-thumb" type="button" data-gallery-src="{{ m.url }}" data-gallery-title="#{{ p.id }} · фото {{ loop.index }}" title="{{ m.status }}">
|
||||
<img src="{{ m.url }}" alt="photo">
|
||||
</button>
|
||||
{% elif m.url %}
|
||||
<a class="media-thumb detail-thumb media-video" href="{{ m.url }}" target="_blank"><span>▶</span></a>
|
||||
{% endif %}
|
||||
<button class="media-remove" type="button" data-mark-media-delete="{{ m.id }}" title="Убрать после сохранения">×</button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<label class="media-upload-form"><span>Добавить медиа после сохранения</span><input type="file" name="media_files" accept="image/*,video/*,.pdf,.doc,.docx" multiple></label>
|
||||
<div class="pending-media-list muted small-help" data-pending-media-list>Выбранные файлы появятся после сохранения.</div>
|
||||
<details open>
|
||||
<summary>Исходный пост</summary>
|
||||
<div class="raw-full-text">{{ p.raw_text }}</div>
|
||||
</details>
|
||||
<details>
|
||||
<summary>AI-рерайт</summary>
|
||||
<div class="raw-full-text">{{ p.rewritten_text }}</div>
|
||||
</details>
|
||||
<dl class="kv compact-kv">
|
||||
<dt>Оценка</dt><dd>{{ p.qualification_score or "—" }}/10</dd>
|
||||
<dt>Категория</dt><dd>{{ p.rewrite_category or "—" }}</dd>
|
||||
<dt>Модель</dt><dd>{{ p.rewrite_model or "—" }}</dd>
|
||||
</dl>
|
||||
</aside>
|
||||
|
||||
<section class="edit-pane">
|
||||
<label><span>Текст публикации</span>
|
||||
<textarea name="final_text" rows="15">{{ p.review_text }}</textarea>
|
||||
</label>
|
||||
<div class="form-grid">
|
||||
<label><span>Категория</span>
|
||||
<select name="final_category">
|
||||
{% for category in categories %}
|
||||
<option value="{{ category }}" {% if p.review_category == category %}selected{% endif %}>{{ category }}</option>
|
||||
{% endfor %}
|
||||
{% if p.review_category and p.review_category not in categories %}
|
||||
<option value="{{ p.review_category }}" selected>{{ p.review_category }} · старая категория</option>
|
||||
{% endif %}
|
||||
</select>
|
||||
</label>
|
||||
<label><span>Тэг источника</span>
|
||||
<input name="final_source_tag" value="{{ p.review_source_tag or p.source_tag or '' }}">
|
||||
</label>
|
||||
</div>
|
||||
<div class="hashtag-preview" data-hashtag-preview>#{{ p.review_category_tag or p.review_category or "category" }} #{{ p.review_source_tag or p.source_tag or "source" }}</div>
|
||||
<label><span>Заметка редактора</span>
|
||||
<textarea name="editor_notes" rows="3">{{ p.editor_notes or "" }}</textarea>
|
||||
</label>
|
||||
<div class="dialog-actions">
|
||||
<button class="btn" type="submit" name="action" value="save">Сохранить правки</button>
|
||||
<button class="btn primary" type="submit" name="action" value="accept">Принять к публикации</button>
|
||||
</div>
|
||||
</section>
|
||||
</form>
|
||||
</dialog>
|
||||
{% else %}
|
||||
<div class="empty-state panel">В этой очереди пока нет постов.</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% include "pagination.html" %}
|
||||
</section>
|
||||
|
||||
<aside class="filter-sidebar panel">
|
||||
<form method="get" action="/editor" data-filter-form>
|
||||
<input type="hidden" name="sort" value="{{ sort }}">
|
||||
<div class="filter-head">
|
||||
<h2>Фильтры</h2>
|
||||
<a href="/editor">Сбросить</a>
|
||||
</div>
|
||||
<div class="filter-section">
|
||||
<div class="filter-title">Оценка AI</div>
|
||||
<div class="range-row">
|
||||
<input name="score_min" inputmode="numeric" placeholder="От" value="{{ score_min }}">
|
||||
<input name="score_max" inputmode="numeric" placeholder="До" value="{{ score_max }}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="filter-section">
|
||||
<div class="filter-title">Дата поста</div>
|
||||
<div class="range-row">
|
||||
<input type="date" name="date_from" value="{{ date_from }}">
|
||||
<input type="date" name="date_to" value="{{ date_to }}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="filter-section">
|
||||
<div class="filter-title">Источник</div>
|
||||
<input class="facet-search" type="search" placeholder="Найти источник" data-facet-search="editor-sources">
|
||||
<div class="facet-list" data-facet-list="editor-sources">
|
||||
{% for item in facets.sources %}
|
||||
<label class="facet-option"><input type="checkbox" name="source_id" value="{{ item.id }}" {% if item.id in source_ids %}checked{% endif %}> <span>{{ item.name }}</span><small>{{ item.count }}</small></label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="filter-section">
|
||||
<div class="filter-title">Категория</div>
|
||||
<input class="facet-search" type="search" placeholder="Найти категорию" data-facet-search="editor-categories">
|
||||
<div class="facet-list" data-facet-list="editor-categories">
|
||||
{% for item in facets.categories %}
|
||||
<label class="facet-option"><input type="checkbox" name="category" value="{{ item.value }}" {% if item.value in categories_selected %}checked{% endif %}> <span>{{ item.value }}</span><small>{{ item.count }}</small></label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="filter-section">
|
||||
<div class="filter-title">Статус редакции</div>
|
||||
<div class="facet-list compact">
|
||||
{% for st in status_options %}
|
||||
<label class="facet-option"><input type="checkbox" name="editorial_status" value="{{ st.value }}" {% if st.value in editorial_statuses %}checked{% endif %}> <span>{{ st.label }}</span><small>{{ counts.get(st.value, 0) }}</small></label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn primary filter-apply" type="submit">Показать</button>
|
||||
</form>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<dialog class="gallery-modal" id="galleryModal" aria-hidden="true">
|
||||
<button class="gallery-close" type="button" aria-label="Закрыть">×</button>
|
||||
<button class="gallery-nav gallery-prev" type="button" aria-label="Назад">‹</button>
|
||||
<img id="galleryImage" src="" alt="">
|
||||
<button class="gallery-nav gallery-next" type="button" aria-label="Вперёд">›</button>
|
||||
<div class="gallery-title" id="galleryTitle"></div>
|
||||
</dialog>
|
||||
|
||||
<script>
|
||||
(() => {
|
||||
document.querySelectorAll("[data-open-editor]").forEach((button) => {
|
||||
button.addEventListener("click", () => document.getElementById(button.dataset.openEditor)?.showModal());
|
||||
});
|
||||
document.querySelectorAll("[data-close-dialog]").forEach((button) => {
|
||||
button.addEventListener("click", () => document.getElementById(button.dataset.closeDialog)?.close());
|
||||
});
|
||||
document.querySelectorAll(".edit-form").forEach((form) => {
|
||||
const category = form.querySelector('[name="final_category"]');
|
||||
const source = form.querySelector('[name="final_source_tag"]');
|
||||
const preview = form.querySelector("[data-hashtag-preview]");
|
||||
const normalize = (value, fallback) => {
|
||||
const tag = String(value || "").trim().replace(/^#+/, "").toLowerCase()
|
||||
.replace(/\s+/g, "_")
|
||||
.replace(/[^\wа-яё_]+/gi, "_")
|
||||
.replace(/_+/g, "_")
|
||||
.replace(/^_+|_+$/g, "");
|
||||
return tag || fallback;
|
||||
};
|
||||
const update = () => {
|
||||
if (!preview) return;
|
||||
preview.textContent = `#${normalize(category?.value, "category")} #${normalize(source?.value, "source")}`;
|
||||
};
|
||||
category?.addEventListener("change", update);
|
||||
source?.addEventListener("input", update);
|
||||
update();
|
||||
});
|
||||
|
||||
const items = Array.from(document.querySelectorAll("[data-gallery-src]"));
|
||||
const modal = document.getElementById("galleryModal");
|
||||
const image = document.getElementById("galleryImage");
|
||||
const title = document.getElementById("galleryTitle");
|
||||
let index = 0;
|
||||
function show(nextIndex) {
|
||||
if (!items.length) return;
|
||||
index = (nextIndex + items.length) % items.length;
|
||||
const item = items[index];
|
||||
image.src = item.dataset.gallerySrc;
|
||||
title.textContent = item.dataset.galleryTitle || "";
|
||||
modal.classList.add("open");
|
||||
modal.setAttribute("aria-hidden", "false");
|
||||
if (typeof modal.showModal === "function" && !modal.open) modal.showModal();
|
||||
}
|
||||
function close() {
|
||||
modal.classList.remove("open");
|
||||
modal.setAttribute("aria-hidden", "true");
|
||||
image.src = "";
|
||||
if (typeof modal.close === "function" && modal.open) modal.close();
|
||||
}
|
||||
items.forEach((item, i) => item.addEventListener("click", () => show(i)));
|
||||
modal.querySelector(".gallery-close").addEventListener("click", close);
|
||||
modal.querySelector(".gallery-prev").addEventListener("click", () => show(index - 1));
|
||||
modal.querySelector(".gallery-next").addEventListener("click", () => show(index + 1));
|
||||
modal.addEventListener("click", (event) => { if (event.target === modal) close(); });
|
||||
document.querySelectorAll("[data-autosubmit]").forEach((control) => {
|
||||
control.addEventListener("change", () => control.form?.submit());
|
||||
});
|
||||
document.querySelectorAll("[data-facet-search]").forEach((input) => {
|
||||
input.addEventListener("input", () => {
|
||||
const list = document.querySelector(`[data-facet-list="${input.dataset.facetSearch}"]`);
|
||||
const q = input.value.trim().toLowerCase();
|
||||
list?.querySelectorAll(".facet-option").forEach((option) => {
|
||||
option.hidden = q && !option.textContent.toLowerCase().includes(q);
|
||||
});
|
||||
});
|
||||
});
|
||||
document.querySelectorAll("[data-mark-media-delete]").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
const form = button.closest("form");
|
||||
const media = button.closest("[data-media-id]");
|
||||
const mediaId = button.dataset.markMediaDelete;
|
||||
if (!form || !mediaId || form.querySelector(`input[name="delete_media_ids"][value="${mediaId}"]`)) return;
|
||||
const hidden = document.createElement("input");
|
||||
hidden.type = "hidden";
|
||||
hidden.name = "delete_media_ids";
|
||||
hidden.value = mediaId;
|
||||
form.appendChild(hidden);
|
||||
media?.classList.add("marked-delete");
|
||||
});
|
||||
});
|
||||
document.querySelectorAll('input[type="file"][name="media_files"]').forEach((input) => {
|
||||
input.addEventListener("change", () => {
|
||||
const list = input.closest(".context-pane")?.querySelector("[data-pending-media-list]");
|
||||
if (!list) return;
|
||||
const names = Array.from(input.files || []).map((file) => file.name);
|
||||
list.textContent = names.length ? `Будет добавлено: ${names.join(", ")}` : "Выбранные файлы появятся после сохранения.";
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user