feat: complete UI migration to Tailwind CSS + HTMX

This commit is contained in:
Your Name
2026-07-18 22:20:40 +05:00
parent baf0e858aa
commit 5220c4051b
19 changed files with 1497 additions and 1501 deletions
+102 -8
View File
@@ -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["last_checked_at_fmt"] = format_dt(item.get("last_checked_at"))
item["created_at_fmt"] = format_dt(item.get("created_at")) item["created_at_fmt"] = format_dt(item.get("created_at"))
sources.append(item) sources.append(item)
template_name = "sources_content.html" if request.headers.get("hx-request") else "sources.html"
return templates.TemplateResponse( return templates.TemplateResponse(
"sources.html", template_name,
base_context( base_context(
request, request,
user, user,
@@ -2317,6 +2318,25 @@ async def source_toggle(request: Request, source_id: int, csrf_token: str = Form
source_id, source_id,
) )
await audit(user["id"], "source.toggle", "source", source_id, {"active": bool(row["active"]) if row else None}) 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") return redirect("/sources")
@@ -2329,11 +2349,12 @@ async def source_delete(request: Request, source_id: int, csrf_token: str = Form
pool = await get_pool() pool = await get_pool()
await pool.execute("UPDATE sources SET archived_at=NOW(), active=FALSE, updated_at=NOW() WHERE id=$1", source_id) 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) await audit(user["id"], "source.archive", "source", source_id)
if request.headers.get("hx-request"):
return HTMLResponse("")
return redirect("/sources") return redirect("/sources")
@app.get("/raw", response_class=HTMLResponse) @app.get("/raw", response_class=HTMLResponse)
@app.get("/v2/raw", response_class=HTMLResponse)
async def raw_posts( async def raw_posts(
request: Request, request: Request,
status_filter: str = "", status_filter: str = "",
@@ -2482,11 +2503,7 @@ async def raw_posts(
) )
posts = [prepare_raw_post(row) for row in rows] posts = [prepare_raw_post(row) for row in rows]
is_v2 = request.url.path.startswith("/v2") template_name = "raw_posts_content.html" if request.headers.get("hx-request") else "raw_posts.html"
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"
return templates.TemplateResponse( return templates.TemplateResponse(
template_name, template_name,
@@ -2567,6 +2584,58 @@ async def raw_send_to_rewrite(
return redirect(safe_next) 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) @app.get("/editor", response_class=HTMLResponse)
async def editor_feed( async def editor_feed(
request: Request, request: Request,
@@ -2709,8 +2778,9 @@ async def editor_feed(
""" """
) )
counts = {str(row["status"]): int(row["count"]) for row in counts_rows} 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( return templates.TemplateResponse(
"editor.html", template_name,
base_context( base_context(
request, request,
user, user,
@@ -2782,6 +2852,14 @@ async def editor_accept(request: Request, post_id: int, csrf_token: str = Form(.
user["id"], user["id"],
) )
await audit(user["id"], "editor.accept", "raw_post", post_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") return redirect("/editor")
@@ -2807,6 +2885,14 @@ async def editor_reject(request: Request, post_id: int, csrf_token: str = Form(.
user["id"], user["id"],
) )
await audit(user["id"], "editor.reject", "raw_post", post_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") return redirect("/editor")
@@ -2934,6 +3020,14 @@ async def editor_save(
user["id"], user["id"],
) )
await audit(user["id"], "editor.save" if status_value == "edited" else "editor.save_accept", "raw_post", post_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") return redirect("/editor")
+54 -235
View File
@@ -1,246 +1,65 @@
<!doctype html> <!doctype html>
<html lang="ru"> <html lang="ru" data-theme="light">
<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">
<link rel="icon" type="image/png" href="/favi.png"> <link rel="icon" type="image/png" href="/favi.png">
<title>{{ title or "VK Parser Admin" }}</title> <title>{{ title or "VK Parser Admin" }}</title>
<!-- Tailwind CSS & DaisyUI -->
<link href="https://cdn.jsdelivr.net/npm/daisyui@4.10.1/dist/full.min.css" rel="stylesheet" type="text/css" />
<script src="https://cdn.tailwindcss.com"></script>
<!-- HTMX -->
<script src="https://unpkg.com/htmx.org@1.9.11"></script>
<!-- AlpineJS (optional but useful for local state) -->
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.13.8/dist/cdn.min.js"></script>
<style> <style>
:root { color-scheme: light; --bg:#f4f6f8; --panel:#fff; --panel-soft:#f8fafc; --line:#d8dee8; --line-strong:#c8d0dc; --text:#20242b; --muted:#647086; --accent:#245fd6; --accent-soft:#eef4ff; --danger:#b42318; --ok:#027a48; --shadow:0 1px 2px rgba(16,24,40,.04), 0 10px 30px rgba(16,24,40,.06); } /* Custom tweaks to match Tailwind typography nicely */
* { box-sizing: border-box; } body { font-family: 'Inter', system-ui, sans-serif; }
body { margin:0; font:14px/1.45 Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Arial, sans-serif; color:var(--text); background:var(--bg); } .htmx-indicator { display:none; }
header { position:sticky; top:0; z-index:40; min-height:58px; display:flex; align-items:center; justify-content:space-between; gap:16px; padding:0 24px; background:rgba(255,255,255,.92); border-bottom:1px solid var(--line); backdrop-filter:blur(12px); } .htmx-request .htmx-indicator { display:inline-block; }
nav { display:flex; gap:4px; align-items:center; flex-wrap:wrap; } .htmx-request.htmx-indicator { display:inline-block; }
nav a { color:var(--muted); text-decoration:none; font-weight:700; padding:8px 10px; border-radius:7px; }
nav a:hover { color:var(--text); background:var(--panel-soft); }
main { padding:26px 24px 40px; max-width:1440px; margin:0 auto; }
h1 { font-size:34px; line-height:1.1; margin:0 0 8px; letter-spacing:0; }
h2 { font-size:21px; line-height:1.2; margin:0 0 8px; letter-spacing:0; }
.panel { background:var(--panel); border:1px solid var(--line); border-radius:8px; padding:16px; margin-bottom:16px; box-shadow:var(--shadow); }
details.panel summary { cursor:pointer; list-style:none; font-size:16px; margin:-4px 0 12px; }
details.panel summary::-webkit-details-marker { display:none; }
details.panel summary:before { content:"▸"; display:inline-block; width:18px; color:var(--muted); }
details.panel[open] summary:before { content:"▾"; }
.toolbar { display:flex; gap:10px; align-items:end; justify-content:space-between; flex-wrap:wrap; margin-bottom:14px; }
.row { display:flex; gap:8px; align-items:center; flex-wrap:wrap; }
table { width:100%; border-collapse:separate; border-spacing:0; background:var(--panel); }
th, td { padding:10px; border-bottom:1px solid var(--line); text-align:left; vertical-align:top; }
th { color:var(--muted); font-size:12px; text-transform:uppercase; }
input, select, textarea { width:100%; padding:9px 10px; border:1px solid var(--line); border-radius:7px; font:inherit; background:#fff; color:var(--text); outline:none; }
input:focus, select:focus, textarea:focus { border-color:var(--accent); box-shadow:0 0 0 3px rgba(36,95,214,.12); }
label { display:block; color:var(--muted); font-weight:600; margin-bottom:8px; }
label span { display:block; margin-bottom:4px; }
.form-grid { display:grid; grid-template-columns:repeat(2, minmax(0,1fr)); gap:14px; }
.filter-grid { display:grid; grid-template-columns:repeat(auto-fit, minmax(150px, 1fr)); gap:12px; align-items:end; }
.sources-filter { grid-template-columns:minmax(260px, 1fr) 160px 140px auto; }
.filter-submit { height:38px; margin-bottom:0; }
.btn { display:inline-flex; align-items:center; justify-content:center; gap:6px; min-height:36px; padding:8px 12px; border:1px solid var(--line); border-radius:7px; background:#fff; color:var(--text); text-decoration:none; cursor:pointer; font-weight:700; white-space:nowrap; }
.btn:hover { border-color:var(--line-strong); background:var(--panel-soft); }
.btn.primary { background:var(--accent); border-color:var(--accent); color:#fff; }
.btn.danger { color:var(--danger); }
.pill { display:inline-flex; padding:3px 8px; border-radius:999px; border:1px solid var(--line); color:var(--muted); font-size:12px; }
.pill.ok { color:var(--ok); border-color:#a6f4c5; background:#ecfdf3; }
.pill.bad { color:var(--danger); border-color:#fecdca; background:#fef3f2; }
.pill.warn { color:#92400e; border-color:#fedf89; background:#fffaeb; }
.muted { color:var(--muted); }
.mono { font-family: Consolas, monospace; }
.actions { display:flex; gap:6px; flex-wrap:wrap; }
.compact-table th, .compact-table td { padding:7px 8px; }
.source-state { margin-left:6px; }
.source-url { max-width:300px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.status-msg { max-width:220px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.icon-actions { display:flex; gap:4px; align-items:center; justify-content:flex-end; white-space:nowrap; }
.icon-actions form { margin:0; }
.icon-btn { width:30px; height:30px; display:inline-flex; align-items:center; justify-content:center; border:1px solid var(--line); border-radius:6px; background:#fff; color:var(--text); text-decoration:none; cursor:pointer; font:18px/1 Arial, sans-serif; }
.icon-btn:hover { border-color:#b9c0cc; background:#f8fafc; }
.icon-btn.danger { color:var(--danger); }
.source-add-grid { display:grid; grid-template-columns:minmax(260px, 360px) minmax(360px, 1fr); gap:18px; align-items:start; }
.checkline { display:flex; gap:8px; align-items:center; color:var(--text); font-weight:600; }
.checkline input { width:auto; }
.preview-block { margin-top:16px; border-top:1px solid var(--line); padding-top:14px; }
.pagination { display:flex; justify-content:flex-end; margin-top:12px; }
.pagination-form { display:flex; gap:8px; align-items:center; }
.pagination button[disabled] { opacity:.45; cursor:not-allowed; }
.raw-id { color:var(--text); font-weight:700; text-decoration:none; }
.source-cell { min-width:150px; }
.source-cell a { font-weight:700; }
.stage-cell { min-width:112px; }
.raw-table { table-layout:fixed; }
.raw-table th:nth-child(1), .raw-table td:nth-child(1) { width:82px; }
.raw-table th:nth-child(2), .raw-table td:nth-child(2) { width:170px; }
.raw-table th:nth-child(3), .raw-table td:nth-child(3) { width:120px; }
.raw-table th:nth-child(5), .raw-table td:nth-child(5) { width:96px; }
.raw-content-cell { max-width:none; min-width:0; overflow-wrap:anywhere; }
.raw-text-details { margin-top:8px; }
.raw-text-details summary { cursor:pointer; color:var(--text); white-space:pre-wrap; }
.raw-full-text { margin-top:8px; white-space:pre-wrap; overflow-wrap:anywhere; }
.raw-text-details summary { overflow-wrap:anywhere; }
.raw-content-cell > .raw-full-text:first-child,
.media-strip + .raw-full-text { margin-top:8px; }
.media-strip { display:flex; gap:6px; max-width:100%; overflow-x:auto; padding:1px 0 5px; margin-bottom:2px; }
.media-thumb { flex:0 0 48px; width:48px; height:48px; padding:0; border:1px solid var(--line); border-radius:6px; overflow:hidden; display:flex; align-items:center; justify-content:center; background:#eef1f6; color:var(--text); text-decoration:none; cursor:pointer; }
.media-thumb img { width:100%; height:100%; object-fit:cover; display:block; }
.media-video span { font-size:18px; line-height:1; }
.media-count { font-size:12px; margin-bottom:4px; }
.raw-meta { display:grid; grid-template-columns:1fr; gap:8px; min-width:130px; }
.raw-links { display:flex; flex-direction:column; gap:3px; }
.raw-dates { display:grid; gap:2px; color:var(--text); }
.post-meta { margin-top:8px; color:var(--muted); font-size:12px; }
.ai-cell { width:96px; }
.ai-badge { width:82px; min-height:58px; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:2px; border:1px solid var(--line); border-radius:8px; color:var(--muted); text-decoration:none; background:#f8fafc; }
.ai-badge span { color:var(--text); font-size:18px; line-height:1; font-weight:800; }
.ai-badge small { max-width:72px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size:11px; }
.ai-badge.ok { border-color:#a6f4c5; background:#ecfdf3; color:var(--ok); }
.ai-badge.ok span { color:var(--ok); }
.ai-badge.warn { border-color:#fedf89; background:#fffaeb; color:#92400e; }
.ai-badge.warn span { color:#92400e; }
.ai-badge.bad { border-color:#fecdca; background:#fef3f2; color:var(--danger); }
.ai-badge.bad span { color:var(--danger); }
.ai-model-decision { width:92px; margin-top:5px; font-size:12px; overflow:hidden; text-overflow:ellipsis; }
.detail-grid { display:grid; grid-template-columns:minmax(0, 1fr) 320px; gap:16px; align-items:start; }
.side-panel { position:sticky; top:72px; }
.detail-media { padding-bottom:8px; }
.detail-thumb { flex-basis:72px; width:72px; height:72px; }
.editable-media { position:relative; flex:0 0 auto; }
.editable-media.marked-delete { opacity:.38; filter:grayscale(1); }
.media-remove { position:absolute; top:-6px; right:-6px; width:22px; height:22px; border:1px solid var(--line); border-radius:999px; background:#fff; color:var(--danger); font-weight:900; cursor:pointer; box-shadow:var(--shadow); }
.media-upload-form { display:block; margin:8px 0 8px; }
.detail-text { font-size:15px; line-height:1.55; }
.detail-ai { width:110px; min-height:72px; margin:8px 0 16px; }
.rewrite-text { white-space:pre-wrap; font-size:16px; line-height:1.55; max-width:820px; }
.rewrite-meta { margin-top:14px; }
.kv { display:grid; grid-template-columns:96px minmax(0,1fr); gap:8px 12px; margin:0; }
.kv dt { color:var(--muted); font-weight:700; }
.kv dd { margin:0; overflow-wrap:anywhere; }
.kv-wide { grid-template-columns:180px minmax(0,1fr); margin-bottom:14px; }
.json-box { white-space:pre-wrap; overflow:auto; max-height:420px; padding:12px; border:1px solid var(--line); border-radius:8px; background:#0f172a; color:#e2e8f0; }
.prompt-editor { display:grid; gap:8px; }
.prompt-hint { max-width:720px; border:1px solid var(--line); border-radius:8px; padding:10px 12px; background:#f8fafc; display:grid; gap:8px; color:var(--text); }
.prompt-hint summary { cursor:pointer; font-weight:700; }
.prompt-hint pre, .prompt-box { white-space:pre-wrap; overflow:auto; padding:10px; border:1px solid var(--line); border-radius:8px; background:#f8fafc; color:var(--text); }
.prompt-test-layout { display:grid; grid-template-columns:minmax(0, 1fr) 420px; gap:16px; align-items:start; }
.prompt-test-controls { display:grid; grid-template-columns:150px minmax(260px, 1fr) minmax(220px, .7fr); gap:12px; align-items:end; }
.prompt-test-main { margin-bottom:0; }
.prompt-test-side { display:grid; gap:16px; position:sticky; top:74px; max-height:calc(100vh - 92px); overflow:auto; }
.prompt-test-model { min-height:38px; display:flex; align-items:center; gap:8px; flex-wrap:wrap; }
.prompt-test-post { margin:12px 0 14px; border:1px solid var(--line); border-radius:8px; padding:12px; background:var(--panel-soft); }
.prompt-test-post-head { display:flex; justify-content:space-between; gap:12px; align-items:center; margin-bottom:4px; }
.prompt-test-post pre { max-height:260px; overflow:auto; margin:10px 0 0; white-space:pre-wrap; font:inherit; color:var(--text); }
.prompt-test-help p { margin:0; }
.human-answer { white-space:pre-wrap; overflow:auto; max-height:360px; padding:12px; border:1px solid #b7caf8; border-radius:8px; background:#f8fbff; color:var(--text); font:16px/1.55 Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Arial, sans-serif; }
.hint-grid { display:grid; grid-template-columns:repeat(2, minmax(0,1fr)); gap:10px; }
.hint-label { color:var(--muted); font-size:12px; font-weight:700; text-transform:uppercase; margin-bottom:3px; }
.small-help { font-size:12px; margin-top:4px; }
.secret-editor { display:grid; gap:3px; }
.secret-visible { font-family:Consolas, monospace; }
.category-create { display:grid; grid-template-columns:minmax(180px, 1fr) minmax(180px, 1fr) auto; gap:10px; align-items:end; margin:10px 0 14px; }
.category-create label { margin-bottom:0; }
.category-table input { min-height:34px; padding:7px 8px; }
.category-table th:nth-child(3), .category-table td:nth-child(3) { width:96px; }
.category-table th:nth-child(4), .category-table td:nth-child(4) { width:110px; }
.category-table th:nth-child(5), .category-table td:nth-child(5) { width:120px; }
.advanced-tools { grid-column:1 / -1; border-top:1px solid var(--line); margin-top:4px; padding-top:10px; }
.advanced-tools summary { cursor:pointer; font-weight:700; color:var(--text); }
.advanced-grid { display:grid; grid-template-columns:minmax(0,1fr) minmax(0,1fr); gap:16px; margin-top:10px; }
.tool-title { font-weight:700; margin-bottom:6px; }
.tool-row { display:grid; grid-template-columns:minmax(130px, 1.1fr) 120px minmax(130px, 1fr); gap:8px; margin-bottom:8px; }
.tool-row.sort-row { grid-template-columns:minmax(170px, 1fr) 150px; }
.nested-details { margin:12px 0; }
.nested-details summary { cursor:pointer; font-weight:700; }
.gallery-modal { position:fixed; inset:0; z-index:5000; display:none; align-items:center; justify-content:center; padding:48px; background:rgba(15,18,25,.86); border:0; width:100vw; height:100vh; max-width:none; max-height:none; }
.gallery-modal::backdrop { background:rgba(15,18,25,.86); }
.gallery-modal.open { display:flex; }
.gallery-modal img { max-width:calc(100vw - 130px); max-height:calc(100vh - 120px); object-fit:contain; border-radius:8px; box-shadow:0 20px 60px rgba(0,0,0,.45); background:#111; }
.gallery-close, .gallery-nav { position:absolute; border:0; background:rgba(255,255,255,.12); color:#fff; cursor:pointer; }
.gallery-close { top:18px; right:22px; width:38px; height:38px; border-radius:50%; font-size:30px; line-height:1; }
.gallery-nav { top:50%; transform:translateY(-50%); width:44px; height:64px; border-radius:8px; font-size:42px; line-height:1; }
.gallery-prev { left:22px; }
.gallery-next { right:22px; }
.gallery-title { position:absolute; left:24px; bottom:18px; color:#fff; font-weight:600; }
.page-head { display:flex; align-items:flex-end; justify-content:space-between; gap:16px; margin-bottom:16px; }
.status-tabs { display:flex; gap:6px; flex-wrap:wrap; justify-content:flex-end; }
.tab { display:inline-flex; align-items:center; gap:7px; min-height:34px; padding:7px 10px; border:1px solid var(--line); border-radius:999px; color:var(--muted); background:#fff; text-decoration:none; font-weight:700; }
.tab span { color:var(--text); background:var(--panel-soft); border-radius:999px; padding:1px 7px; font-size:12px; }
.tab.active { color:var(--accent); border-color:#b7caf8; background:var(--accent-soft); }
.editor-filter { grid-template-columns:180px 180px 140px auto; }
.catalog-layout { display:grid; grid-template-columns:minmax(0, 1fr) 306px; gap:16px; align-items:start; }
.catalog-main { min-width:0; overflow:hidden; }
.filter-sidebar { position:sticky; top:74px; max-height:calc(100vh - 92px); overflow:auto; overflow-x:hidden; padding:14px; }
.filter-sidebar input, .filter-sidebar select { min-width:0; }
.filter-head { display:flex; align-items:center; justify-content:space-between; gap:10px; margin-bottom:8px; }
.filter-head h2 { margin:0; }
.filter-head a { color:var(--muted); font-weight:700; text-decoration:none; }
.filter-section { border-top:1px solid var(--line); padding-top:12px; margin-top:12px; }
.filter-title { font-weight:800; margin-bottom:8px; }
.range-row { display:grid; grid-template-columns:minmax(0,1fr) minmax(0,1fr); gap:8px; }
.facet-search { margin-bottom:8px; }
.facet-list { display:grid; gap:4px; max-height:220px; overflow:auto; padding-right:3px; }
.facet-list.compact { max-height:160px; }
.facet-option { display:grid; grid-template-columns:auto minmax(0,1fr) auto; align-items:center; gap:8px; margin:0; padding:6px 7px; border-radius:7px; color:var(--text); font-weight:600; cursor:pointer; }
.facet-option:hover { background:var(--panel-soft); }
.facet-option input { width:16px; height:16px; margin:0; }
.facet-option span { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; margin:0; }
.facet-option small { color:var(--muted); font-weight:700; }
.filter-apply { width:100%; margin-top:14px; }
.list-toolbar { display:flex; justify-content:space-between; align-items:end; gap:12px; margin-bottom:12px; }
.list-footer { display:flex; align-items:center; justify-content:space-between; gap:12px; flex-wrap:wrap; margin-top:12px; }
.list-footer .pagination { margin-top:0; }
.per-page-form label { width:126px; margin-bottom:0; }
.compact-controls { justify-content:flex-end; }
.compact-controls label { width:170px; margin-bottom:0; }
.sort-head { display:inline-flex; align-items:center; gap:4px; color:inherit; text-decoration:none; }
.sort-head:hover { color:var(--accent); }
.sort-head.active:after { content:"↓"; font-size:11px; color:var(--accent); }
.sort-head.active.asc:after { content:"↑"; }
.raw-select-head, .raw-select-cell { display:flex; align-items:center; gap:8px; margin:0; }
.raw-select-head input, .raw-select-cell input { width:16px; height:16px; margin:0; flex:0 0 auto; }
.editor-list { display:grid; gap:12px; }
.editor-card { display:grid; grid-template-columns:112px minmax(0,1fr) 132px; gap:16px; align-items:start; background:var(--panel); border:1px solid var(--line); border-radius:8px; padding:14px; box-shadow:var(--shadow); }
.editor-media .media-strip { display:grid; grid-template-columns:repeat(2, 48px); overflow:visible; }
.editor-main { min-width:0; }
.editor-meta { display:flex; gap:8px; align-items:center; flex-wrap:wrap; color:var(--muted); font-size:13px; margin-bottom:8px; }
.editor-meta a { color:var(--text); font-weight:800; text-decoration:none; }
.editor-title-row { display:flex; gap:8px; align-items:center; flex-wrap:wrap; margin-bottom:8px; }
.publication-badges { display:flex; gap:6px; flex-wrap:wrap; margin:0 0 8px; }
.publication-badges .pill { text-decoration:none; }
.editor-text { white-space:pre-wrap; line-height:1.55; overflow-wrap:anywhere; }
.source-compare { margin-top:10px; border-top:1px solid var(--line); padding-top:8px; }
.source-compare summary { cursor:pointer; color:var(--muted); font-weight:700; }
.compare-grid { display:grid; grid-template-columns:1.3fr .7fr; gap:14px; margin-top:8px; }
.editor-actions { display:grid; gap:8px; }
.editor-actions form, .editor-actions button { width:100%; }
.editor-dialog { width:min(1180px, calc(100vw - 36px)); max-height:calc(100vh - 36px); border:1px solid var(--line); border-radius:8px; padding:0; box-shadow:0 24px 80px rgba(16,24,40,.28); }
.editor-dialog::backdrop { background:rgba(15,23,42,.56); backdrop-filter:blur(4px); }
.dialog-head { position:sticky; top:0; z-index:2; display:flex; align-items:center; justify-content:space-between; gap:12px; padding:16px 18px; border-bottom:1px solid var(--line); background:#fff; }
.dialog-grid { display:grid; grid-template-columns:420px minmax(0,1fr); gap:0; min-height:620px; }
.context-pane { border-right:1px solid var(--line); background:var(--panel-soft); padding:16px; overflow:auto; max-height:calc(100vh - 112px); }
.context-pane details { border-top:1px solid var(--line); padding-top:10px; margin-top:12px; }
.context-pane summary { cursor:pointer; font-weight:800; }
.edit-pane { padding:16px; overflow:auto; max-height:calc(100vh - 112px); }
.edit-form textarea[name="final_text"] { min-height:360px; resize:vertical; line-height:1.55; }
.hashtag-preview { margin:-2px 0 10px; color:var(--muted); font-family:Consolas, monospace; }
.dialog-actions { display:flex; gap:8px; justify-content:flex-end; flex-wrap:wrap; }
.regen-form { margin-top:16px; border-top:1px solid var(--line); padding-top:14px; }
.compact-kv { margin-top:12px; grid-template-columns:90px minmax(0,1fr); }
.empty-state { text-align:center; color:var(--muted); padding:34px; }
@media (max-width: 900px) { header { padding:8px 12px; align-items:flex-start; } .filter-grid, .sources-filter, .source-add-grid, .hint-grid, .advanced-grid, .tool-row, .tool-row.sort-row, .editor-filter, .page-head, .compare-grid, .dialog-grid, .catalog-layout, .media-upload-form, .category-create, .prompt-test-layout, .prompt-test-controls { grid-template-columns:1fr; } .page-head { display:grid; } .form-grid { grid-template-columns:1fr; } .detail-grid { grid-template-columns:1fr; } .editor-card { grid-template-columns:1fr; } .editor-actions { grid-template-columns:repeat(3, minmax(0,1fr)); } .context-pane { border-right:0; border-bottom:1px solid var(--line); max-height:none; } .edit-pane { max-height:none; } .side-panel, .filter-sidebar, .prompt-test-side { position:static; max-height:none; } .list-toolbar { display:grid; align-items:start; } .compact-controls { justify-content:start; } main { padding:14px; } }
</style> </style>
</head> </head>
<body> <body class="bg-base-200 min-h-screen">
{% if user %}
<header> {% if user %}
<nav> <div class="navbar bg-base-100 shadow-sm sticky top-0 z-50">
<a href="/sources">Источники</a> <div class="flex-1 px-2 mx-2">
<a href="/raw">Raw</a> <span class="text-lg font-bold">Parser Admin</span>
<a href="/editor">Редакторская</a> </div>
<a href="/prompt-test">Тест промптов</a> <div class="flex-none">
<a href="/workers">Воркеры</a> <ul class="menu menu-horizontal px-1">
<a href="/logs">Логи</a> <li><a href="/raw" class="{{ 'active' if 'raw' in request.url.path }}">Сырые посты</a></li>
<a href="/users">Пользователи</a> <li><a href="/editor" class="{{ 'active' if 'editor' in request.url.path }}">Редактор</a></li>
</nav> <li><a href="/sources" class="{{ 'active' if 'sources' in request.url.path }}">Источники</a></li>
<form method="post" action="/logout"><button class="btn" type="submit">{{ user.login }} · выйти</button></form> <li><a href="/workers" class="{{ 'active' if 'workers' in request.url.path }}">Воркеры</a></li>
</header> </ul>
{% endif %} </div>
<main>{% block body %}{% endblock %}</main> <div class="flex-none hidden lg:block">
<ul class="menu menu-horizontal gap-2">
<li>
<form method="post" action="/logout" class="m-0">
<input type="hidden" name="csrf_token" value="{{ user.csrf_token }}">
<button class="btn btn-ghost btn-sm text-error">Выйти</button>
</form>
</li>
</ul>
</div>
</div>
{% endif %}
<main class="container mx-auto p-4 md:p-6 max-w-7xl">
{% block body %}{% endblock %}
</main>
<script>
// Handle HTMX errors
document.body.addEventListener('htmx:responseError', function(evt) {
alert("Ошибка при загрузке: " + evt.detail.xhr.status);
});
</script>
</body> </body>
</html> </html>
+11 -346
View File
@@ -1,359 +1,24 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block body %} {% block body %}
<div class="page-head"> <div class="mb-6 flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
<div> <div>
<h1>Редакторская</h1> <h1 class="text-3xl font-bold">Редакторская</h1>
<div class="muted">Посты после AI-рерайта: быстрая проверка, правка и принятие.</div> <div class="text-base-content/60 mt-1">Посты после AI-рерайта: быстрая проверка, правка и принятие.</div>
</div> </div>
<div class="status-tabs"> <div class="tabs tabs-boxed bg-base-200">
{% for st in status_options %} {% for st in status_options %}
<a class="tab {% if st.value in editorial_statuses %}active{% endif %}" href="/editor?editorial_status={{ st.value }}"> <a class="tab {% if st.value in editorial_statuses %}tab-active{% endif %}" href="/editor?editorial_status={{ st.value }}">
{{ st.label }} <span>{{ counts.get(st.value, 0) }}</span> {{ st.label }}
<span class="badge badge-sm ml-2 {% if st.value in editorial_statuses %}badge-primary{% else %}badge-ghost{% endif %}">{{ counts.get(st.value, 0) }}</span>
</a> </a>
{% endfor %} {% endfor %}
</div> </div>
</div> </div>
<div class="catalog-layout editor-catalog"> <div id="content-area">
<section class="catalog-main"> {% include "editor_content.html" %}
<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>
<div class="editor-list"> <!-- Modal container for HTMX forms -->
{% for p in posts %} <div id="modal-container"></div>
<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 in ['accepted', 'published'] %}ok{% elif p.editorial_status in ['rejected', 'publish_failed'] %}bad{% elif p.editorial_status == 'regenerating' %}warn{% endif %}" title="{{ p.publication_error or p.tg_publication_url or '' }}">
{{ 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="publication-badges">
{% for pub in p.publication_platforms %}
{% if pub.url %}
<a class="pill {{ pub.class }}" href="{{ pub.url }}" target="_blank" title="{{ pub.error or pub.status_label }}">{{ pub.label }} · {{ pub.status_label }}</a>
{% else %}
<span class="pill {{ pub.class }}" title="{{ pub.error or pub.status_label }}">{{ pub.label }} · {{ pub.status_label }}</span>
{% endif %}
{% endfor %}
</div>
<div class="editor-text">{{ p.publication_preview_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.name }}" data-tag="{{ category.tag }}" {% if p.review_category == category.name %}selected{% endif %}>{{ category.name }}</option>
{% endfor %}
{% set category_names = categories | map(attribute="name") | list %}
{% if p.review_category and p.review_category not in category_names %}
<option value="{{ p.review_category }}" data-tag="{{ p.review_category_tag }}" 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>
<label><span>Предпросмотр публикации</span>
<div class="publication-preview" data-publication-preview>{{ p.publication_preview_text }}</div>
</label>
<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 textArea = form.querySelector('[name="final_text"]');
const category = form.querySelector('[name="final_category"]');
const source = form.querySelector('[name="final_source_tag"]');
const hashtagPreview = form.querySelector("[data-hashtag-preview]");
const publicationPreview = form.querySelector("[data-publication-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 = () => {
const option = category?.selectedOptions?.[0];
const tags = `#${normalize(option?.dataset?.tag || category?.value, "category")} #${normalize(source?.value, "source")}`;
if (hashtagPreview) hashtagPreview.textContent = tags;
if (publicationPreview) {
const cleanText = String(textArea?.value || "").trim();
publicationPreview.textContent = cleanText ? `${cleanText}\n\n${tags}` : tags;
}
};
textArea?.addEventListener("input", update);
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 %} {% endblock %}
@@ -0,0 +1,129 @@
<div class="flex flex-col lg:flex-row gap-6">
<!-- Sidebar Filters -->
<aside class="w-full lg:w-72 flex-shrink-0">
<div class="card bg-base-100 shadow-sm border border-base-200">
<div class="card-body p-4">
<form hx-get="/editor" hx-target="#content-area" hx-push-url="true" hx-trigger="submit, change delay:300ms">
{% for item in preserved_query %}
{% if item.key != "per_page" and item.key != "sort" and item.key != "editorial_status" %}
<input type="hidden" name="{{ item.key }}" value="{{ item.value }}">
{% endif %}
{% endfor %}
<input type="hidden" name="editorial_status" value="{{ status_filter }}">
<div class="flex justify-between items-center mb-4">
<h2 class="card-title text-lg">Фильтры</h2>
<a href="/editor" class="text-sm link link-hover text-base-content/60" hx-boost="true" hx-target="#content-area">Сбросить</a>
</div>
<div class="form-control w-full mb-4">
<label class="label"><span class="label-text font-bold">Сортировка</span></label>
<select name="sort" class="select select-bordered select-sm w-full">
<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>
</div>
<div class="form-control w-full mb-4">
<label class="label"><span class="label-text font-bold">Оценка AI</span></label>
<div class="flex gap-2">
<input type="number" name="score_min" placeholder="От" value="{{ score_min }}" class="input input-bordered input-sm w-full" />
<input type="number" name="score_max" placeholder="До" value="{{ score_max }}" class="input input-bordered input-sm w-full" />
</div>
</div>
<div class="form-control w-full mb-4">
<label class="label"><span class="label-text font-bold">Дата поста VK</span></label>
<div class="flex flex-col gap-2">
<input type="date" name="date_from" value="{{ date_from }}" class="input input-bordered input-sm w-full" />
<input type="date" name="date_to" value="{{ date_to }}" class="input input-bordered input-sm w-full" />
</div>
</div>
<div class="form-control w-full mb-4">
<label class="label"><span class="label-text font-bold">Источник</span></label>
<div class="bg-base-200 rounded-lg p-2 max-h-48 overflow-y-auto">
{% for item in facets.sources %}
<label class="label cursor-pointer justify-start gap-2 p-1">
<input type="checkbox" name="source_id" value="{{ item.value }}" class="checkbox checkbox-sm checkbox-primary" {% if item.value|string in source_ids %}checked{% endif %} />
<span class="label-text flex-1 truncate" title="{{ item.label }}">{{ item.label }}</span>
<span class="badge badge-sm badge-ghost">{{ item.count }}</span>
</label>
{% endfor %}
</div>
</div>
<div class="form-control w-full mb-4">
<label class="label"><span class="label-text font-bold">Категория</span></label>
<div class="bg-base-200 rounded-lg p-2 max-h-48 overflow-y-auto">
{% for item in facets.categories %}
<label class="label cursor-pointer justify-start gap-2 p-1">
<input type="checkbox" name="category" value="{{ item.value }}" class="checkbox checkbox-sm checkbox-primary" {% if item.value in categories_selected %}checked{% endif %} />
<span class="label-text flex-1">{{ item.value or "Без категории" }}</span>
<span class="badge badge-sm badge-ghost">{{ item.count }}</span>
</label>
{% endfor %}
</div>
</div>
<button class="btn btn-primary w-full mt-4" type="submit">Применить</button>
</form>
</div>
</div>
</aside>
<!-- Main Content (List) -->
<div class="flex-1 min-w-0 flex flex-col gap-4">
<div class="flex justify-between items-center px-2">
<div class="text-sm text-base-content/70">
Всего постов: <span class="font-bold">{{ pagination.total }}</span>
</div>
<div class="flex items-center gap-2">
<span class="text-sm">На странице:</span>
<select name="per_page" class="select select-bordered select-sm" form="pagination-form" hx-get="/editor" hx-target="#content-area">
{% for n in [10,25,50,100] %}
<option value="{{ n }}" {% if pagination.per_page == n %}selected{% endif %}>{{ n }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="flex flex-col gap-6">
{% for p in posts %}
{% include "editor_post.html" %}
{% else %}
<div class="card bg-base-100 shadow-sm border border-base-200 p-8 text-center text-base-content/60">
В этой очереди пока нет постов.
</div>
{% endfor %}
</div>
<!-- Pagination -->
{% if pagination.total_pages > 1 %}
<div class="flex justify-center mt-4">
<form id="pagination-form" hx-get="/editor" hx-target="#content-area" hx-push-url="true" class="join">
<input type="hidden" name="sort" value="{{ sort }}">
<input type="hidden" name="score_min" value="{{ score_min }}">
<input type="hidden" name="score_max" value="{{ score_max }}">
<input type="hidden" name="date_from" value="{{ date_from }}">
<input type="hidden" name="date_to" value="{{ date_to }}">
<input type="hidden" name="editorial_status" value="{{ status_filter }}">
{% for cat in categories_selected %}<input type="hidden" name="category" value="{{ cat }}">{% endfor %}
{% for sid in source_ids %}<input type="hidden" name="source_id" value="{{ sid }}">{% endfor %}
<button class="join-item btn btn-sm" name="page" value="1" {% if pagination.page == 1 %}disabled{% endif %}>«</button>
<button class="join-item btn btn-sm" name="page" value="{{ pagination.page - 1 }}" {% if pagination.page == 1 %}disabled{% endif %}></button>
<button class="join-item btn btn-sm btn-disabled">Стр. {{ pagination.page }} из {{ pagination.total_pages }}</button>
<button class="join-item btn btn-sm" name="page" value="{{ pagination.page + 1 }}" {% if pagination.page >= pagination.total_pages %}disabled{% endif %}></button>
<button class="join-item btn btn-sm" name="page" value="{{ pagination.total_pages }}" {% if pagination.page >= pagination.total_pages %}disabled{% endif %}>»</button>
</form>
</div>
{% endif %}
</div>
</div>
@@ -0,0 +1,165 @@
<div id="post-container-{{ p.id }}" class="card bg-base-100 shadow-sm border border-base-200 overflow-hidden">
<div class="card-body p-0 flex flex-col md:flex-row">
<!-- Media Column -->
<div class="w-full md:w-48 bg-base-200 border-r border-base-200 flex flex-col items-center justify-center p-4">
<div class="grid grid-cols-2 gap-2 mb-2 w-full">
{% for m in p.media_items %}
{% if m.type == "photo" and m.url %}
<div class="aspect-square bg-base-300 rounded overflow-hidden">
<img src="{{ m.url }}" class="w-full h-full object-cover" alt="photo" title="{{ m.status }}">
</div>
{% elif m.url %}
<a href="{{ m.url }}" target="_blank" class="aspect-square bg-base-300 rounded flex items-center justify-center hover:bg-base-content/20 text-xl" title="{{ m.error or m.status }}"></a>
{% endif %}
{% endfor %}
</div>
<div class="text-xs text-base-content/60 font-bold uppercase tracking-widest">{{ p.media_count or 0 }} медиа</div>
</div>
<!-- Main Content -->
<div class="p-6 flex-1 flex flex-col gap-4">
<div class="flex flex-wrap items-center justify-between gap-4">
<div class="flex items-center gap-2 text-sm text-base-content/70">
<a href="{{ p.original_url }}" target="_blank" class="link link-hover font-medium text-base-content">{{ p.source_name }}</a>
<span>&bull;</span>
<span class="badge badge-sm">#{{ p.review_source_tag or p.source_tag or "source" }}</span>
<span>&bull;</span>
<span>{{ p.posted_at_fmt or p.created_at_fmt }}</span>
</div>
<div class="flex items-center gap-2">
<span class="badge {% if p.editorial_status in ['accepted', 'published'] %}badge-success{% elif p.editorial_status in ['rejected', 'publish_failed'] %}badge-error{% elif p.editorial_status == 'regenerating' %}badge-warning{% else %}badge-ghost{% endif %}">
{{ p.editorial_status_label }}
</span>
<span class="badge badge-outline">{{ p.review_category or "Без категории" }}</span>
<span class="badge badge-neutral">AI {{ p.qualification_score or "?" }}/10</span>
</div>
</div>
<div class="flex flex-wrap gap-2">
{% for pub in p.publication_platforms %}
{% if pub.url %}
<a class="badge badge-sm badge-info gap-1" href="{{ pub.url }}" target="_blank" title="{{ pub.error or pub.status_label }}">
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" /></svg>
{{ pub.label }}
</a>
{% else %}
<span class="badge badge-sm {{ 'badge-error' if pub.status == 'error' else 'badge-ghost' }}" title="{{ pub.error or pub.status_label }}">
{{ pub.label }}
</span>
{% endif %}
{% endfor %}
</div>
<div class="prose prose-sm max-w-none text-base-content whitespace-pre-wrap leading-snug">
{{ p.publication_preview_text }}
</div>
<div class="collapse collapse-arrow bg-base-200 border border-base-300 rounded-lg">
<input type="checkbox" />
<div class="collapse-title text-sm font-medium">Исходник и AI-заметки</div>
<div class="collapse-content text-sm flex flex-col md:flex-row gap-6 border-t border-base-300 pt-4">
<div class="flex-1">
<div class="font-bold text-xs uppercase tracking-widest text-base-content/60 mb-2">Оригинал</div>
<div class="whitespace-pre-wrap text-base-content/80">{{ p.raw_text }}</div>
</div>
<div class="flex-1">
<div class="font-bold text-xs uppercase tracking-widest text-base-content/60 mb-2">Заметки</div>
<div class="text-base-content/80">{{ p.rewrite_notes or p.qualification_reason or "—" }}</div>
</div>
</div>
</div>
<div class="flex flex-wrap gap-2 mt-2 pt-4 border-t border-base-200">
<form hx-post="/editor/{{ p.id }}/accept" hx-target="#post-container-{{ p.id }}" hx-swap="outerHTML">
<input type="hidden" name="csrf_token" value="{{ user.csrf_token }}">
<button class="btn btn-primary btn-sm" type="submit">Принять</button>
</form>
<button class="btn btn-outline btn-sm" onclick="editModal{{ p.id }}.showModal()">Редактировать</button>
<form hx-post="/editor/{{ p.id }}/reject" hx-target="#post-container-{{ p.id }}" hx-swap="outerHTML" hx-confirm="Отклонить пост #{{ p.id }}?">
<input type="hidden" name="csrf_token" value="{{ user.csrf_token }}">
<button class="btn btn-error btn-sm btn-outline" type="submit">Отклонить</button>
</form>
</div>
</div>
</div>
<!-- Modal for Editing -->
<dialog id="editModal{{ p.id }}" class="modal">
<div class="modal-box w-11/12 max-w-5xl rounded-xl">
<form method="dialog">
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"></button>
</form>
<h3 class="font-bold text-lg mb-1">Пост #{{ p.id }}</h3>
<div class="text-sm text-base-content/60 mb-6">
<a href="{{ p.original_url }}" target="_blank" class="link link-hover">{{ p.source_name }}</a> &bull; {{ p.posted_at_fmt or p.created_at_fmt }}
</div>
<!-- HTMX form to submit edits -->
<form hx-post="/editor/{{ p.id }}/save" hx-target="#post-container-{{ p.id }}" hx-swap="outerHTML" hx-encoding="multipart/form-data" class="flex flex-col md:flex-row gap-6">
<input type="hidden" name="csrf_token" value="{{ user.csrf_token }}">
<div class="w-full md:w-1/3 flex flex-col gap-4">
<div class="bg-base-200 rounded p-4 text-sm whitespace-pre-wrap max-h-64 overflow-y-auto border border-base-300">
<span class="font-bold block mb-1">Оригинал:</span>
{{ p.raw_text }}
</div>
<div class="bg-base-200 rounded p-4 text-sm whitespace-pre-wrap max-h-64 overflow-y-auto border border-base-300">
<span class="font-bold block mb-1">AI-рерайт:</span>
{{ p.rewritten_text }}
</div>
<div class="grid grid-cols-2 gap-2 text-sm bg-base-200 p-4 rounded border border-base-300">
<div class="text-base-content/60 font-bold">Оценка:</div><div>{{ p.qualification_score or "—" }}/10</div>
<div class="text-base-content/60 font-bold">AI-Категория:</div><div>{{ p.rewrite_category or "—" }}</div>
<div class="text-base-content/60 font-bold">Модель:</div><div class="truncate" title="{{ p.rewrite_model }}">{{ p.rewrite_model or "—" }}</div>
</div>
<div class="form-control">
<label class="label"><span class="label-text">Добавить медиа</span></label>
<input type="file" name="media_files" class="file-input file-input-bordered file-input-sm w-full" accept="image/*,video/*,.pdf,.doc,.docx" multiple>
</div>
</div>
<div class="w-full md:w-2/3 flex flex-col gap-4">
<div class="form-control">
<label class="label"><span class="label-text font-bold">Текст публикации</span></label>
<textarea name="final_text" class="textarea textarea-bordered h-64 font-mono text-sm leading-relaxed" placeholder="Текст...">{{ p.review_text }}</textarea>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control">
<label class="label"><span class="label-text font-bold">Категория</span></label>
<select name="final_category" class="select select-bordered w-full">
{% for category in categories %}
<option value="{{ category.name }}" data-tag="{{ category.tag }}" {% if p.review_category == category.name %}selected{% endif %}>{{ category.name }}</option>
{% endfor %}
{% set category_names = categories | map(attribute="name") | list %}
{% if p.review_category and p.review_category not in category_names %}
<option value="{{ p.review_category }}" selected>{{ p.review_category }} · старая</option>
{% endif %}
</select>
</div>
<div class="form-control">
<label class="label"><span class="label-text font-bold">Тэг источника</span></label>
<input type="text" name="final_source_tag" value="{{ p.review_source_tag or p.source_tag or '' }}" class="input input-bordered w-full">
</div>
</div>
<div class="form-control">
<label class="label"><span class="label-text font-bold">Заметка редактора (для себя)</span></label>
<input type="text" name="editor_notes" value="{{ p.editor_notes or '' }}" class="input input-bordered w-full input-sm">
</div>
<div class="flex justify-end gap-2 mt-4 pt-4 border-t border-base-200">
<form method="dialog" class="inline">
<button class="btn btn-ghost" type="button" onclick="editModal{{ p.id }}.close()">Отмена</button>
</form>
<button type="submit" name="action" value="save" class="btn btn-outline btn-primary" onclick="editModal{{ p.id }}.close()">Сохранить правки</button>
<button type="submit" name="action" value="accept" class="btn btn-primary" onclick="editModal{{ p.id }}.close()">Принять к публикации</button>
</div>
</div>
</form>
</div>
<form method="dialog" class="modal-backdrop">
<button>Закрыть</button>
</form>
</dialog>
</div>
+26 -8
View File
@@ -1,12 +1,30 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block body %} {% block body %}
<div class="panel" style="max-width:420px;margin:10vh auto 0;"> <div class="flex items-center justify-center min-h-[70vh]">
<h1>Вход</h1> <div class="card w-96 bg-base-100 shadow-xl border border-base-200">
{% if error %}<p class="pill bad">{{ error }}</p>{% endif %} <div class="card-body">
<form method="post" action="/login"> <h2 class="card-title text-2xl font-bold justify-center mb-4">Вход в систему</h2>
<label><span>Логин</span><input name="login" autocomplete="username" required></label>
<label><span>Пароль</span><input name="password" type="password" autocomplete="current-password" required></label> {% if error %}
<button class="btn primary" type="submit">Войти</button> <div class="alert alert-error text-sm py-2 mb-4">
</form> <span>{{ error }}</span>
</div>
{% endif %}
<form method="post" action="/login" class="flex flex-col gap-4">
<div class="form-control">
<label class="label"><span class="label-text font-bold">Логин</span></label>
<input name="login" autocomplete="username" class="input input-bordered" required>
</div>
<div class="form-control">
<label class="label"><span class="label-text font-bold">Пароль</span></label>
<input name="password" type="password" autocomplete="current-password" class="input input-bordered" required>
</div>
<div class="form-control mt-6">
<button class="btn btn-primary w-full" type="submit">Войти</button>
</div>
</form>
</div>
</div>
</div> </div>
{% endblock %} {% endblock %}
+130 -93
View File
@@ -1,105 +1,142 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block body %} {% block body %}
<div class="page-head"> <div class="mb-8">
<div> <h1 class="text-3xl font-bold">Логи</h1>
<h1>Логи</h1> <div class="text-base-content/60 mt-1">Журнал действий и запросов админки. События старше 30 дней удаляются автоматически.</div>
<div class="muted">Журнал действий и запросов админки. События старше 30 дней удаляются автоматически.</div>
</div>
</div> </div>
<section class="panel"> <div class="card bg-base-100 shadow-sm border border-base-200 mb-8">
<form class="filter-grid" method="get" action="/logs"> <div class="card-body p-4 bg-base-200/50 rounded-t-xl border-b border-base-200">
<label><span>Поиск</span><input name="q" value="{{ q }}" placeholder="action, entity, JSON"></label> <form method="get" action="/logs" class="flex flex-wrap items-end gap-4">
<label><span>Действие</span> <div class="form-control flex-1 min-w-[200px]">
<select name="action"> <label class="label"><span class="label-text font-bold">Поиск</span></label>
<option value="">Все</option> <input name="q" value="{{ q }}" placeholder="action, entity, JSON" class="input input-bordered input-sm w-full">
{% for item in actions %} </div>
<option value="{{ item.action }}" {% if action == item.action %}selected{% endif %}>{{ item.action }} · {{ item.count }}</option>
<div class="form-control">
<label class="label"><span class="label-text font-bold">Действие</span></label>
<select name="action" class="select select-bordered select-sm">
<option value="">Все</option>
{% for item in actions %}
<option value="{{ item.action }}" {% if action == item.action %}selected{% endif %}>{{ item.action }} · {{ item.count }}</option>
{% endfor %}
</select>
</div>
<div class="form-control">
<label class="label"><span class="label-text font-bold">Пользователь</span></label>
<select name="actor_id" class="select select-bordered select-sm">
<option value="0">Все</option>
{% for item in actors %}
<option value="{{ item.id }}" {% if actor_id == item.id %}selected{% endif %}>{{ item.login }} · {{ item.count }}</option>
{% endfor %}
</select>
</div>
<div class="form-control">
<label class="label"><span class="label-text font-bold">Сущность</span></label>
<select name="entity_type" class="select select-bordered select-sm">
<option value="">Все</option>
{% for item in entity_types %}
<option value="{{ item.entity_type }}" {% if entity_type == item.entity_type %}selected{% endif %}>{{ item.entity_type }} · {{ item.count }}</option>
{% endfor %}
</select>
</div>
<div class="form-control">
<label class="label"><span class="label-text font-bold">С даты</span></label>
<input type="date" name="date_from" value="{{ date_from }}" class="input input-bordered input-sm">
</div>
<div class="form-control">
<label class="label"><span class="label-text font-bold">По дату</span></label>
<input type="date" name="date_to" value="{{ date_to }}" class="input input-bordered input-sm">
</div>
<div class="flex gap-2 w-full md:w-auto">
<button class="btn btn-primary btn-sm flex-1 md:flex-none" type="submit">Показать</button>
<a class="btn btn-outline btn-sm flex-1 md:flex-none" href="/logs">Сбросить</a>
</div>
</form>
</div>
<div class="p-4 border-b border-base-200 flex justify-between items-center bg-base-100">
<div class="text-sm text-base-content/70">
Всего записей: <span class="font-bold">{{ pagination.total }}</span>
</div>
<div class="flex items-center gap-2">
<span class="text-sm">На странице:</span>
<form method="get" action="/logs" class="m-0">
{% for item in preserved_query %}
{% if item.key != "per_page" %}
<input type="hidden" name="{{ item.key }}" value="{{ item.value }}">
{% endif %}
{% endfor %} {% endfor %}
</select> <select name="per_page" class="select select-bordered select-sm" onchange="this.form.submit()">
</label>
<label><span>Пользователь</span>
<select name="actor_id">
<option value="0">Все</option>
{% for item in actors %}
<option value="{{ item.id }}" {% if actor_id == item.id %}selected{% endif %}>{{ item.login }} · {{ item.count }}</option>
{% endfor %}
</select>
</label>
<label><span>Сущность</span>
<select name="entity_type">
<option value="">Все</option>
{% for item in entity_types %}
<option value="{{ item.entity_type }}" {% if entity_type == item.entity_type %}selected{% endif %}>{{ item.entity_type }} · {{ item.count }}</option>
{% endfor %}
</select>
</label>
<label><span>С даты</span><input type="date" name="date_from" value="{{ date_from }}"></label>
<label><span>По дату</span><input type="date" name="date_to" value="{{ date_to }}"></label>
<button class="btn primary filter-submit" type="submit">Показать</button>
<a class="btn filter-submit" href="/logs">Сбросить</a>
</form>
</section>
<section class="panel">
<div class="list-footer">
<div class="muted">Всего: {{ pagination.total }}</div>
<form class="row per-page-form" method="get" action="/logs">
{% for item in preserved_query %}
{% if item.key != "per_page" %}
<input type="hidden" name="{{ item.key }}" value="{{ item.value }}">
{% endif %}
{% endfor %}
<label><span>На странице</span>
<select name="per_page" data-autosubmit>
{% for n in [25,50,100,200] %} {% for n in [25,50,100,200] %}
<option value="{{ n }}" {% if pagination.per_page == n %}selected{% endif %}>{{ n }}</option> <option value="{{ n }}" {% if pagination.per_page == n %}selected{% endif %}>{{ n }}</option>
{% endfor %} {% endfor %}
</select> </select>
</label> </form>
</form> </div>
</div> </div>
<table class="compact-table"> <div class="card-body p-0 overflow-x-auto">
<thead> <table class="table table-zebra w-full text-sm">
<tr> <thead class="bg-base-200 text-base-content/70">
<th>Время</th> <tr>
<th>Пользователь</th> <th class="w-40">Время</th>
<th>Действие</th> <th class="w-32">Пользователь</th>
<th>Сущность</th> <th class="w-48">Действие</th>
<th>Детали</th> <th class="w-48">Сущность</th>
</tr> <th>Детали</th>
</thead> </tr>
<tbody> </thead>
{% for item in logs %} <tbody>
<tr> {% for item in logs %}
<td class="mono">{{ item.created_at_fmt }}</td> <tr class="hover">
<td>{{ item.actor_label }}</td> <td class="font-mono text-xs text-base-content/70">{{ item.created_at_fmt }}</td>
<td><span class="pill">{{ item.action }}</span></td> <td class="font-bold">{{ item.actor_label }}</td>
<td>{{ item.entity_type }}{% if item.entity_id %} #{{ item.entity_id }}{% endif %}</td> <td><span class="badge badge-outline badge-sm font-mono">{{ item.action }}</span></td>
<td> <td class="font-mono text-xs">
{% if item.details_pretty %} {{ item.entity_type }}
<details class="nested-details"> {% if item.entity_id %}
<summary>JSON</summary> <span class="text-base-content/50">#{{ item.entity_id }}</span>
<pre class="json-box">{{ item.details_pretty }}</pre> {% endif %}
</details> </td>
{% else %} <td>
<span class="muted"></span> {% if item.details_pretty %}
{% endif %} <div class="collapse collapse-arrow bg-base-200/50 rounded-lg border border-base-200">
</td> <input type="checkbox" />
</tr> <div class="collapse-title min-h-0 py-2 text-xs font-bold">JSON payload</div>
{% else %} <div class="collapse-content border-t border-base-200 pt-2 pb-2">
<tr><td colspan="5" class="empty-state">Записей нет.</td></tr> <pre class="bg-base-300 p-2 rounded text-xs font-mono overflow-x-auto">{{ item.details_pretty }}</pre>
{% endfor %} </div>
</tbody> </div>
</table> {% else %}
<span class="text-base-content/30"></span>
{% include "pagination.html" %} {% endif %}
</section> </td>
</tr>
<script> {% else %}
document.querySelectorAll("[data-autosubmit]").forEach((input) => { <tr>
input.addEventListener("change", () => input.form?.submit()); <td colspan="5" class="text-center p-8 text-base-content/50 italic">Записей нет.</td>
}); </tr>
</script> {% endfor %}
</tbody>
</table>
</div>
{% if pagination.total_pages > 1 %}
<div class="card-body p-4 border-t border-base-200 flex justify-center">
<div class="join">
<a class="join-item btn btn-sm {% if pagination.page == 1 %}btn-disabled{% endif %}" href="?page=1">«</a>
<a class="join-item btn btn-sm {% if pagination.page == 1 %}btn-disabled{% endif %}" href="?page={{ pagination.page - 1 }}"></a>
<button class="join-item btn btn-sm btn-disabled">Стр. {{ pagination.page }} из {{ pagination.total_pages }}</button>
<a class="join-item btn btn-sm {% if pagination.page >= pagination.total_pages %}btn-disabled{% endif %}" href="?page={{ pagination.page + 1 }}"></a>
<a class="join-item btn btn-sm {% if pagination.page >= pagination.total_pages %}btn-disabled{% endif %}" href="?page={{ pagination.total_pages }}">»</a>
</div>
</div>
{% endif %}
</div>
{% endblock %} {% endblock %}
+120 -84
View File
@@ -1,118 +1,154 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block body %} {% block body %}
<div class="page-head"> <div class="mb-8 flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
<div> <div>
<h1>Тест промпта райтера</h1> <h1 class="text-3xl font-bold">Тест промпта райтера</h1>
<p class="muted">Песочница для свободной части <span class="mono">ai_writer_prompt</span>. Посты, рерайт и статусы в БД не меняются.</p> <div class="text-base-content/60 mt-1">Песочница для свободной части <span class="font-mono bg-base-200 px-1 rounded text-xs">ai_writer_prompt</span>. Посты и статусы в БД не меняются.</div>
</div> </div>
<a class="btn" href="/workers">Настройки AI</a> <a class="btn btn-outline" href="/workers">Настройки AI</a>
</div> </div>
<details class="panel prompt-test-help" open> <div class="collapse collapse-arrow bg-base-100 shadow-sm border border-base-200 mb-8" open>
<summary><strong>Как пользоваться</strong></summary> <input type="checkbox" checked />
<div class="hint-grid"> <div class="collapse-title text-lg font-bold">Как пользоваться</div>
<div class="collapse-content border-t border-base-200 pt-4 grid grid-cols-1 md:grid-cols-2 gap-6 text-sm">
<div> <div>
<div class="hint-label">Что тестируем</div> <div class="font-bold uppercase tracking-widest text-xs text-base-content/60 mb-1">Что тестируем</div>
<p>Отправляется только текст из поля ниже как system prompt. <span class="mono">ai_writer_contract</span> специально не добавляется, чтобы можно было быстро проверять стиль, структуру и редакторские правила.</p> <p class="text-base-content/80">Отправляется только текст из поля ниже как system prompt. <span class="font-mono bg-base-200 px-1 rounded text-xs">ai_writer_contract</span> специально не добавляется, чтобы проверять стиль и структуру.</p>
</div> </div>
<div> <div>
<div class="hint-label">Что уйдёт в модель</div> <div class="font-bold uppercase tracking-widest text-xs text-base-content/60 mb-1">Что уйдёт в модель</div>
<p>Один выбранный raw-пост в том же формате, что использует райтер: <span class="mono">categories</span>, <span class="mono">producer_name</span>, <span class="mono">producer_tag</span>, ссылка, AI-оценка, медиа и обрезанный исходный текст.</p> <p class="text-base-content/80">Один выбранный raw-пост в формате: categories, producer, ссылка, оценка, медиа и обрезанный исходный текст.</p>
</div> </div>
<div> <div>
<div class="hint-label">Что безопасно менять</div> <div class="font-bold uppercase tracking-widest text-xs text-base-content/60 mb-1">Что безопасно менять</div>
<p>Голос канала, структуру текста, длину, правила про эмоджи, запрет выдумок, примеры формулировок и логику упоминания производителя/магазина. JSON-контракт рабочего воркера живёт отдельно.</p> <p class="text-base-content/80">Голос канала, структуру текста, длину, правила про эмоджи, запрет выдумок, примеры формулировок.</p>
</div> </div>
<div> <div>
<div class="hint-label">Что не происходит</div> <div class="font-bold uppercase tracking-widest text-xs text-base-content/60 mb-1">Что не происходит</div>
<p>Рерайт не сохраняется, batch не создаётся, категория и статус поста не меняются. Тест только тратит токены текущей модели райтера.</p> <p class="text-base-content/80">Рерайт не сохраняется, batch не создаётся. Тест только тратит токены текущей модели райтера.</p>
</div> </div>
</div> </div>
</details> </div>
<form class="prompt-test-layout" method="post" action="/prompt-test"> <form method="post" action="/prompt-test" class="flex flex-col lg:flex-row gap-8">
<input type="hidden" name="csrf_token" value="{{ user.csrf_token }}"> <input type="hidden" name="csrf_token" value="{{ user.csrf_token }}">
<section class="panel prompt-test-main">
<div class="prompt-test-controls"> <section class="flex-1 flex flex-col gap-6">
<label> <div class="card bg-base-100 shadow-sm border border-base-200">
<span>Фильтр</span> <div class="card-body p-4 flex flex-col gap-4">
<select name="q_status" data-status-select>
{% for item in status_options %} <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<option value="{{ item.value }}" {% if q_status == item.value %}selected{% endif %}>{{ item.label }}</option> <div class="form-control">
{% endfor %} <label class="label"><span class="label-text font-bold">Фильтр постов</span></label>
</select> <select name="q_status" data-status-select class="select select-bordered select-sm w-full">
</label> {% for item in status_options %}
<label> <option value="{{ item.value }}" {% if q_status == item.value %}selected{% endif %}>{{ item.label }}</option>
<span>Пост для теста</span> {% endfor %}
<select name="post_id" data-post-select> </select>
{% for post in posts %} </div>
<option value="{{ post.id }}" {% if selected and post.id == selected.id %}selected{% endif %}>{{ post.label }}</option> <div class="form-control">
{% endfor %} <label class="label"><span class="label-text font-bold">Пост для теста</span></label>
</select> <select name="post_id" data-post-select class="select select-bordered select-sm w-full">
</label> {% for post in posts %}
<div> <option value="{{ post.id }}" {% if selected and post.id == selected.id %}selected{% endif %}>{{ post.label }}</option>
<span class="muted">Модель</span> {% endfor %}
<div class="prompt-test-model"><span class="pill">{{ provider }}</span><span class="mono">{{ model or "не выбрана" }}</span></div> </select>
</div>
</div>
<div class="flex items-center gap-2 mt-2">
<span class="text-sm text-base-content/60">Модель:</span>
<span class="badge badge-primary">{{ provider }}</span>
<span class="badge badge-neutral font-mono">{{ model or "не выбрана" }}</span>
</div>
</div> </div>
</div> </div>
{% if selected %} {% if selected %}
<div class="prompt-test-post"> <div class="card bg-base-200 border border-base-300">
<div class="prompt-test-post-head"> <div class="card-body p-4 text-sm flex flex-col gap-2">
<strong>#{{ selected.id }} · {{ selected.source_name }}</strong> <div class="flex justify-between items-start">
<a href="{{ selected.original_url }}" target="_blank" rel="noopener">Оригинал</a> <strong class="text-base">#{{ selected.id }} · {{ selected.source_name }}</strong>
<a href="{{ selected.original_url }}" target="_blank" rel="noopener" class="btn btn-xs btn-outline">Оригинал</a>
</div>
<div class="text-base-content/60">
{{ selected.posted_at_fmt or selected.created_at_fmt }} · {{ selected.media_count }} медиа · оценка {{ selected.qualification_score or "—" }}
</div>
<pre class="whitespace-pre-wrap font-mono text-xs max-h-48 overflow-y-auto mt-2">{{ selected.raw_text }}</pre>
</div> </div>
<div class="muted">{{ selected.posted_at_fmt or selected.created_at_fmt }} · {{ selected.media_count }} медиа · {{ selected.media_types or [] }} · оценка {{ selected.qualification_score or "—" }}</div>
<pre>{{ selected.raw_text }}</pre>
</div> </div>
{% endif %} {% endif %}
<label> <div class="card bg-base-100 shadow-sm border border-base-200">
<span>ai_writer_prompt для теста</span> <div class="card-body p-4">
<textarea name="prompt" rows="18" spellcheck="false">{{ prompt }}</textarea> <div class="form-control">
</label> <label class="label"><span class="label-text font-bold">ai_writer_prompt для теста</span></label>
<div class="dialog-actions"> <textarea name="prompt" rows="18" spellcheck="false" class="textarea textarea-bordered font-mono text-sm leading-relaxed w-full">{{ prompt }}</textarea>
<button class="btn primary" type="submit">Отправить тест</button> </div>
<div class="mt-4 flex justify-end">
<button class="btn btn-primary" type="submit">Отправить тест</button>
</div>
</div>
</div> </div>
</section> </section>
<aside class="prompt-test-side"> <aside class="w-full lg:w-1/3 flex flex-col gap-6">
<section class="panel"> <div class="card bg-base-100 shadow-sm border border-base-200">
<h2>Payload</h2> <div class="card-body p-4">
<p class="muted">Именно это отправится user-сообщением. Текст обрезан по настройке <span class="mono">ai_writer_max_text_chars={{ max_text_chars }}</span>.</p> <h2 class="card-title text-base">Payload (Входные данные)</h2>
<pre class="json-box">{{ payload_json }}</pre> <p class="text-xs text-base-content/60 mb-2">Именно это отправится user-сообщением. Текст обрезан по настройке ai_writer_max_text_chars={{ max_text_chars }}.</p>
</section> <pre class="bg-base-200 p-2 rounded text-xs font-mono overflow-auto max-h-64">{{ payload_json }}</pre>
</div>
</div>
<section class="panel"> <div class="card bg-base-100 shadow-sm border border-base-200">
<h2>Ответ</h2> <div class="card-body p-4 flex flex-col gap-4">
{% if error %} <h2 class="card-title text-base">Ответ модели</h2>
<div class="pill bad">{{ error }}</div>
{% elif result %} {% if error %}
<dl class="kv compact-kv"> <div class="alert alert-error text-sm">{{ error }}</div>
<dt>Модель</dt><dd class="mono">{{ result.model }}</dd>
<dt>Токены</dt><dd>{{ result.usage.total_tokens or "—" }} total, {{ result.usage.prompt_tokens or "—" }} prompt, {{ result.usage.completion_tokens or "—" }} completion</dd> {% elif result %}
<dt>Стоимость</dt><dd>{{ result.usage.estimated_cost_usd or "—" }}</dd> <div class="grid grid-cols-2 gap-2 text-xs bg-base-200 p-3 rounded">
</dl> <div class="font-bold text-base-content/60">Модель</div><div class="font-mono text-right truncate" title="{{ result.model }}">{{ result.model }}</div>
{% if result.preview_text %} <div class="font-bold text-base-content/60">Токены</div><div class="text-right">{{ result.usage.total_tokens or "—" }}</div>
<h3>Как будет выглядеть</h3> <div class="font-bold text-base-content/60">Стоимость</div><div class="text-right text-success">{{ result.usage.estimated_cost_usd or "—" }}</div>
<pre class="human-answer">{{ result.preview_text }}</pre> </div>
<dl class="kv compact-kv">
<dt>Категория</dt><dd>{{ result.preview_category or "—" }}</dd> {% if result.preview_text %}
<dt>Заметка</dt><dd>{{ result.preview_notes or "—" }}</dd> <div>
</dl> <h3 class="font-bold text-sm mb-2">Как будет выглядеть</h3>
{% endif %} <div class="prose prose-sm max-w-none text-base-content whitespace-pre-wrap leading-snug bg-base-200 p-3 rounded">{{ result.preview_text }}</div>
{% if result.parsed_json %} <div class="grid grid-cols-2 gap-2 text-xs mt-2">
<h3>JSON разобран</h3> <div class="font-bold text-base-content/60">Категория</div><div class="text-right">{{ result.preview_category or "—" }}</div>
<pre class="json-box">{{ result.parsed_json }}</pre> <div class="font-bold text-base-content/60">Заметка</div><div class="text-right">{{ result.preview_notes or "—" }}</div>
{% endif %} </div>
<h3>Сырой ответ</h3> </div>
<pre class="json-box">{{ result.content }}</pre> {% endif %}
{% else %}
<p class="muted">После отправки здесь появится ответ модели.</p> {% if result.parsed_json %}
{% endif %} <div>
</section> <h3 class="font-bold text-sm mb-2">JSON разобран</h3>
<pre class="bg-base-200 p-2 rounded text-xs font-mono overflow-auto max-h-48">{{ result.parsed_json }}</pre>
</div>
{% endif %}
<div>
<h3 class="font-bold text-sm mb-2">Сырой ответ</h3>
<pre class="bg-base-200 p-2 rounded text-xs font-mono overflow-auto max-h-48">{{ result.content }}</pre>
</div>
{% else %}
<div class="text-sm text-base-content/50 italic text-center py-8 border border-dashed border-base-300 rounded">
После отправки здесь появится ответ модели.
</div>
{% endif %}
</div>
</div>
</aside> </aside>
</form> </form>
<script> <script>
const statusSelect = document.querySelector("[data-status-select]"); const statusSelect = document.querySelector("[data-status-select]");
document.querySelector("[data-post-select]")?.addEventListener("change", (event) => { document.querySelector("[data-post-select]")?.addEventListener("change", (event) => {
+7 -242
View File
@@ -1,249 +1,14 @@
{% extends "base.html" %} {% extends "v2/base.html" %}
{% block body %} {% block body %}
<div class="toolbar"> <div class="mb-6 flex justify-between items-end">
<div> <div>
<h1>Raw-посты</h1> <h1 class="text-3xl font-bold mb-1">Raw-посты</h1>
<div class="muted">Исходный пост, этап обработки и AI-оценка.</div> <div class="text-base-content/60">Исходный пост, этап обработки и AI-оценка.</div>
</div> </div>
</div> </div>
<div class="catalog-layout">
<section class="panel catalog-main">
<form id="raw-bulk-form" method="post" action="/raw/send-to-rewrite">
<input type="hidden" name="csrf_token" value="{{ user.csrf_token }}">
<input type="hidden" name="next_url" value="{{ request.url.path }}{% if request.url.query %}?{{ request.url.query }}{% endif %}">
<div class="list-toolbar">
<div>
<div class="muted">Всего: {{ pagination.total }}</div>
<div class="muted">Выбранные storage-ready посты можно вручную отправить в рерайт.</div>
</div>
<button class="btn primary" type="submit" data-bulk-submit disabled>Отправить в рерайт</button>
</div>
<table class="raw-table">
<tr>
<th>
<label class="raw-select-head">
<input type="checkbox" data-raw-select-all aria-label="Выбрать все посты на странице">
<a class="sort-head {% if sort_headers.id.active %}active {{ sort_headers.id.direction }}{% endif %}" href="{{ sort_headers.id.url }}">#</a>
</label>
</th>
<th><a class="sort-head {% if sort_headers.source.active %}active {{ sort_headers.source.direction }}{% endif %}" href="{{ sort_headers.source.url }}">Источник</a></th>
<th><a class="sort-head {% if sort_headers.stage.active %}active {{ sort_headers.stage.direction }}{% endif %}" href="{{ sort_headers.stage.url }}">Этап</a></th>
<th><a class="sort-head {% if sort_headers.post.active %}active {{ sort_headers.post.direction }}{% endif %}" href="{{ sort_headers.post.url }}">Пост</a></th>
<th><a class="sort-head {% if sort_headers.ai.active %}active {{ sort_headers.ai.direction }}{% endif %}" href="{{ sort_headers.ai.url }}">AI</a></th>
</tr>
{% for p in posts %}
<tr>
<td>
<label class="raw-select-cell">
<input type="checkbox" name="raw_post_id" value="{{ p.id }}" data-raw-select aria-label="Выбрать пост {{ p.id }}">
<a class="raw-id" href="/raw/{{ p.id }}">{{ p.id }}</a>
</label>
</td>
<td class="source-cell">
<a href="{{ p.original_url }}" target="_blank">{{ p.source_name }}</a>
<div class="muted">{{ p.source_platform }} · #{{ p.source_tag or "—" }}</div>
{% if p.posted_at_fmt %}<div class="muted">{{ p.posted_at_fmt }}</div>{% endif %}
</td>
<td class="stage-cell">
<span class="pill {{ p.stage.class }}" title="{{ p.stage.key }}">{{ p.stage.label }}</span>
{% if p.error_reason %}<div class="muted">{{ p.error_reason }}</div>{% endif %}
</td>
<td class="raw-content-cell">
<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>
{% if p.media_count %}<div class="muted media-count">{{ p.media_count }} медиа</div>{% endif %}
{% if p.raw_text|length > 520 %}
<details class="raw-text-details">
<summary>{{ p.raw_text[:520] }}...</summary>
<div class="raw-full-text">{{ p.raw_text }}</div>
</details>
{% else %}
<div class="raw-full-text">{{ p.raw_text }}</div>
{% endif %}
<div class="post-meta">Загружен парсером: {{ p.created_at_fmt }}</div>
</td>
<td class="ai-cell">
<a class="ai-badge {{ p.ai_badge.class }}" href="/raw/{{ p.id }}#ai" title="{{ p.ai_badge.reason or p.ai_badge.sublabel }}">
<span>{{ p.ai_badge.label }}</span>
<small>итог: {{ p.ai_badge.sublabel }}</small>
</a>
{% if p.qualification_model_decision %}<div class="muted ai-model-decision">модель: {{ p.qualification_model_decision }}</div>{% endif %}
{% if p.rewrite_category %}<div class="muted ai-model-decision">{{ p.rewrite_category }}</div>{% endif %}
</td>
</tr>
{% endfor %}
</table>
</form>
<div class="list-footer">
<form class="row per-page-form" method="get" action="/raw">
{% for item in preserved_query %}
{% if item.key != "per_page" %}
<input type="hidden" name="{{ item.key }}" value="{{ item.value }}">
{% endif %}
{% endfor %}
<label><span>На странице</span>
<select name="per_page" data-autosubmit>
{% for n in [25,50,100,200] %}
<option value="{{ n }}" {% if pagination.per_page == n %}selected{% endif %}>{{ n }}</option>
{% endfor %}
</select>
</label>
</form>
{% include "pagination.html" %}
</div>
</section>
<aside class="filter-sidebar panel"> <div id="content-area">
<form method="get" action="/raw" data-filter-form> {% include "raw_posts_content.html" %}
<input type="hidden" name="sort" value="{{ sort }}">
<div class="filter-head">
<h2>Фильтры</h2>
<a href="/raw">Сбросить</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="raw-sources">
<div class="facet-list" data-facet-list="raw-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="raw-categories">
<div class="facet-list" data-facet-list="raw-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">Raw-статус</div>
<div class="facet-list compact">
{% for item in facets.statuses %}
<label class="facet-option"><input type="checkbox" name="raw_status" value="{{ item.value }}" {% if item.value in raw_statuses %}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 item in facets.qualification_statuses %}
<label class="facet-option"><input type="checkbox" name="qualification_status" value="{{ item.value }}" {% if item.value in qualification_statuses %}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 item in facets.rewrite_statuses %}
<label class="facet-option"><input type="checkbox" name="rewrite_status" value="{{ item.value }}" {% if item.value in rewrite_statuses %}checked{% endif %}> <span>{{ item.value }}</span><small>{{ item.count }}</small></label>
{% endfor %}
</div>
</div>
<button class="btn primary filter-apply" type="submit">Показать</button>
</form>
</aside>
</div> </div>
<div 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>
</div>
<script>
(() => {
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");
}
function close() {
modal.classList.remove("open");
modal.setAttribute("aria-hidden", "true");
image.src = "";
}
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.addEventListener("keydown", (event) => {
if (!modal.classList.contains("open")) return;
if (event.key === "Escape") close();
if (event.key === "ArrowLeft") show(index - 1);
if (event.key === "ArrowRight") show(index + 1);
});
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);
});
});
});
const rawChecks = Array.from(document.querySelectorAll("[data-raw-select]"));
const rawSelectAll = document.querySelector("[data-raw-select-all]");
const bulkSubmit = document.querySelector("[data-bulk-submit]");
function syncBulkState() {
const selected = rawChecks.filter((item) => item.checked).length;
if (bulkSubmit) {
bulkSubmit.disabled = selected === 0;
bulkSubmit.textContent = selected ? `Отправить в рерайт (${selected})` : "Отправить в рерайт";
}
if (rawSelectAll) {
rawSelectAll.checked = selected > 0 && selected === rawChecks.length;
rawSelectAll.indeterminate = selected > 0 && selected < rawChecks.length;
}
}
rawChecks.forEach((item) => item.addEventListener("change", syncBulkState));
rawSelectAll?.addEventListener("change", () => {
rawChecks.forEach((item) => { item.checked = rawSelectAll.checked; });
syncBulkState();
});
syncBulkState();
})();
</script>
{% endblock %} {% endblock %}
@@ -3,12 +3,12 @@
<aside class="w-full lg:w-72 flex-shrink-0"> <aside class="w-full lg:w-72 flex-shrink-0">
<div class="card bg-base-100 shadow-sm border border-base-200"> <div class="card bg-base-100 shadow-sm border border-base-200">
<div class="card-body p-4"> <div class="card-body p-4">
<form hx-get="/v2/raw" hx-target="#content-area" hx-push-url="true" hx-trigger="submit, change delay:300ms"> <form hx-get="/raw" hx-target="#content-area" hx-push-url="true" hx-trigger="submit, change delay:300ms">
<input type="hidden" name="sort" value="{{ sort }}"> <input type="hidden" name="sort" value="{{ sort }}">
<div class="flex justify-between items-center mb-4"> <div class="flex justify-between items-center mb-4">
<h2 class="card-title text-lg">Фильтры</h2> <h2 class="card-title text-lg">Фильтры</h2>
<a href="/v2/raw" class="text-sm link link-hover text-base-content/60" hx-boost="true" hx-target="#content-area">Сбросить</a> <a href="/raw" class="text-sm link link-hover text-base-content/60" hx-boost="true" hx-target="#content-area">Сбросить</a>
</div> </div>
<div class="form-control w-full mb-4"> <div class="form-control w-full mb-4">
@@ -36,7 +36,7 @@
<span class="label-text flex-1">{{ item.value }}</span> <span class="label-text flex-1">{{ item.value }}</span>
<span class="badge badge-sm badge-ghost">{{ item.count }}</span> <span class="badge badge-sm badge-ghost">{{ item.count }}</span>
</label> </label>
{% endendfor %} {% endfor %}
</div> </div>
</div> </div>
@@ -72,7 +72,7 @@
<!-- Pagination top (optional, or per page select) --> <!-- Pagination top (optional, or per page select) -->
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span class="text-sm">На странице:</span> <span class="text-sm">На странице:</span>
<select name="per_page" class="select select-bordered select-sm" form="pagination-form" hx-get="/v2/raw" hx-target="#content-area" hx-include="[name='date_from'], [name='date_to']"> <select name="per_page" class="select select-bordered select-sm" form="pagination-form" hx-get="/raw" hx-target="#content-area" hx-include="[name='date_from'], [name='date_to']">
{% for n in [25,50,100,200] %} {% for n in [25,50,100,200] %}
<option value="{{ n }}" {% if pagination.per_page == n %}selected{% endif %}>{{ n }}</option> <option value="{{ n }}" {% if pagination.per_page == n %}selected{% endif %}>{{ n }}</option>
{% endfor %} {% endfor %}
+52 -20
View File
@@ -1,25 +1,57 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block body %} {% block body %}
<div class="toolbar"> <div class="mb-6 flex items-center justify-between">
<h1>{{ title }}</h1> <div>
<a class="btn" href="/sources">Назад</a> <h1 class="text-3xl font-bold">{{ title }}</h1>
</div>
<a class="btn btn-outline" href="/sources">Назад</a>
</div> </div>
<div class="panel">
<form method="post" action="{{ action }}"> <div class="card bg-base-100 shadow-sm border border-base-200 max-w-3xl">
<input type="hidden" name="csrf_token" value="{{ user.csrf_token }}"> <div class="card-body">
<div class="form-grid"> <form method="post" action="{{ action }}" class="flex flex-col gap-4">
<label><span>Площадка</span> <input type="hidden" name="csrf_token" value="{{ user.csrf_token }}">
<select name="platform">
<option value="vk" {% if not source or source.platform == "vk" %}selected{% endif %}>VK</option> <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
</select> <div class="form-control">
</label> <label class="label"><span class="label-text font-bold">Площадка</span></label>
<label><span>Приоритет</span><input name="priority" type="number" value="{{ source.priority if source else 100 }}"></label> <select name="platform" class="select select-bordered w-full">
<label><span>Название</span><input name="name" value="{{ source.name if source else '' }}" required></label> <option value="vk" {% if not source or source.platform == "vk" %}selected{% endif %}>VK</option>
<label><span>Тэг</span><input name="tag" value="{{ source.tag if source else '' }}" placeholder="academy_gear"></label> </select>
<label><span>Ссылка</span><input name="url" value="{{ source.url if source else '' }}" required></label> </div>
<label><span>Активен</span><input name="active" type="checkbox" {% if not source or source.active %}checked{% endif %}></label>
</div> <div class="form-control">
<button class="btn primary" type="submit">Сохранить</button> <label class="label"><span class="label-text font-bold">Приоритет</span></label>
</form> <input name="priority" type="number" value="{{ source.priority if source else 100 }}" class="input input-bordered w-full">
</div>
<div class="form-control md:col-span-2">
<label class="label"><span class="label-text font-bold">Название</span></label>
<input name="name" value="{{ source.name if source else '' }}" required class="input input-bordered w-full">
</div>
<div class="form-control">
<label class="label"><span class="label-text font-bold">Тэг</span></label>
<input name="tag" value="{{ source.tag if source else '' }}" placeholder="academy_gear" class="input input-bordered w-full">
</div>
<div class="form-control md:col-span-2">
<label class="label"><span class="label-text font-bold">Ссылка</span></label>
<input name="url" value="{{ source.url if source else '' }}" required class="input input-bordered w-full text-primary">
</div>
</div>
<div class="form-control mt-4">
<label class="label cursor-pointer justify-start gap-3">
<input name="active" type="checkbox" class="toggle toggle-primary" {% if not source or source.active %}checked{% endif %}>
<span class="label-text font-bold">Активен (парсер будет собирать посты)</span>
</label>
</div>
<div class="card-actions justify-end mt-4 pt-4 border-t border-base-200">
<button class="btn btn-primary" type="submit">Сохранить</button>
</div>
</form>
</div>
</div> </div>
{% endblock %} {% endblock %}
@@ -0,0 +1,45 @@
<tr class="hover" id="source-row-{{ s.id }}">
<td class="font-bold text-base-content/50">{{ s.id }}</td>
<td class="uppercase text-xs tracking-widest font-bold text-base-content/60">{{ s.platform }}</td>
<td>
<div class="flex items-center gap-2">
<span class="font-bold text-base">{{ s.name }}</span>
{% if s.active %}
<span class="badge badge-success badge-sm" title="Включен">ON</span>
{% else %}
<span class="badge badge-error badge-sm" title="Выключен">OFF</span>
{% endif %}
</div>
<div class="text-xs font-mono text-base-content/50 mt-1" title="External ID">{{ s.external_id or "" }}</div>
</td>
<td class="font-mono text-xs text-base-content/70">#{{ s.tag or "—" }}</td>
<td>
<a href="{{ s.url }}" target="_blank" class="link link-primary truncate inline-block max-w-[12rem]" title="{{ s.url }}">{{ s.url }}</a>
</td>
<td>
<div class="flex flex-col gap-1">
<span class="badge {% if s.status == 'ok' %}badge-success{% elif s.status == 'error' %}badge-error{% else %}badge-ghost{% endif %} badge-sm">{{ s.status }}</span>
{% if s.status_msg %}
<span class="text-xs text-base-content/60 truncate max-w-[10rem]" title="{{ s.status_msg }}">{{ s.status_msg }}</span>
{% endif %}
</div>
</td>
<td class="text-right">
<div class="font-bold">{{ s.posts_count }}</div>
<div class="text-xs text-success" title="За последние 24 часа">+{{ s.posts_24h }}</div>
</td>
<td class="text-right text-xs text-base-content/70">{{ s.last_parsed_at_fmt or "Никогда" }}</td>
<td>
<div class="flex justify-center gap-1">
<a href="/sources/{{ s.id }}/edit" class="btn btn-ghost btn-xs btn-square" title="Править"></a>
<form hx-post="/sources/{{ s.id }}/toggle" hx-target="#source-row-{{ s.id }}" hx-swap="outerHTML" class="m-0">
<input type="hidden" name="csrf_token" value="{{ user.csrf_token }}">
<button class="btn btn-ghost btn-xs btn-square" type="submit" title="{% if s.active %}Выключить{% else %}Включить{% endif %}"></button>
</form>
<form hx-post="/sources/{{ s.id }}/delete" hx-target="#source-row-{{ s.id }}" hx-swap="outerHTML swap:1s" hx-confirm="Удалить источник {{ s.name }}?" class="m-0">
<input type="hidden" name="csrf_token" value="{{ user.csrf_token }}">
<button class="btn btn-ghost btn-xs btn-square text-error hover:bg-error hover:text-error-content" type="submit" title="Удалить">×</button>
</form>
</div>
</td>
</tr>
+145 -113
View File
@@ -1,123 +1,155 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block body %} {% block body %}
<div class="toolbar"> <div class="mb-6">
<div> <h1 class="text-3xl font-bold">Источники</h1>
<h1>Источники</h1> <div class="text-base-content/60 mt-1">VK-источники для парсинга. Название идёт в prompt, тэг — в будущие хэштеги.</div>
<div class="muted">VK-источники для парсинга. Название идёт в prompt, тэг — в будущие хэштеги.</div> </div>
<div class="collapse collapse-arrow bg-base-100 shadow-sm border border-base-200 mb-6 {% if source_preview %}collapse-open{% endif %}">
<input type="checkbox" {% if source_preview %}checked{% endif %} />
<div class="collapse-title text-lg font-bold">
Добавить источники
</div>
<div class="collapse-content border-t border-base-200 pt-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
<!-- Single Add -->
<div>
<h3 class="font-bold mb-4">Добавить один</h3>
<form method="post" action="/sources/new" class="flex flex-col gap-3">
<input type="hidden" name="csrf_token" value="{{ user.csrf_token }}">
<input type="hidden" name="platform" value="vk">
<input type="hidden" name="priority" value="100">
<div class="form-control">
<label class="label"><span class="label-text">Название</span></label>
<input name="name" class="input input-bordered input-sm" placeholder="Можно оставить как в VK">
</div>
<div class="form-control">
<label class="label"><span class="label-text">Тэг</span></label>
<input name="tag" class="input input-bordered input-sm" placeholder="academy_gear">
</div>
<div class="form-control">
<label class="label"><span class="label-text">Ссылка</span></label>
<input name="url" class="input input-bordered input-sm" placeholder="https://vk.com/academy_gear" required>
</div>
<div class="form-control">
<label class="label cursor-pointer justify-start gap-2">
<input name="active" type="checkbox" class="checkbox checkbox-primary checkbox-sm" checked>
<span class="label-text">Включить сразу</span>
</label>
</div>
<button class="btn btn-primary btn-sm mt-2" type="submit">Добавить</button>
</form>
</div>
<!-- Bulk Add -->
<div>
<h3 class="font-bold mb-4">Массовое добавление</h3>
<form method="post" action="/sources/preview" class="flex flex-col gap-3">
<input type="hidden" name="csrf_token" value="{{ user.csrf_token }}">
<input type="hidden" name="q" value="{{ q }}">
<input type="hidden" name="status_filter" value="{{ status_filter }}">
<input type="hidden" name="per_page" value="{{ pagination.per_page }}">
<div class="form-control">
<label class="label"><span class="label-text">Список (Название Тэг Ссылка)</span></label>
<textarea name="bulk_text" class="textarea textarea-bordered font-mono text-xs" rows="5" placeholder="Academy Gear academy_gear https://vk.com/academy_gear&#10;A2 Technologies a2technologies https://vk.com/a2technologies">{{ bulk_text }}</textarea>
</div>
<div class="form-control">
<label class="label cursor-pointer justify-start gap-2">
<input name="bulk_active" type="checkbox" class="checkbox checkbox-primary checkbox-sm" {% if bulk_active %}checked{% endif %}>
<span class="label-text">Включить после добавления</span>
</label>
</div>
<button class="btn btn-outline btn-sm mt-2" type="submit">Проверить список</button>
</form>
</div>
</div>
{% if source_preview %}
<div class="mt-8 pt-4 border-t border-base-200">
<h3 class="font-bold mb-4">Предпросмотр массового добавления</h3>
<div class="overflow-x-auto bg-base-200 rounded-lg">
<table class="table table-sm">
<thead>
<tr>
<th># строки</th>
<th>Название</th>
<th>Тэг</th>
<th>Ссылка</th>
<th>VK ID</th>
<th>Статус</th>
</tr>
</thead>
<tbody>
{% for item in source_preview %}
<tr>
<td>{{ item.line_no }}</td>
<td>{{ item.name or "—" }}</td>
<td class="font-mono text-xs">#{{ item.tag or "—" }}</td>
<td><a href="{{ item.url }}" target="_blank" class="link">{{ item.url }}</a></td>
<td class="font-mono text-xs text-base-content/70">
{{ item.external_id }}<br>
{{ item.external_owner_id or "" }}
</td>
<td>
{% if item.ok %}
<span class="badge badge-success badge-sm">Готов</span>
{% else %}
<span class="badge badge-error badge-sm" title="{{ item.error }}">Ошибка</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<form method="post" action="/sources/bulk" class="mt-4 flex items-center gap-4">
<input type="hidden" name="csrf_token" value="{{ user.csrf_token }}">
<input type="hidden" name="bulk_payload" value='{{ source_preview|tojson }}'>
<button class="btn btn-primary btn-sm" type="submit">Добавить прошедшие проверку</button>
<span class="text-xs text-base-content/60">Строки с ошибками и дубли будут пропущены.</span>
</form>
</div>
{% endif %}
</div> </div>
</div> </div>
<details class="panel" {% if source_preview %}open{% endif %}> <div class="card bg-base-100 shadow-sm border border-base-200 mb-6">
<summary><strong>Добавить источники</strong></summary> <div class="card-body p-4 flex flex-col md:flex-row gap-4 justify-between items-end bg-base-200/50 rounded-t-xl">
<div class="source-add-grid"> <form class="flex-1 flex flex-wrap gap-4 items-end w-full" hx-get="/sources" hx-target="#content-area" hx-push-url="true">
<form method="post" action="/sources/new"> <div class="form-control w-full max-w-xs">
<input type="hidden" name="csrf_token" value="{{ user.csrf_token }}"> <label class="label"><span class="label-text font-bold">Поиск</span></label>
<input type="hidden" name="platform" value="vk"> <input name="q" value="{{ q }}" placeholder="Название, ссылка, id" class="input input-bordered input-sm w-full">
<input type="hidden" name="priority" value="100"> </div>
<label><span>Название</span><input name="name" placeholder="Можно оставить как в VK"></label>
<label><span>Тэг</span><input name="tag" placeholder="academy_gear"></label> <div class="form-control">
<label><span>Ссылка</span><input name="url" placeholder="https://vk.com/academy_gear" required></label> <label class="label"><span class="label-text font-bold">Статус</span></label>
<label class="checkline"><input name="active" type="checkbox" checked> <span>Включить сразу</span></label> <select name="status_filter" class="select select-bordered select-sm">
<button class="btn primary" type="submit">Добавить</button> <option value="">Все</option>
</form> {% for st in ["new","ok","paused","error"] %}
<option value="{{ st }}" {% if status_filter == st %}selected{% endif %}>{{ st }}</option>
<form method="post" action="/sources/preview"> {% endfor %}
<input type="hidden" name="csrf_token" value="{{ user.csrf_token }}"> </select>
<input type="hidden" name="q" value="{{ q }}"> </div>
<input type="hidden" name="status_filter" value="{{ status_filter }}">
<input type="hidden" name="per_page" value="{{ pagination.per_page }}"> <button class="btn btn-primary btn-sm" type="submit">Найти</button>
<label><span>Список источников</span> <div class="w-full">
<textarea name="bulk_text" rows="7" placeholder="Academy Gear academy_gear https://vk.com/academy_gear&#10;A2 Technologies a2technologies https://vk.com/a2technologies">{{ bulk_text }}</textarea> {% include "table_tools.html" %}
</label> </div>
<label class="checkline"><input name="bulk_active" type="checkbox" {% if bulk_active %}checked{% endif %}> <span>Включить после добавления</span></label>
<button class="btn" type="submit">Проверить список</button>
</form> </form>
</div> </div>
{% if source_preview %} <div class="card-body p-0" id="content-area">
<div class="preview-block"> {% include "sources_content.html" %}
<h3>Предпросмотр</h3>
<table>
<tr><th>#</th><th>Название</th><th>Тэг</th><th>Ссылка</th><th>VK id</th><th>Проверка</th></tr>
{% for item in source_preview %}
<tr>
<td>{{ item.line_no }}</td>
<td>{{ item.name or "—" }}</td>
<td class="mono">#{{ item.tag or "—" }}</td>
<td><a href="{{ item.url }}" target="_blank">{{ item.url }}</a></td>
<td class="mono">{{ item.external_id }}<br>{{ item.external_owner_id or "" }}</td>
<td>{% if item.ok %}<span class="pill ok">готов</span>{% else %}<span class="pill bad">{{ item.error }}</span>{% endif %}</td>
</tr>
{% endfor %}
</table>
<form method="post" action="/sources/bulk" class="row">
<input type="hidden" name="csrf_token" value="{{ user.csrf_token }}">
<input type="hidden" name="bulk_payload" value='{{ source_preview|tojson }}'>
<button class="btn primary" type="submit">Добавить прошедшие проверку</button>
<span class="muted">Строки с ошибками и дубли будут пропущены.</span>
</form>
</div> </div>
{% endif %}
</details>
<div class="panel">
<form class="filter-grid sources-filter" method="get" action="/sources">
<label><span>Поиск</span><input name="q" value="{{ q }}" placeholder="название, ссылка, id"></label>
<label><span>Статус</span>
<select name="status_filter">
<option value="">Все</option>
{% for st in ["new","ok","paused","error"] %}
<option value="{{ st }}" {% if status_filter == st %}selected{% endif %}>{{ st }}</option>
{% endfor %}
</select>
</label>
<label><span>На странице</span>
<select name="per_page">
{% for n in [25,50,100,200] %}
<option value="{{ n }}" {% if pagination.per_page == n %}selected{% endif %}>{{ n }}</option>
{% endfor %}
</select>
</label>
{% include "table_tools.html" %}
<button class="btn filter-submit" type="submit">Показать</button>
</form>
</div>
<div class="panel">
<table class="compact-table">
<tr>
<th>#</th><th>Площадка</th><th>Название</th><th>Тэг</th><th>Ссылка</th><th>Статус</th><th>Посты</th><th>Последний парсинг</th><th></th>
</tr>
{% for s in sources %}
<tr>
<td>{{ s.id }}</td>
<td>{{ s.platform }}</td>
<td>
<b>{{ s.name }}</b>
{% if s.active %}<span class="pill ok source-state">on</span>{% else %}<span class="pill bad source-state">off</span>{% endif %}
<div class="muted mono">{{ s.external_id or "" }}</div>
</td>
<td class="mono">#{{ s.tag or "—" }}</td>
<td class="source-url"><a href="{{ s.url }}" target="_blank">{{ s.url }}</a></td>
<td><span class="pill {% if s.status == 'ok' %}ok{% elif s.status == 'error' %}bad{% endif %}">{{ s.status }}</span>{% if s.status_msg %}<div class="muted status-msg">{{ s.status_msg }}</div>{% endif %}</td>
<td>{{ s.posts_count }} <span class="muted">+{{ s.posts_24h }}/24ч</span></td>
<td>{{ s.last_parsed_at_fmt or "нет" }}</td>
<td>
<div class="icon-actions">
<a class="icon-btn" href="/sources/{{ s.id }}/edit" title="Править" aria-label="Править"></a>
<form method="post" action="/sources/{{ s.id }}/toggle">
<input type="hidden" name="csrf_token" value="{{ user.csrf_token }}">
<button class="icon-btn" type="submit" title="{% if s.active %}Выключить{% else %}Включить{% endif %}" aria-label="Вкл/выкл"></button>
</form>
<form method="post" action="/sources/{{ s.id }}/delete" onsubmit="return confirm('Удалить источник {{ s.name }}?');">
<input type="hidden" name="csrf_token" value="{{ user.csrf_token }}">
<button class="icon-btn danger" type="submit" title="Удалить" aria-label="Удалить">×</button>
</form>
</div>
</td>
</tr>
{% endfor %}
</table>
{% include "pagination.html" %}
</div> </div>
{% endblock %} {% endblock %}
@@ -0,0 +1,57 @@
<div class="p-4 border-b border-base-200 flex justify-between items-center bg-base-100/50">
<div class="text-sm text-base-content/70">
Всего: <span class="font-bold">{{ pagination.total }}</span>
</div>
<div class="flex items-center gap-2">
<span class="text-sm">На странице:</span>
<form id="pagination-form" hx-get="/sources" hx-target="#content-area" hx-push-url="true" class="m-0">
<input type="hidden" name="q" value="{{ q }}">
<input type="hidden" name="status_filter" value="{{ status_filter }}">
<select name="per_page" class="select select-bordered select-sm" onchange="this.form.requestSubmit()">
{% for n in [25,50,100,200] %}
<option value="{{ n }}" {% if pagination.per_page == n %}selected{% endif %}>{{ n }}</option>
{% endfor %}
</select>
</form>
</div>
</div>
<div class="overflow-x-auto">
<table class="table table-zebra w-full text-sm">
<thead>
<tr class="bg-base-200 text-base-content">
<th class="w-16">#</th>
<th class="w-24">Площадка</th>
<th>Название</th>
<th class="w-32">Тэг</th>
<th class="w-48">Ссылка</th>
<th class="w-32">Статус</th>
<th class="w-24 text-right">Посты</th>
<th class="w-40 text-right">Последний парсинг</th>
<th class="w-24 text-center"></th>
</tr>
</thead>
<tbody>
{% for s in sources %}
{% include "source_row.html" %}
{% else %}
<tr>
<td colspan="9" class="text-center p-8 text-base-content/50">Нет источников, удовлетворяющих фильтрам.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Pagination bottom -->
{% if pagination.total_pages > 1 %}
<div class="flex justify-center p-4 border-t border-base-200">
<div class="join">
<button class="join-item btn btn-sm" form="pagination-form" name="page" value="1" {% if pagination.page == 1 %}disabled{% endif %}>«</button>
<button class="join-item btn btn-sm" form="pagination-form" name="page" value="{{ pagination.page - 1 }}" {% if pagination.page == 1 %}disabled{% endif %}></button>
<button class="join-item btn btn-sm btn-disabled">Стр. {{ pagination.page }} из {{ pagination.total_pages }}</button>
<button class="join-item btn btn-sm" form="pagination-form" name="page" value="{{ pagination.page + 1 }}" {% if pagination.page >= pagination.total_pages %}disabled{% endif %}></button>
<button class="join-item btn btn-sm" form="pagination-form" name="page" value="{{ pagination.total_pages }}" {% if pagination.page >= pagination.total_pages %}disabled{% endif %}>»</button>
</div>
</div>
{% endif %}
+44 -38
View File
@@ -1,43 +1,49 @@
<details class="advanced-tools" {% if table_filters|selectattr("field")|list or table_sorts|selectattr("field")|list %}open{% endif %}> <div class="collapse collapse-arrow bg-base-200 border border-base-300 rounded-lg mt-4" {% if table_filters|selectattr("field")|list or table_sorts|selectattr("field")|list %}open{% endif %}>
<summary>Расширенные фильтры и сортировка</summary> <input type="checkbox" {% if table_filters|selectattr("field")|list or table_sorts|selectattr("field")|list %}checked{% endif %} />
<div class="advanced-grid"> <div class="collapse-title text-sm font-medium">Расширенные фильтры и сортировка</div>
<div> <div class="collapse-content border-t border-base-300 pt-4 flex flex-col lg:flex-row gap-6">
<div class="tool-title">Фильтры</div> <div class="flex-1">
{% for item in table_filters %} <div class="font-bold text-xs uppercase tracking-widest text-base-content/60 mb-2">Фильтры</div>
<div class="tool-row"> <div class="flex flex-col gap-2">
<select name="f_field"> {% for item in table_filters %}
<option value="">Поле</option> <div class="flex flex-col sm:flex-row gap-2">
{% for field in field_options %} <select name="f_field" class="select select-bordered select-sm w-full sm:w-1/3">
<option value="{{ field.value }}" {% if item.field == field.value %}selected{% endif %}>{{ field.label }}</option> <option value="">Поле</option>
{% endfor %} {% for field in field_options %}
</select> <option value="{{ field.value }}" {% if item.field == field.value %}selected{% endif %}>{{ field.label }}</option>
<select name="f_op"> {% endfor %}
{% for op in filter_op_options %} </select>
<option value="{{ op.value }}" {% if item.op == op.value %}selected{% endif %}>{{ op.label }}</option> <select name="f_op" class="select select-bordered select-sm w-full sm:w-1/4">
{% endfor %} {% for op in filter_op_options %}
</select> <option value="{{ op.value }}" {% if item.op == op.value %}selected{% endif %}>{{ op.label }}</option>
<input name="f_value" value="{{ item.value }}" placeholder="значение"> {% endfor %}
</select>
<input name="f_value" value="{{ item.value }}" placeholder="Значение" class="input input-bordered input-sm w-full sm:w-auto flex-1">
</div>
{% endfor %}
</div> </div>
{% endfor %} <div class="text-xs text-base-content/50 mt-2">Пустые строки игнорируются. Для дат используй YYYY-MM-DD, для булевых значений — true/false.</div>
<div class="muted small-help">Пустые строки игнорируются. Для дат используй формат YYYY-MM-DD, для true/false — true или false.</div>
</div> </div>
<div>
<div class="tool-title">Сортировка</div> <div class="flex-1">
{% for item in table_sorts %} <div class="font-bold text-xs uppercase tracking-widest text-base-content/60 mb-2">Сортировка</div>
<div class="tool-row sort-row"> <div class="flex flex-col gap-2">
<select name="sort_field"> {% for item in table_sorts %}
<option value="">Поле</option> <div class="flex flex-col sm:flex-row gap-2">
{% for field in field_options %} <select name="sort_field" class="select select-bordered select-sm w-full sm:w-1/2">
<option value="{{ field.value }}" {% if item.field == field.value %}selected{% endif %}>{{ field.label }}</option> <option value="">Поле</option>
{% endfor %} {% for field in field_options %}
</select> <option value="{{ field.value }}" {% if item.field == field.value %}selected{% endif %}>{{ field.label }}</option>
<select name="sort_dir"> {% endfor %}
<option value="desc" {% if item.dir == "desc" %}selected{% endif %}>по убыванию</option> </select>
<option value="asc" {% if item.dir == "asc" %}selected{% endif %}>по возрастанию</option> <select name="sort_dir" class="select select-bordered select-sm w-full sm:w-1/2">
</select> <option value="desc" {% if item.dir == "desc" %}selected{% endif %}>По убыванию</option>
<option value="asc" {% if item.dir == "asc" %}selected{% endif %}>По возрастанию</option>
</select>
</div>
{% endfor %}
</div> </div>
{% endfor %} <div class="text-xs text-base-content/50 mt-2">Сортировки применяются сверху вниз. Заменяют базовую сортировку страницы.</div>
<div class="muted small-help">Сортировки применяются сверху вниз. Если сортировка задана здесь, обычная сортировка страницы становится запасной.</div>
</div> </div>
</div> </div>
</details> </div>
+88 -28
View File
@@ -1,35 +1,95 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block body %} {% block body %}
<h1>Пользователи</h1> <div class="mb-8">
<div class="panel"> <h1 class="text-3xl font-bold">Пользователи</h1>
<h2>Пользователи</h2> <div class="text-base-content/60 mt-1">Управление доступом к админ-панели.</div>
<table>
<tr><th>ID</th><th>Логин</th><th>Роль</th><th>Создан</th><th>Активен</th><th></th></tr>
{% for u in users %}
<tr>
<td>{{ u.id }}</td><td>{{ u.login }}</td><td>{{ u.role }}</td>
<td>{{ u.created_at_fmt }}</td>
<td>{% if u.is_active %}<span class="pill ok">yes</span>{% else %}<span class="pill bad">no</span>{% endif %}</td>
<td>{% if user.role == "admin" and user.id != u.id %}<form method="post" action="/users/{{ u.id }}/toggle"><input type="hidden" name="csrf_token" value="{{ user.csrf_token }}"><button class="btn" type="submit">Вкл/выкл</button></form>{% endif %}</td>
</tr>
{% endfor %}
</table>
{% include "pagination.html" %}
</div> </div>
{% if user.role == "admin" %}
<div class="panel"> <div class="card bg-base-100 shadow-sm border border-base-200 mb-8">
<h2>Добавить</h2> <div class="card-body p-0 overflow-x-auto">
<form method="post" action="/users/create"> <table class="table table-zebra w-full text-sm">
<input type="hidden" name="csrf_token" value="{{ user.csrf_token }}"> <thead class="bg-base-200 text-base-content/70">
<div class="form-grid"> <tr>
<label><span>Логин</span><input name="login" required></label> <th class="w-16">ID</th>
<label><span>Пароль</span><input name="password" type="password" required minlength="10"></label> <th>Логин</th>
<label><span>Роль</span> <th>Роль</th>
<select name="role"><option value="editor">editor</option><option value="viewer">viewer</option><option value="admin">admin</option></select> <th>Создан</th>
</label> <th>Статус</th>
<th class="w-32 text-right">Действия</th>
</tr>
</thead>
<tbody>
{% for u in users %}
<tr class="hover">
<td class="font-bold text-base-content/50">{{ u.id }}</td>
<td class="font-bold">{{ u.login }}</td>
<td>
<span class="badge badge-outline badge-sm">{{ u.role }}</span>
</td>
<td class="text-xs text-base-content/70">{{ u.created_at_fmt }}</td>
<td>
{% if u.is_active %}
<span class="badge badge-success badge-sm gap-1"><span class="w-1.5 h-1.5 rounded-full bg-success-content"></span> Активен</span>
{% else %}
<span class="badge badge-error badge-sm gap-1"><span class="w-1.5 h-1.5 rounded-full bg-error-content"></span> Заблокирован</span>
{% endif %}
</td>
<td class="text-right">
{% if user.role == "admin" and user.id != u.id %}
<form method="post" action="/users/{{ u.id }}/toggle" class="m-0 inline-block">
<input type="hidden" name="csrf_token" value="{{ user.csrf_token }}">
<button class="btn btn-xs btn-outline" type="submit">{% if u.is_active %}Блокировать{% else %}Разблокировать{% endif %}</button>
</form>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if pagination.total_pages > 1 %}
<div class="card-body p-4 border-t border-base-200 flex justify-center">
<div class="join">
<a class="join-item btn btn-sm {% if pagination.page == 1 %}btn-disabled{% endif %}" href="?page=1">«</a>
<a class="join-item btn btn-sm {% if pagination.page == 1 %}btn-disabled{% endif %}" href="?page={{ pagination.page - 1 }}"></a>
<button class="join-item btn btn-sm btn-disabled">Стр. {{ pagination.page }} из {{ pagination.total_pages }}</button>
<a class="join-item btn btn-sm {% if pagination.page >= pagination.total_pages %}btn-disabled{% endif %}" href="?page={{ pagination.page + 1 }}"></a>
<a class="join-item btn btn-sm {% if pagination.page >= pagination.total_pages %}btn-disabled{% endif %}" href="?page={{ pagination.total_pages }}">»</a>
</div> </div>
<button class="btn primary" type="submit">Создать</button> </div>
</form> {% endif %}
</div>
{% if user.role == "admin" %}
<div class="collapse collapse-arrow bg-base-100 shadow-sm border border-base-200">
<input type="checkbox" />
<div class="collapse-title text-lg font-bold">Добавить пользователя</div>
<div class="collapse-content border-t border-base-200 pt-4">
<form method="post" action="/users/create" class="flex flex-col md:flex-row gap-4 items-end">
<input type="hidden" name="csrf_token" value="{{ user.csrf_token }}">
<div class="form-control w-full">
<label class="label"><span class="label-text font-bold">Логин</span></label>
<input name="login" class="input input-bordered input-sm w-full" required>
</div>
<div class="form-control w-full">
<label class="label"><span class="label-text font-bold">Пароль</span></label>
<input name="password" type="password" class="input input-bordered input-sm w-full" required minlength="10" placeholder="Минимум 10 символов">
</div>
<div class="form-control w-full max-w-xs">
<label class="label"><span class="label-text font-bold">Роль</span></label>
<select name="role" class="select select-bordered select-sm w-full">
<option value="editor">Редактор (editor)</option>
<option value="viewer">Читатель (viewer)</option>
<option value="admin">Администратор (admin)</option>
</select>
</div>
<button class="btn btn-primary btn-sm" type="submit">Создать</button>
</form>
</div>
</div> </div>
{% endif %} {% endif %}
{% endblock %} {% endblock %}
-62
View File
@@ -1,62 +0,0 @@
<!doctype html>
<html lang="ru" data-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/png" href="/favi.png">
<title>{{ title or "VK Parser Admin V2" }}</title>
<!-- Tailwind CSS & DaisyUI -->
<link href="https://cdn.jsdelivr.net/npm/daisyui@4.10.1/dist/full.min.css" rel="stylesheet" type="text/css" />
<script src="https://cdn.tailwindcss.com"></script>
<!-- HTMX -->
<script src="https://unpkg.com/htmx.org@1.9.11"></script>
<!-- AlpineJS (optional but useful for local state) -->
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.13.8/dist/cdn.min.js"></script>
<style>
/* Custom tweaks to match Tailwind typography nicely */
body { font-family: 'Inter', system-ui, sans-serif; }
.htmx-indicator { display:none; }
.htmx-request .htmx-indicator { display:inline-block; }
.htmx-request.htmx-indicator { display:inline-block; }
</style>
</head>
<body class="bg-base-200 min-h-screen">
<div class="navbar bg-base-100 shadow-sm sticky top-0 z-50">
<div class="flex-1 px-2 mx-2">
<span class="text-lg font-bold">Parser V2</span>
<div class="hidden md:flex ml-6 gap-2">
<a href="/v2/raw" class="btn btn-ghost btn-sm {{ 'btn-active' if 'raw' in request.url.path }}">Raw-посты</a>
<!-- Temporary links to V1 for others -->
<a href="/editor" class="btn btn-ghost btn-sm">Редактор (V1)</a>
<a href="/workers" class="btn btn-ghost btn-sm">Воркеры (V1)</a>
</div>
</div>
<div class="flex-none hidden lg:block">
<ul class="menu menu-horizontal gap-2">
<li><a href="/" class="btn btn-outline btn-sm">Вернуться в V1</a></li>
<li>
<form method="post" action="/logout" class="m-0">
<input type="hidden" name="csrf_token" value="{{ user.csrf_token }}">
<button class="btn btn-ghost btn-sm text-error">Выйти</button>
</form>
</li>
</ul>
</div>
</div>
<main class="container mx-auto p-4 md:p-6 max-w-7xl">
{% block body %}{% endblock %}
</main>
<script>
// Handle HTMX errors
document.body.addEventListener('htmx:responseError', function(evt) {
alert("Ошибка при загрузке: " + evt.detail.xhr.status);
});
</script>
</body>
</html>
@@ -1,14 +0,0 @@
{% extends "v2/base.html" %}
{% block body %}
<div class="mb-6 flex justify-between items-end">
<div>
<h1 class="text-3xl font-bold mb-1">Raw-посты</h1>
<div class="text-base-content/60">Исходный пост, этап обработки и AI-оценка.</div>
</div>
</div>
<div id="content-area">
{% include "v2/raw_posts_content.html" %}
</div>
{% endblock %}
+318 -206
View File
@@ -1,217 +1,329 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block body %} {% block body %}
<h1>Воркеры</h1> <div class="mb-8">
<div class="panel"> <h1 class="text-3xl font-bold">Система и Воркеры</h1>
<table> <div class="text-base-content/60 mt-1">Управление фоновыми процессами, категориями, расписанием и настройками AI.</div>
<tr><th>Имя</th><th>Enabled</th><th>Heartbeat</th><th>Статус</th><th>Job</th><th></th></tr>
{% for w in workers %}
<tr>
<td class="mono">{{ w.name }}</td>
<td>{% if w.enabled %}<span class="pill ok">enabled</span>{% else %}<span class="pill bad">disabled</span>{% endif %}</td>
<td>{{ w.heartbeat_at or "нет" }}</td>
<td>{{ w.status or "" }}</td>
<td>{{ w.current_job_id or "" }}</td>
<td><form method="post" action="/workers/{{ w.name }}/toggle"><input type="hidden" name="csrf_token" value="{{ user.csrf_token }}"><button class="btn" type="submit">Вкл/выкл</button></form></td>
</tr>
{% endfor %}
</table>
</div> </div>
<details class="panel" open> <!-- Workers Panel -->
<summary><strong>Категории публикаций</strong></summary> <div class="card bg-base-100 shadow-sm border border-base-200 mb-8">
<p class="muted">Название описывает категорию для AI, тэг используется в соцсетях. Название и URL сайта хранятся отдельно и не меняют рабочие хэштеги.</p> <div class="card-body p-0 overflow-x-auto">
<form class="category-create" method="post" action="/categories/create"> <table class="table table-zebra w-full text-sm">
<input type="hidden" name="csrf_token" value="{{ user.csrf_token }}"> <thead class="bg-base-200">
<label><span>Название</span><input name="name" placeholder="например, разгрузка" required></label> <tr>
<label><span>Тэг</span><input name="tag" placeholder="если пусто, соберётся из названия"></label> <th>Воркер</th>
<label><span>На сайте</span><input name="site_name" placeholder="Снаряжение" required></label> <th>Состояние</th>
<label><span>URL сайта</span><input name="site_slug" placeholder="snaryazhenie" pattern="[a-z0-9-]+" required></label> <th>Heartbeat</th>
<label class="checkline"><input type="checkbox" name="site_enabled" checked> Публиковать</label> <th>Статус</th>
<button class="btn primary" type="submit">Добавить</button> <th>Текущая задача</th>
</form> <th>Действия</th>
<table class="compact-table category-table"> </tr>
<tr><th>Название AI</th><th>Тэг</th><th>Название сайта</th><th>URL сайта</th><th>ID</th><th>Статус</th><th></th></tr> </thead>
{% for c in categories %} <tbody>
<tr> {% for w in workers %}
<td> <tr>
<form id="category-update-{{ c.id }}" method="post" action="/categories/{{ c.id }}/update"> <td class="font-mono font-bold">{{ w.name }}</td>
<input type="hidden" name="csrf_token" value="{{ user.csrf_token }}"> <td>
<input name="name" value="{{ c.name }}"> {% if w.enabled %}
</form> <span class="badge badge-success badge-sm gap-1"><span class="w-1.5 h-1.5 rounded-full bg-success-content"></span> Включен</span>
</td> {% else %}
<td><input form="category-update-{{ c.id }}" name="tag" value="{{ c.tag }}"></td> <span class="badge badge-error badge-sm gap-1"><span class="w-1.5 h-1.5 rounded-full bg-error-content"></span> Выключен</span>
<td><input form="category-update-{{ c.id }}" name="site_name" value="{{ c.site_name }}" required></td>
<td><input form="category-update-{{ c.id }}" name="site_slug" value="{{ c.site_slug }}" pattern="[a-z0-9-]+" required></td>
<td><input value="{{ c.sort_order }}" type="number" readonly title="Стабильный ID категории для AI-райтера"></td>
<td>{% if c.is_active %}<span class="pill ok">активна</span>{% else %}<span class="pill">выключена</span>{% endif %}<label class="checkline"><input form="category-update-{{ c.id }}" type="checkbox" name="site_enabled" {% if c.site_enabled %}checked{% endif %}> сайт</label></td>
<td class="icon-actions">
<button class="icon-btn" form="category-update-{{ c.id }}" type="submit" title="Сохранить"></button>
<form method="post" action="/categories/{{ c.id }}/toggle">
<input type="hidden" name="csrf_token" value="{{ user.csrf_token }}">
<button class="icon-btn" type="submit" title="{% if c.is_active %}Выключить{% else %}Включить{% endif %}">{% if c.is_active %}⏸{% else %}▶{% endif %}</button>
</form>
<form method="post" action="/categories/{{ c.id }}/delete">
<input type="hidden" name="csrf_token" value="{{ user.csrf_token }}">
<button class="icon-btn danger" type="submit" title="Удалить из активных">×</button>
</form>
</td>
</tr>
{% endfor %}
</table>
</details>
<details class="panel" open>
<summary><strong>Расписание TG-постера</strong></summary>
<p class="muted">Время публикации задаётся по Екатеринбургу. Для запуска нужны оба флага: worker <span class="mono">tg-poster</span> и настройка <span class="mono">tg_poster_enabled</span>.</p>
<form class="category-create" method="post" action="/tg-poster-schedule/create">
<input type="hidden" name="csrf_token" value="{{ user.csrf_token }}">
<label><span>Время Екб</span><input type="time" name="time" value="10:00" required></label>
<label><span>Постов</span><input type="number" name="count" value="1" min="1" max="20" required></label>
<label class="checkline"><input type="checkbox" name="enabled" checked> Активно</label>
<button class="btn primary" type="submit">Добавить</button>
</form>
<table class="compact-table category-table">
<tr><th>Время Екб</th><th>Постов</th><th>Статус</th><th></th></tr>
{% for row in tg_schedule %}
<tr>
<td>
<form id="tg-schedule-update-{{ row.id }}" method="post" action="/tg-poster-schedule/{{ row.id }}/update">
<input type="hidden" name="csrf_token" value="{{ user.csrf_token }}">
<input type="time" name="time" value="{{ row.time }}" required>
</form>
</td>
<td><input form="tg-schedule-update-{{ row.id }}" type="number" name="count" value="{{ row.count }}" min="1" max="20" required></td>
<td><label class="checkline"><input form="tg-schedule-update-{{ row.id }}" type="checkbox" name="enabled" {% if row.enabled %}checked{% endif %}> Активно</label></td>
<td class="icon-actions">
<button class="icon-btn" form="tg-schedule-update-{{ row.id }}" type="submit" title="Сохранить"></button>
<form method="post" action="/tg-poster-schedule/{{ row.id }}/delete">
<input type="hidden" name="csrf_token" value="{{ user.csrf_token }}">
<button class="icon-btn danger" type="submit" title="Удалить">×</button>
</form>
</td>
</tr>
{% endfor %}
</table>
</details>
<details class="panel" open>
<summary><strong>Расписание VK-постера</strong></summary>
<p class="muted">Время публикации задаётся по Екатеринбургу. Для запуска нужны оба флага: worker <span class="mono">vk-poster</span> и настройка <span class="mono">vk_poster_enabled</span>.</p>
<p><a class="btn" href="/vk-oauth/start">Получить VK user token</a></p>
<form class="category-create" method="post" action="/vk-poster-schedule/create">
<input type="hidden" name="csrf_token" value="{{ user.csrf_token }}">
<label><span>Время Екб</span><input type="time" name="time" value="10:05" required></label>
<label><span>Постов</span><input type="number" name="count" value="1" min="1" max="20" required></label>
<label class="checkline"><input type="checkbox" name="enabled" checked> Активно</label>
<button class="btn primary" type="submit">Добавить</button>
</form>
<table class="compact-table category-table">
<tr><th>Время Екб</th><th>Постов</th><th>Статус</th><th></th></tr>
{% for row in vk_schedule %}
<tr>
<td>
<form id="vk-schedule-update-{{ row.id }}" method="post" action="/vk-poster-schedule/{{ row.id }}/update">
<input type="hidden" name="csrf_token" value="{{ user.csrf_token }}">
<input type="time" name="time" value="{{ row.time }}" required>
</form>
</td>
<td><input form="vk-schedule-update-{{ row.id }}" type="number" name="count" value="{{ row.count }}" min="1" max="20" required></td>
<td><label class="checkline"><input form="vk-schedule-update-{{ row.id }}" type="checkbox" name="enabled" {% if row.enabled %}checked{% endif %}> Активно</label></td>
<td class="icon-actions">
<button class="icon-btn" form="vk-schedule-update-{{ row.id }}" type="submit" title="Сохранить"></button>
<form method="post" action="/vk-poster-schedule/{{ row.id }}/delete">
<input type="hidden" name="csrf_token" value="{{ user.csrf_token }}">
<button class="icon-btn danger" type="submit" title="Удалить">×</button>
</form>
</td>
</tr>
{% endfor %}
</table>
</details>
<h2>Настройки</h2>
{% for category, rows in settings|groupby("category") %}
<details class="panel" {% if category == "AI Qualifier" %}open{% endif %}>
<summary><strong>{{ category_titles.get(category, category) }}</strong></summary>
<table>
<tr><th>Ключ</th><th>Значение</th><th>Описание</th></tr>
{% for s in rows %}
<tr>
<td><div class="mono">{{ s.key }}</div><div class="muted">{{ s.title }}</div></td>
<td>
<form class="row" method="post" action="/settings/save">
<input type="hidden" name="csrf_token" value="{{ user.csrf_token }}">
<input type="hidden" name="key" value="{{ s.key }}">
{% if s.value_type == "bool" %}
<select name="value" style="width:120px;">
<option value="true" {% if s.value_json == true %}selected{% endif %}>true</option>
<option value="false" {% if s.value_json == false %}selected{% endif %}>false</option>
</select>
{% elif s.key in ["ai_qualifier_provider", "ai_writer_provider"] %}
<select name="value" style="width:260px;">
{% for p in provider_options %}
<option value="{{ p.value }}" {% if s.value_json == p.value %}selected{% endif %}>{{ p.label }}</option>
{% endfor %}
</select>
{% elif s.key == "ai_qualifier_model" %}
<select name="value" style="width:420px; max-width:100%;">
{% for m in ai_model_options %}
<option value="{{ m.value }}" {% if s.value_json == m.value %}selected{% endif %}>{{ m.label }}</option>
{% endfor %}
{% if s.value_json and s.value_json not in ai_model_options|map(attribute="value")|list %}
<option value="{{ s.value_json }}">{{ s.value_json }} · текущее значение</option>
{% endif %} {% endif %}
</select> </td>
{% elif s.key == "ai_writer_model" %} <td class="text-xs text-base-content/70">{{ w.heartbeat_at or "—" }}</td>
<select name="value" style="width:420px; max-width:100%;"> <td class="text-xs max-w-xs truncate">{{ w.status or "—" }}</td>
{% for m in writer_model_options %} <td class="font-mono text-xs text-base-content/60">{{ w.current_job_id or "—" }}</td>
<option value="{{ m.value }}" {% if s.value_json == m.value %}selected{% endif %}>{{ m.label }}</option> <td>
<form method="post" action="/workers/{{ w.name }}/toggle" class="m-0">
<input type="hidden" name="csrf_token" value="{{ user.csrf_token }}">
<button class="btn btn-xs btn-outline" type="submit">Переключить</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<div class="grid grid-cols-1 xl:grid-cols-2 gap-8 mb-8">
<!-- Categories -->
<div class="collapse collapse-arrow bg-base-100 shadow-sm border border-base-200" open>
<input type="checkbox" checked />
<div class="collapse-title text-lg font-bold">Категории публикаций</div>
<div class="collapse-content border-t border-base-200 pt-4 flex flex-col gap-6">
<div class="text-sm text-base-content/70">
Название описывает категорию для AI, тэг используется в соцсетях.
</div>
<form method="post" action="/categories/create" class="flex flex-col gap-3 bg-base-200 p-4 rounded-xl">
<div class="font-bold text-sm">Добавить категорию</div>
<input type="hidden" name="csrf_token" value="{{ user.csrf_token }}">
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<input name="name" class="input input-bordered input-sm w-full" placeholder="Название (для AI)" required>
<input name="tag" class="input input-bordered input-sm w-full" placeholder="Тэг (для хэштегов)">
<input name="site_name" class="input input-bordered input-sm w-full" placeholder="На сайте" required>
<input name="site_slug" class="input input-bordered input-sm w-full" placeholder="URL-slug" pattern="[a-z0-9-]+" required>
</div>
<div class="flex items-center justify-between mt-2">
<label class="cursor-pointer label gap-2 p-0">
<input type="checkbox" name="site_enabled" class="checkbox checkbox-primary checkbox-sm" checked>
<span class="label-text">Публиковать</span>
</label>
<button class="btn btn-primary btn-sm" type="submit">Добавить</button>
</div>
</form>
<div class="overflow-x-auto">
<table class="table table-xs w-full">
<thead class="bg-base-200 text-base-content/70">
<tr>
<th>Название / Тэг</th>
<th>Сайт / Slug</th>
<th class="w-20 text-center">ID</th>
<th>Статус</th>
<th class="w-16"></th>
</tr>
</thead>
<tbody>
{% for c in categories %}
<tr class="hover">
<td>
<form id="category-update-{{ c.id }}" method="post" action="/categories/{{ c.id }}/update" class="m-0">
<input type="hidden" name="csrf_token" value="{{ user.csrf_token }}">
</form>
<div class="flex flex-col gap-1">
<input form="category-update-{{ c.id }}" name="name" value="{{ c.name }}" class="input input-bordered input-xs w-full">
<input form="category-update-{{ c.id }}" name="tag" value="{{ c.tag }}" class="input input-bordered input-xs w-full text-primary font-mono">
</div>
</td>
<td>
<div class="flex flex-col gap-1">
<input form="category-update-{{ c.id }}" name="site_name" value="{{ c.site_name }}" class="input input-bordered input-xs w-full" required>
<input form="category-update-{{ c.id }}" name="site_slug" value="{{ c.site_slug }}" pattern="[a-z0-9-]+" class="input input-bordered input-xs w-full text-base-content/60 font-mono" required>
</div>
</td>
<td class="text-center font-mono text-base-content/50">{{ c.sort_order }}</td>
<td>
<div class="flex flex-col gap-1">
<span class="badge {% if c.is_active %}badge-success{% else %}badge-ghost{% endif %} badge-sm w-full">{% if c.is_active %}Активна{% else %}Выкл{% endif %}</span>
<label class="cursor-pointer flex items-center gap-1">
<input form="category-update-{{ c.id }}" type="checkbox" name="site_enabled" class="checkbox checkbox-xs" {% if c.site_enabled %}checked{% endif %}>
<span class="text-xs">Сайт</span>
</label>
</div>
</td>
<td>
<div class="flex flex-col gap-1 items-center">
<button form="category-update-{{ c.id }}" type="submit" class="btn btn-ghost btn-xs w-full" title="Сохранить"></button>
<form method="post" action="/categories/{{ c.id }}/toggle" class="m-0 w-full">
<input type="hidden" name="csrf_token" value="{{ user.csrf_token }}">
<button type="submit" class="btn btn-ghost btn-xs w-full" title="Вкл/Выкл">{% if c.is_active %}⏸{% else %}▶{% endif %}</button>
</form>
<form method="post" action="/categories/{{ c.id }}/delete" class="m-0 w-full" onsubmit="return confirm('Удалить категорию?');">
<input type="hidden" name="csrf_token" value="{{ user.csrf_token }}">
<button type="submit" class="btn btn-ghost btn-xs text-error hover:bg-error hover:text-error-content w-full" title="Удалить"></button>
</form>
</div>
</td>
</tr>
{% endfor %} {% endfor %}
{% if s.value_json and s.value_json not in writer_model_options|map(attribute="value")|list %} </tbody>
<option value="{{ s.value_json }}">{{ s.value_json }} · текущее значение</option> </table>
{% endif %} </div>
</select> </div>
{% elif s.value_type == "secret" %} </div>
<div class="secret-editor">
<input class="secret-visible" name="value" value="{{ s.value_json }}" autocomplete="off" spellcheck="false" style="width:420px; max-width:100%;"> <div class="flex flex-col gap-8">
<div class="muted small-help">Ключ показан полностью для удобства. Не открывай эту страницу в демонстрации экрана.</div> <!-- TG Schedule -->
<div class="collapse collapse-arrow bg-base-100 shadow-sm border border-base-200" open>
<input type="checkbox" checked />
<div class="collapse-title text-lg font-bold">Расписание TG-постера</div>
<div class="collapse-content border-t border-base-200 pt-4 flex flex-col gap-4">
<div class="text-sm text-base-content/70">
Время по Екб. Нужны: воркер <span class="font-mono bg-base-200 px-1 rounded text-xs">tg-poster</span> и настройка <span class="font-mono bg-base-200 px-1 rounded text-xs">tg_poster_enabled</span>.
</div>
<form method="post" action="/tg-poster-schedule/create" class="flex items-center gap-3 bg-base-200 p-3 rounded-xl">
<input type="hidden" name="csrf_token" value="{{ user.csrf_token }}">
<input type="time" name="time" value="10:00" class="input input-bordered input-sm flex-1" required>
<input type="number" name="count" value="1" min="1" max="20" class="input input-bordered input-sm w-20" required>
<label class="cursor-pointer label gap-2 p-0">
<input type="checkbox" name="enabled" class="checkbox checkbox-sm checkbox-primary" checked>
</label>
<button class="btn btn-primary btn-sm" type="submit">Добавить</button>
</form>
<table class="table table-sm w-full">
<thead><tr><th>Время Екб</th><th>Постов</th><th>Статус</th><th></th></tr></thead>
<tbody>
{% for row in tg_schedule %}
<tr class="hover">
<td>
<form id="tg-schedule-update-{{ row.id }}" method="post" action="/tg-poster-schedule/{{ row.id }}/update" class="m-0">
<input type="hidden" name="csrf_token" value="{{ user.csrf_token }}">
</form>
<input form="tg-schedule-update-{{ row.id }}" type="time" name="time" value="{{ row.time }}" class="input input-bordered input-xs w-full" required>
</td>
<td><input form="tg-schedule-update-{{ row.id }}" type="number" name="count" value="{{ row.count }}" min="1" max="20" class="input input-bordered input-xs w-16" required></td>
<td><label class="cursor-pointer"><input form="tg-schedule-update-{{ row.id }}" type="checkbox" name="enabled" class="toggle toggle-xs toggle-primary" {% if row.enabled %}checked{% endif %}></label></td>
<td class="flex gap-1">
<button form="tg-schedule-update-{{ row.id }}" type="submit" class="btn btn-ghost btn-xs btn-square"></button>
<form method="post" action="/tg-poster-schedule/{{ row.id }}/delete" class="m-0">
<input type="hidden" name="csrf_token" value="{{ user.csrf_token }}">
<button type="submit" class="btn btn-ghost btn-xs btn-square text-error"></button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<!-- VK Schedule -->
<div class="collapse collapse-arrow bg-base-100 shadow-sm border border-base-200" open>
<input type="checkbox" checked />
<div class="collapse-title text-lg font-bold">Расписание VK-постера</div>
<div class="collapse-content border-t border-base-200 pt-4 flex flex-col gap-4">
<div class="text-sm text-base-content/70">
Время по Екб. Нужны: воркер <span class="font-mono bg-base-200 px-1 rounded text-xs">vk-poster</span> и <span class="font-mono bg-base-200 px-1 rounded text-xs">vk_poster_enabled</span>.
</div>
<div><a class="btn btn-outline btn-sm" href="/vk-oauth/start">Получить VK user token</a></div>
<form method="post" action="/vk-poster-schedule/create" class="flex items-center gap-3 bg-base-200 p-3 rounded-xl">
<input type="hidden" name="csrf_token" value="{{ user.csrf_token }}">
<input type="time" name="time" value="10:05" class="input input-bordered input-sm flex-1" required>
<input type="number" name="count" value="1" min="1" max="20" class="input input-bordered input-sm w-20" required>
<label class="cursor-pointer label gap-2 p-0">
<input type="checkbox" name="enabled" class="checkbox checkbox-sm checkbox-primary" checked>
</label>
<button class="btn btn-primary btn-sm" type="submit">Добавить</button>
</form>
<table class="table table-sm w-full">
<thead><tr><th>Время Екб</th><th>Постов</th><th>Статус</th><th></th></tr></thead>
<tbody>
{% for row in vk_schedule %}
<tr class="hover">
<td>
<form id="vk-schedule-update-{{ row.id }}" method="post" action="/vk-poster-schedule/{{ row.id }}/update" class="m-0">
<input type="hidden" name="csrf_token" value="{{ user.csrf_token }}">
</form>
<input form="vk-schedule-update-{{ row.id }}" type="time" name="time" value="{{ row.time }}" class="input input-bordered input-xs w-full" required>
</td>
<td><input form="vk-schedule-update-{{ row.id }}" type="number" name="count" value="{{ row.count }}" min="1" max="20" class="input input-bordered input-xs w-16" required></td>
<td><label class="cursor-pointer"><input form="vk-schedule-update-{{ row.id }}" type="checkbox" name="enabled" class="toggle toggle-xs toggle-primary" {% if row.enabled %}checked{% endif %}></label></td>
<td class="flex gap-1">
<button form="vk-schedule-update-{{ row.id }}" type="submit" class="btn btn-ghost btn-xs btn-square"></button>
<form method="post" action="/vk-poster-schedule/{{ row.id }}/delete" class="m-0">
<input type="hidden" name="csrf_token" value="{{ user.csrf_token }}">
<button type="submit" class="btn btn-ghost btn-xs btn-square text-error"></button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
<div class="divider mb-8 text-2xl font-bold">Настройки</div>
<div class="flex flex-col gap-6">
{% for category, rows in settings|groupby("category") %}
<div class="collapse collapse-arrow bg-base-100 shadow-sm border border-base-200" {% if category == "AI Qualifier" or loop.first %}open{% endif %}>
<input type="checkbox" {% if category == "AI Qualifier" or loop.first %}checked{% endif %} />
<div class="collapse-title text-xl font-bold">{{ category_titles.get(category, category) }}</div>
<div class="collapse-content border-t border-base-200 pt-4">
<div class="flex flex-col gap-6">
{% for s in rows %}
<div class="flex flex-col md:flex-row gap-6 p-4 bg-base-200/30 rounded-xl">
<!-- Description Sidebar -->
<div class="w-full md:w-1/3 flex flex-col gap-1">
<div class="font-mono font-bold text-sm text-base-content">{{ s.key }}</div>
<div class="text-xs text-base-content/60">{{ s.title }}</div>
<div class="text-xs text-base-content/80 mt-2">{{ s.description }}</div>
</div> </div>
{% elif s.value_type == "text" %}
<div class="prompt-editor"> <!-- Control Panel -->
{% if prompt_hints and s.key in prompt_hints %} <div class="flex-1">
<div class="prompt-hint"> <form method="post" action="/settings/save" class="flex flex-col items-start gap-3 w-full">
<strong>{{ prompt_hints[s.key].title }}</strong> <input type="hidden" name="csrf_token" value="{{ user.csrf_token }}">
<div class="muted">{{ prompt_hints[s.key].body }}</div> <input type="hidden" name="key" value="{{ s.key }}">
<div class="hint-grid">
<div> <div class="flex gap-2 w-full">
<div class="hint-label">Как безопасно менять</div> {% if s.value_type == "bool" %}
<div>{{ prompt_hints[s.key].safe }}</div> <select name="value" class="select select-bordered select-sm w-32">
</div> <option value="true" {% if s.value_json == true %}selected{% endif %}>true</option>
<div> <option value="false" {% if s.value_json == false %}selected{% endif %}>false</option>
<div class="hint-label">Что приходит на вход</div> </select>
<div>{{ prompt_hints[s.key].input }}</div>
{% elif s.key in ["ai_qualifier_provider", "ai_writer_provider"] %}
<select name="value" class="select select-bordered select-sm max-w-sm">
{% for p in provider_options %}
<option value="{{ p.value }}" {% if s.value_json == p.value %}selected{% endif %}>{{ p.label }}</option>
{% endfor %}
</select>
{% elif s.key == "ai_qualifier_model" %}
<select name="value" class="select select-bordered select-sm max-w-sm">
{% for m in ai_model_options %}
<option value="{{ m.value }}" {% if s.value_json == m.value %}selected{% endif %}>{{ m.label }}</option>
{% endfor %}
{% if s.value_json and s.value_json not in ai_model_options|map(attribute="value")|list %}
<option value="{{ s.value_json }}">{{ s.value_json }} · текущее значение</option>
{% endif %}
</select>
{% elif s.key == "ai_writer_model" %}
<select name="value" class="select select-bordered select-sm max-w-sm">
{% for m in writer_model_options %}
<option value="{{ m.value }}" {% if s.value_json == m.value %}selected{% endif %}>{{ m.label }}</option>
{% endfor %}
{% if s.value_json and s.value_json not in writer_model_options|map(attribute="value")|list %}
<option value="{{ s.value_json }}">{{ s.value_json }} · текущее значение</option>
{% endif %}
</select>
{% elif s.value_type == "secret" %}
<input type="password" name="value" value="{{ s.value_json }}" class="input input-bordered input-sm w-full max-w-md" autocomplete="off" spellcheck="false" placeholder="Секретный ключ">
{% elif s.value_type == "text" %}
<div class="w-full flex flex-col gap-2">
{% if prompt_hints and s.key in prompt_hints %}
<div class="bg-info/10 text-info-content text-xs p-4 rounded-lg flex flex-col gap-2">
<strong>{{ prompt_hints[s.key].title }}</strong>
<div>{{ prompt_hints[s.key].body }}</div>
<div class="grid grid-cols-2 gap-4 mt-2">
<div><span class="font-bold">Как безопасно менять:</span> {{ prompt_hints[s.key].safe }}</div>
<div><span class="font-bold">На вход:</span> {{ prompt_hints[s.key].input }}</div>
</div>
</div>
{% endif %}
<textarea name="value" class="textarea textarea-bordered font-mono text-xs w-full leading-relaxed" rows="12">{{ s.value_json }}</textarea>
</div> </div>
{% elif s.value_type == "json" %}
<textarea name="value" class="textarea textarea-bordered font-mono text-xs w-full max-w-2xl leading-relaxed" rows="5" spellcheck="false">{{ s.value_json | tojson }}</textarea>
{% else %}
<input name="value" value="{{ s.value_json }}" class="input input-bordered input-sm max-w-xs">
{% endif %}
<button class="btn btn-primary btn-sm h-full" type="submit">OK</button>
</div> </div>
<details class="nested-details"> </form>
<summary>Базовая форма/пример</summary>
<pre>{{ prompt_hints[s.key].contract }}</pre>
</details>
</div>
{% endif %}
<textarea name="value" rows="12" style="width:720px; max-width:100%;">{{ s.value_json }}</textarea>
</div> </div>
{% elif s.value_type == "json" %} </div>
<textarea name="value" rows="5" style="width:520px; max-width:100%;" spellcheck="false">{{ s.value_json | tojson }}</textarea> {% endfor %}
{% else %} </div>
<input name="value" value="{{ s.value_json }}" style="width:180px;">
{% endif %} </div>
<button class="btn" type="submit">OK</button> </div>
</form> {% endfor %}
</td> </div>
<td>{{ s.description }}</td>
</tr>
{% endfor %}
</table>
</details>
{% endfor %}
{% endblock %} {% endblock %}