From 5220c4051ba76fab63846812228f9e834065881a Mon Sep 17 00:00:00 2001 From: Your Name Date: Sat, 18 Jul 2026 22:20:40 +0500 Subject: [PATCH] feat: complete UI migration to Tailwind CSS + HTMX --- src/vk_parser_app/admin.py | 110 +++- src/vk_parser_app/templates/base.html | 289 ++-------- src/vk_parser_app/templates/editor.html | 357 +----------- .../templates/editor_content.html | 129 +++++ src/vk_parser_app/templates/editor_post.html | 165 ++++++ src/vk_parser_app/templates/login.html | 34 +- src/vk_parser_app/templates/logs.html | 223 ++++---- src/vk_parser_app/templates/prompt_test.html | 204 ++++--- src/vk_parser_app/templates/raw_posts.html | 249 +-------- .../templates/{v2 => }/raw_posts_content.html | 8 +- src/vk_parser_app/templates/source_form.html | 72 ++- src/vk_parser_app/templates/source_row.html | 45 ++ src/vk_parser_app/templates/sources.html | 258 +++++---- .../templates/sources_content.html | 57 ++ src/vk_parser_app/templates/table_tools.html | 82 +-- src/vk_parser_app/templates/users.html | 116 +++- src/vk_parser_app/templates/v2/base.html | 62 --- src/vk_parser_app/templates/v2/raw_posts.html | 14 - src/vk_parser_app/templates/workers.html | 524 +++++++++++------- 19 files changed, 1497 insertions(+), 1501 deletions(-) create mode 100644 src/vk_parser_app/templates/editor_content.html create mode 100644 src/vk_parser_app/templates/editor_post.html rename src/vk_parser_app/templates/{v2 => }/raw_posts_content.html (94%) create mode 100644 src/vk_parser_app/templates/source_row.html create mode 100644 src/vk_parser_app/templates/sources_content.html delete mode 100644 src/vk_parser_app/templates/v2/base.html delete mode 100644 src/vk_parser_app/templates/v2/raw_posts.html diff --git a/src/vk_parser_app/admin.py b/src/vk_parser_app/admin.py index 39f3969..d24e179 100644 --- a/src/vk_parser_app/admin.py +++ b/src/vk_parser_app/admin.py @@ -2020,8 +2020,9 @@ async def sources_list(request: Request, q: str = "", status_filter: str = "", p item["last_checked_at_fmt"] = format_dt(item.get("last_checked_at")) item["created_at_fmt"] = format_dt(item.get("created_at")) sources.append(item) + template_name = "sources_content.html" if request.headers.get("hx-request") else "sources.html" return templates.TemplateResponse( - "sources.html", + template_name, base_context( request, user, @@ -2317,6 +2318,25 @@ async def source_toggle(request: Request, source_id: int, csrf_token: str = Form source_id, ) await audit(user["id"], "source.toggle", "source", source_id, {"active": bool(row["active"]) if row else None}) + if request.headers.get("hx-request"): + # Fetch the updated source + source_row = await pool.fetchrow("SELECT * FROM sources WHERE id=$1", source_id) + if source_row: + s = dict(source_row) + # Fetch stats just for this source to render correctly + stats = await pool.fetchrow( + """ + SELECT COUNT(*) AS total_posts, + COUNT(*) FILTER (WHERE created_at >= NOW() - INTERVAL '24 hours') AS recent_posts + FROM raw_posts + WHERE source_id=$1 + """, + source_id + ) + s["posts_count"] = stats["total_posts"] + s["posts_24h"] = stats["recent_posts"] + s["last_parsed_at_fmt"] = s["last_parsed_at"].strftime("%d.%m.%Y %H:%M") if s.get("last_parsed_at") else None + return templates.TemplateResponse("source_row.html", base_context(request, user, s=s)) return redirect("/sources") @@ -2329,11 +2349,12 @@ async def source_delete(request: Request, source_id: int, csrf_token: str = Form pool = await get_pool() await pool.execute("UPDATE sources SET archived_at=NOW(), active=FALSE, updated_at=NOW() WHERE id=$1", source_id) await audit(user["id"], "source.archive", "source", source_id) + if request.headers.get("hx-request"): + return HTMLResponse("") return redirect("/sources") @app.get("/raw", response_class=HTMLResponse) -@app.get("/v2/raw", response_class=HTMLResponse) async def raw_posts( request: Request, status_filter: str = "", @@ -2482,11 +2503,7 @@ async def raw_posts( ) posts = [prepare_raw_post(row) for row in rows] - is_v2 = request.url.path.startswith("/v2") - if is_v2: - template_name = "v2/raw_posts_content.html" if request.headers.get("hx-request") else "v2/raw_posts.html" - else: - template_name = "raw_posts.html" + template_name = "raw_posts_content.html" if request.headers.get("hx-request") else "raw_posts.html" return templates.TemplateResponse( template_name, @@ -2567,6 +2584,58 @@ async def raw_send_to_rewrite( return redirect(safe_next) +async def fetch_single_editor_row(pool, post_id: int): + return await pool.fetchrow( + """ + SELECT rp.*, s.name AS source_name, s.tag AS source_tag, s.platform AS source_platform, s.url AS source_url, + u.login AS reviewed_by_login, + COALESCE(c_id.tag, c_name.tag) AS resolved_category_tag, + COUNT(rpm.id) AS media_count, + COALESCE( + jsonb_agg( + jsonb_build_object( + 'id', rpm.id, + 'type', rpm.media_type, + 'url', rpm.original_url, + 'status', rpm.status, + 'error', rpm.error, + 'duration_sec', rpm.duration_sec + ) + ORDER BY rpm.sort_order ASC, rpm.id ASC + ) FILTER (WHERE rpm.id IS NOT NULL), + '[]'::jsonb + ) AS media_items, + COALESCE( + ( + SELECT jsonb_agg( + jsonb_build_object( + 'platform', pp.platform, + 'status', pp.status, + 'target_id', pp.target_id, + 'external_id', pp.external_id, + 'url', pp.url, + 'error', pp.error, + 'published_at', pp.published_at + ) + ORDER BY pp.platform + ) + FROM post_publications pp + WHERE pp.raw_post_id=rp.id + ), + '[]'::jsonb + ) AS platform_publications + FROM raw_posts rp + JOIN sources s ON s.id=rp.source_id + LEFT JOIN admin_users u ON u.id=rp.reviewed_by + LEFT JOIN content_categories c_id ON c_id.sort_order=COALESCE(rp.final_category_id, rp.rewrite_category_id) + LEFT JOIN content_categories c_name ON lower(c_name.name)=lower(COALESCE(rp.final_category, rp.rewrite_category, '')) + LEFT JOIN raw_post_media rpm ON rpm.raw_post_id=rp.id AND COALESCE(rpm.editor_hidden, FALSE)=FALSE + WHERE rp.id = $1 + GROUP BY rp.id, s.name, s.tag, s.platform, s.url, u.login, c_id.tag, c_name.tag + """, + post_id + ) + @app.get("/editor", response_class=HTMLResponse) async def editor_feed( request: Request, @@ -2709,8 +2778,9 @@ async def editor_feed( """ ) counts = {str(row["status"]): int(row["count"]) for row in counts_rows} + template_name = "editor_content.html" if request.headers.get("hx-request") else "editor.html" return templates.TemplateResponse( - "editor.html", + template_name, base_context( request, user, @@ -2782,6 +2852,14 @@ async def editor_accept(request: Request, post_id: int, csrf_token: str = Form(. user["id"], ) await audit(user["id"], "editor.accept", "raw_post", post_id) + if request.headers.get("hx-request"): + row = await fetch_single_editor_row(pool, post_id) + if row: + categories = await writer_category_options() + return templates.TemplateResponse( + "editor_post.html", + base_context(request, user, p=prepare_editor_post(row), categories=categories) + ) return redirect("/editor") @@ -2807,6 +2885,14 @@ async def editor_reject(request: Request, post_id: int, csrf_token: str = Form(. user["id"], ) await audit(user["id"], "editor.reject", "raw_post", post_id) + if request.headers.get("hx-request"): + row = await fetch_single_editor_row(pool, post_id) + if row: + categories = await writer_category_options() + return templates.TemplateResponse( + "editor_post.html", + base_context(request, user, p=prepare_editor_post(row), categories=categories) + ) return redirect("/editor") @@ -2934,6 +3020,14 @@ async def editor_save( user["id"], ) await audit(user["id"], "editor.save" if status_value == "edited" else "editor.save_accept", "raw_post", post_id) + if request.headers.get("hx-request"): + row = await fetch_single_editor_row(pool, post_id) + if row: + categories = await writer_category_options() + return templates.TemplateResponse( + "editor_post.html", + base_context(request, user, p=prepare_editor_post(row), categories=categories) + ) return redirect("/editor") diff --git a/src/vk_parser_app/templates/base.html b/src/vk_parser_app/templates/base.html index 5e5a6f0..d125d06 100644 --- a/src/vk_parser_app/templates/base.html +++ b/src/vk_parser_app/templates/base.html @@ -1,246 +1,65 @@ - + {{ title or "VK Parser Admin" }} + + + + + + + + + + - -{% if user %} -
- -
-
-{% endif %} -
{% block body %}{% endblock %}
+ + + {% if user %} + + {% endif %} + +
+ {% block body %}{% endblock %} +
+ + diff --git a/src/vk_parser_app/templates/editor.html b/src/vk_parser_app/templates/editor.html index 31d31c9..f41735f 100644 --- a/src/vk_parser_app/templates/editor.html +++ b/src/vk_parser_app/templates/editor.html @@ -1,359 +1,24 @@ {% extends "base.html" %} {% block body %} -
+
-

Редакторская

-
Посты после AI-рерайта: быстрая проверка, правка и принятие.
+

Редакторская

+
Посты после AI-рерайта: быстрая проверка, правка и принятие.
- -
-
-
-
Всего: {{ pagination.total }}
-
- {% for item in preserved_query %} - {% if item.key != "per_page" and item.key != "sort" %} - - {% endif %} - {% endfor %} - - -
+
+ {% include "editor_content.html" %}
-
- {% for p in posts %} -
-
-
- {% for m in p.media_items %} - {% if m.type == "photo" and m.url %} - - {% elif m.url %} - - {% endif %} - {% endfor %} -
-
{{ p.media_count or 0 }} медиа
-
- -
-
- {{ p.source_name }} - #{{ p.review_source_tag or p.source_tag or "source" }} - {{ p.posted_at_fmt or p.created_at_fmt }} -
-
- - {{ p.editorial_status_label }} - - {{ p.review_category or "без категории" }} - AI {{ p.qualification_score or "?" }}/10 -
-
- {% for pub in p.publication_platforms %} - {% if pub.url %} - {{ pub.label }} · {{ pub.status_label }} - {% else %} - {{ pub.label }} · {{ pub.status_label }} - {% endif %} - {% endfor %} -
-
{{ p.publication_preview_text }}
-
- Исходник и AI-заметки -
-
-
Оригинал
-
{{ p.raw_text }}
-
-
-
Заметки
-
{{ p.rewrite_notes or p.qualification_reason or "—" }}
-
-
-
-
- -
-
- - -
- -
- - -
-
-
- - -
-
-

Пост #{{ p.id }}

-
{{ p.source_name }} · {{ p.posted_at_fmt or p.created_at_fmt }}
-
- -
-
- - - -
- -
- - -
- -
#{{ p.review_category_tag or p.review_category or "category" }} #{{ p.review_source_tag or p.source_tag or "source" }}
- -
- - -
-
-
-
- {% else %} -
В этой очереди пока нет постов.
- {% endfor %} -
- -{% include "pagination.html" %} -
- - -
- - - - + + {% endblock %} diff --git a/src/vk_parser_app/templates/editor_content.html b/src/vk_parser_app/templates/editor_content.html new file mode 100644 index 0000000..ca2d37e --- /dev/null +++ b/src/vk_parser_app/templates/editor_content.html @@ -0,0 +1,129 @@ +
+ + + + +
+
+
+ Всего постов: {{ pagination.total }} +
+
+ На странице: + +
+
+ +
+ {% for p in posts %} + {% include "editor_post.html" %} + {% else %} +
+ В этой очереди пока нет постов. +
+ {% endfor %} +
+ + + {% if pagination.total_pages > 1 %} +
+
+ + + + + + + {% for cat in categories_selected %}{% endfor %} + {% for sid in source_ids %}{% endfor %} + + + + + + + + +
+
+ {% endif %} +
+
diff --git a/src/vk_parser_app/templates/editor_post.html b/src/vk_parser_app/templates/editor_post.html new file mode 100644 index 0000000..c0dde4c --- /dev/null +++ b/src/vk_parser_app/templates/editor_post.html @@ -0,0 +1,165 @@ +
+
+ +
+
+ {% for m in p.media_items %} + {% if m.type == "photo" and m.url %} +
+ photo +
+ {% elif m.url %} + + {% endif %} + {% endfor %} +
+
{{ p.media_count or 0 }} медиа
+
+ + +
+
+
+ {{ p.source_name }} + + #{{ p.review_source_tag or p.source_tag or "source" }} + + {{ p.posted_at_fmt or p.created_at_fmt }} +
+
+ + {{ p.editorial_status_label }} + + {{ p.review_category or "Без категории" }} + AI {{ p.qualification_score or "?" }}/10 +
+
+ +
+ {% for pub in p.publication_platforms %} + {% if pub.url %} + + + {{ pub.label }} + + {% else %} + + {{ pub.label }} + + {% endif %} + {% endfor %} +
+ +
+ {{ p.publication_preview_text }} +
+ +
+ +
Исходник и AI-заметки
+
+
+
Оригинал
+
{{ p.raw_text }}
+
+
+
Заметки
+
{{ p.rewrite_notes or p.qualification_reason or "—" }}
+
+
+
+ +
+
+ + +
+ +
+ + +
+
+
+
+ + + + + + +
diff --git a/src/vk_parser_app/templates/login.html b/src/vk_parser_app/templates/login.html index 9dd4d56..25ad63f 100644 --- a/src/vk_parser_app/templates/login.html +++ b/src/vk_parser_app/templates/login.html @@ -1,12 +1,30 @@ {% extends "base.html" %} {% block body %} -
-

Вход

- {% if error %}

{{ error }}

{% endif %} -
- - - -
+
+
+
+

Вход в систему

+ + {% if error %} +
+ {{ error }} +
+ {% endif %} + +
+
+ + +
+
+ + +
+
+ +
+
+
+
{% endblock %} diff --git a/src/vk_parser_app/templates/logs.html b/src/vk_parser_app/templates/logs.html index 8b9b001..f65326c 100644 --- a/src/vk_parser_app/templates/logs.html +++ b/src/vk_parser_app/templates/logs.html @@ -1,105 +1,142 @@ {% extends "base.html" %} {% block body %} -
-
-

Логи

-
Журнал действий и запросов админки. События старше 30 дней удаляются автоматически.
-
+
+

Логи

+
Журнал действий и запросов админки. События старше 30 дней удаляются автоматически.
-
-
- -
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + Сбросить +
+ +
+ +
+
+ Всего записей: {{ pagination.total }} +
+
+ На странице: +
+ {% for item in preserved_query %} + {% if item.key != "per_page" %} + + {% endif %} {% endfor %} - - - - - - - - Сбросить -
- - -
-
- - - - - - - - - - - - {% for item in logs %} - - - - - - - - {% else %} - - {% endfor %} - -
ВремяПользовательДействиеСущностьДетали
{{ item.created_at_fmt }}{{ item.actor_label }}{{ item.action }}{{ item.entity_type }}{% if item.entity_id %} #{{ item.entity_id }}{% endif %} - {% if item.details_pretty %} -
- JSON -
{{ item.details_pretty }}
-
- {% else %} - - {% endif %} -
Записей нет.
- - {% include "pagination.html" %} - - - +
+ + + + + + + + + + + + {% for item in logs %} + + + + + + + + {% else %} + + + + {% endfor %} + +
ВремяПользовательДействиеСущностьДетали
{{ item.created_at_fmt }}{{ item.actor_label }}{{ item.action }} + {{ item.entity_type }} + {% if item.entity_id %} + #{{ item.entity_id }} + {% endif %} + + {% if item.details_pretty %} +
+ +
JSON payload
+
+
{{ item.details_pretty }}
+
+
+ {% else %} + + {% endif %} +
Записей нет.
+
+ + {% if pagination.total_pages > 1 %} +
+
+ « + + + + » +
+
+ {% endif %} +
{% endblock %} diff --git a/src/vk_parser_app/templates/prompt_test.html b/src/vk_parser_app/templates/prompt_test.html index 158a3b7..f7d0e21 100644 --- a/src/vk_parser_app/templates/prompt_test.html +++ b/src/vk_parser_app/templates/prompt_test.html @@ -1,118 +1,154 @@ {% extends "base.html" %} {% block body %} -
+
-

Тест промпта райтера

-

Песочница для свободной части ai_writer_prompt. Посты, рерайт и статусы в БД не меняются.

+

Тест промпта райтера

+
Песочница для свободной части ai_writer_prompt. Посты и статусы в БД не меняются.
- Настройки AI + Настройки AI
-
- Как пользоваться -
+
+ +
Как пользоваться
+
-
Что тестируем
-

Отправляется только текст из поля ниже как system prompt. ai_writer_contract специально не добавляется, чтобы можно было быстро проверять стиль, структуру и редакторские правила.

+
Что тестируем
+

Отправляется только текст из поля ниже как system prompt. ai_writer_contract специально не добавляется, чтобы проверять стиль и структуру.

-
Что уйдёт в модель
-

Один выбранный raw-пост в том же формате, что использует райтер: categories, producer_name, producer_tag, ссылка, AI-оценка, медиа и обрезанный исходный текст.

+
Что уйдёт в модель
+

Один выбранный raw-пост в формате: categories, producer, ссылка, оценка, медиа и обрезанный исходный текст.

-
Что безопасно менять
-

Голос канала, структуру текста, длину, правила про эмоджи, запрет выдумок, примеры формулировок и логику упоминания производителя/магазина. JSON-контракт рабочего воркера живёт отдельно.

+
Что безопасно менять
+

Голос канала, структуру текста, длину, правила про эмоджи, запрет выдумок, примеры формулировок.

-
Что не происходит
-

Рерайт не сохраняется, batch не создаётся, категория и статус поста не меняются. Тест только тратит токены текущей модели райтера.

+
Что не происходит
+

Рерайт не сохраняется, batch не создаётся. Тест только тратит токены текущей модели райтера.

-
+
-
+ -
-
- - -
- Модель -
{{ provider }}{{ model or "не выбрана" }}
+ +
+
+
+ +
+
+ + +
+
+ + +
+
+ +
+ Модель: + {{ provider }} + {{ model or "не выбрана" }} +
{% if selected %} -
-
- #{{ selected.id }} · {{ selected.source_name }} - Оригинал +
+
+
+ #{{ selected.id }} · {{ selected.source_name }} + Оригинал +
+
+ {{ selected.posted_at_fmt or selected.created_at_fmt }} · {{ selected.media_count }} медиа · оценка {{ selected.qualification_score or "—" }} +
+
{{ selected.raw_text }}
-
{{ selected.posted_at_fmt or selected.created_at_fmt }} · {{ selected.media_count }} медиа · {{ selected.media_types or [] }} · оценка {{ selected.qualification_score or "—" }}
-
{{ selected.raw_text }}
{% endif %} - -
- +
+
+
+ + +
+
+ +
+
-