Initial RAA parser poster copy

This commit is contained in:
Your Name
2026-08-03 16:18:56 +05:00
commit d01890ff2c
148 changed files with 18924 additions and 0 deletions
+340
View File
@@ -0,0 +1,340 @@
<!doctype html>
<html lang="ru" class="dark">
<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 site_title or "Редакторская" }}</title>
<!-- Fonts: Fira Sans (UI) & Fira Code (Data) -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;500;600;700&family=Fira+Sans:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<script>
(function() {
const theme = localStorage.getItem('admin-theme') || 'dark';
document.documentElement.classList.toggle('dark', theme !== 'light');
})();
</script>
<!-- Tailwind CSS via CDN -->
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
fontFamily: {
sans: ['"Fira Sans"', 'sans-serif'],
mono: ['"Fira Code"', 'monospace'],
},
colors: {
app: {
bg: 'rgb(var(--app-bg) / <alpha-value>)',
surface: 'rgb(var(--app-surface) / <alpha-value>)',
surfaceHover: 'rgb(var(--app-surface-hover) / <alpha-value>)',
border: 'rgb(var(--app-border) / <alpha-value>)',
borderFocus: 'rgb(var(--app-border-focus) / <alpha-value>)',
primary: 'rgb(var(--app-primary) / <alpha-value>)',
primaryHover: 'rgb(var(--app-primary-hover) / <alpha-value>)',
textMain: 'rgb(var(--app-text-main) / <alpha-value>)',
textMuted: 'rgb(var(--app-text-muted) / <alpha-value>)',
success: 'rgb(var(--app-success) / <alpha-value>)',
successBg: 'var(--app-success-bg)',
warning: 'rgb(var(--app-warning) / <alpha-value>)',
warningBg: 'var(--app-warning-bg)',
error: 'rgb(var(--app-error) / <alpha-value>)',
errorBg: 'var(--app-error-bg)',
}
},
boxShadow: {
'glow': '0 0 15px rgba(59, 130, 246, 0.3)',
}
}
}
}
</script>
<!-- HTMX & AlpineJS -->
<script src="https://unpkg.com/htmx.org@1.9.11"></script>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.13.8/dist/cdn.min.js"></script>
<!-- Lucide Icons -->
<script src="https://unpkg.com/lucide@latest"></script>
<style>
:root {
--app-bg: 248 250 252;
--app-surface: 255 255 255;
--app-surface-hover: 226 232 240;
--app-border: 203 213 225;
--app-border-focus: 148 163 184;
--app-primary: 37 99 235;
--app-primary-hover: 29 78 216;
--app-text-main: 15 23 42;
--app-text-muted: 100 116 139;
--app-success: 5 150 105;
--app-success-bg: rgba(5, 150, 105, 0.1);
--app-warning: 217 119 6;
--app-warning-bg: rgba(217, 119, 6, 0.1);
--app-error: 220 38 38;
--app-error-bg: rgba(220, 38, 38, 0.1);
}
.dark {
--app-bg: 15 23 42;
--app-surface: 30 41 59;
--app-surface-hover: 51 65 85;
--app-border: 51 65 85;
--app-border-focus: 71 85 105;
--app-primary: 59 130 246;
--app-primary-hover: 37 99 235;
--app-text-main: 248 250 252;
--app-text-muted: 148 163 184;
--app-success: 16 185 129;
--app-success-bg: rgba(16, 185, 129, 0.1);
--app-warning: 245 158 11;
--app-warning-bg: rgba(245, 158, 11, 0.1);
--app-error: 239 68 68;
--app-error-bg: rgba(239, 68, 68, 0.1);
}
:root:not(.dark) .text-white,
:root:not(.dark) .hover\:text-white:hover {
color: rgb(var(--app-text-main)) !important;
}
:root:not(.dark) .hover\:bg-app-primary:hover.hover\:text-white,
:root:not(.dark) .hover\:bg-app-error:hover.hover\:text-white {
color: #fff !important;
}
:root:not(.dark) .media-gallery-layer .text-white,
:root:not(.dark) .media-gallery-layer .hover\:text-app-primary:hover {
color: #fff !important;
}
</style>
<style type="text/tailwindcss">
@layer components {
/* Form inputs */
.input {
@apply bg-app-bg border border-app-border rounded-lg px-3 py-2 text-sm text-app-textMain focus:outline-none focus:border-app-primary focus:ring-1 focus:ring-app-primary transition-colors w-full placeholder:text-app-textMuted;
}
.select {
@apply input appearance-none bg-no-repeat bg-[right_0.5rem_center] bg-[length:1.5em_1.5em];
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%2394A3B8' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e");
}
.textarea {
@apply bg-app-bg border border-app-border rounded-lg px-3 py-2 text-sm text-app-textMain focus:outline-none focus:border-app-primary focus:ring-1 focus:ring-app-primary transition-colors w-full placeholder:text-app-textMuted;
}
.checkbox {
@apply w-4 h-4 rounded border-app-border bg-app-bg text-app-primary focus:ring-app-primary focus:ring-offset-app-surface;
}
/* Buttons */
.btn {
@apply inline-flex items-center justify-center gap-2 rounded-lg font-medium text-sm px-4 py-2 transition-all disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-app-bg;
}
.btn-primary {
@apply bg-app-primary text-white hover:bg-app-primaryHover focus:ring-app-primary border border-transparent;
}
.btn-surface {
@apply bg-app-surface border border-app-border text-app-textMain hover:bg-app-surfaceHover focus:ring-app-border;
}
.btn-ghost {
@apply text-app-textMuted hover:text-app-textMain hover:bg-app-surfaceHover border border-transparent;
}
.btn-danger {
@apply bg-app-error text-white hover:bg-red-600 focus:ring-app-error border border-transparent;
}
.btn-danger-outline {
@apply bg-transparent border border-app-error text-app-error hover:bg-app-error hover:text-white focus:ring-app-error;
}
.btn-primary-outline {
@apply bg-transparent border border-app-primary text-app-primary hover:bg-app-primary hover:text-white focus:ring-app-primary;
}
.btn-sm {
@apply px-3 py-1.5 text-xs;
}
.btn-xs {
@apply px-2 py-1 text-xs;
}
.btn-icon {
@apply p-2;
}
/* Cards */
.card {
@apply bg-app-surface border border-app-border rounded-xl shadow-sm overflow-hidden;
}
/* Badges */
.badge {
@apply inline-flex items-center px-2 py-0.5 rounded text-xs font-medium border;
}
.badge-success {
@apply bg-app-successBg text-app-success border-app-success/20;
}
.badge-error {
@apply bg-app-errorBg text-app-error border-app-error/20;
}
.badge-warning {
@apply bg-app-warningBg text-app-warning border-app-warning/20;
}
.badge-neutral {
@apply bg-app-bg text-app-textMuted border-app-border;
}
.badge-primary {
@apply bg-app-primary/10 text-app-primary border-app-primary/20;
}
.badge-info {
@apply bg-blue-500/10 text-blue-400 border-blue-500/20;
}
/* Tabs */
.tabs {
@apply flex space-x-1 bg-app-bg p-1 rounded-lg border border-app-border overflow-x-auto;
}
.tab {
@apply flex items-center px-3 py-1.5 text-sm font-medium rounded-md text-app-textMuted hover:text-app-textMain transition-colors whitespace-nowrap;
}
.tab-active {
@apply bg-app-surface text-app-textMain shadow-sm border border-app-border;
}
/* HTMX Indicators */
.htmx-indicator { display:none; }
.htmx-request .htmx-indicator { display:inline-block; }
.htmx-request.htmx-indicator { display:inline-block; }
[x-cloak] { display: none !important; }
/* Scrollbar */
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-track { @apply bg-transparent; }
::-webkit-scrollbar-thumb { @apply bg-app-border rounded-full hover:bg-app-borderFocus; }
}
</style>
</head>
<body class="bg-app-bg text-app-textMain min-h-screen font-sans flex antialiased">
{% if user %}
<!-- Sidebar -->
<aside x-data="{ expanded: false }" :class="expanded ? 'w-64' : 'w-20'" class="flex-shrink-0 bg-app-surface border-r border-app-border flex flex-col hidden md:flex sticky top-0 h-screen transition-all duration-200">
<div class="p-4">
<div class="flex items-center gap-3 text-app-textMain font-bold text-lg tracking-wide" :class="expanded ? 'justify-between' : 'justify-center'">
<div class="flex items-center gap-3 min-w-0">
<span x-show="expanded" x-cloak class="truncate">{{ app_title or "Редакторская" }}</span>
</div>
<button type="button" class="btn btn-ghost btn-icon flex-shrink-0" @click="expanded = !expanded" :aria-label="expanded ? 'Свернуть меню' : 'Развернуть меню'" :title="expanded ? 'Свернуть меню' : 'Развернуть меню'">
<i data-lucide="panel-left" class="w-5 h-5"></i>
</button>
</div>
</div>
<nav class="flex-1 px-4 space-y-1 overflow-y-auto">
<a href="/raw" title="Сырые посты" class="flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors {{ 'bg-app-primary/10 text-app-primary' if 'raw' in request.url.path else 'text-app-textMuted hover:bg-app-surfaceHover hover:text-app-textMain' }}" :class="expanded ? '' : 'justify-center'">
<i data-lucide="inbox" class="w-5 h-5 flex-shrink-0"></i>
<span x-show="expanded" x-cloak>Сырые посты</span>
</a>
<a href="/editor" title="Редактор" class="flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors {{ 'bg-app-primary/10 text-app-primary' if 'editor' in request.url.path else 'text-app-textMuted hover:bg-app-surfaceHover hover:text-app-textMain' }}" :class="expanded ? '' : 'justify-center'">
<i data-lucide="edit-3" class="w-5 h-5 flex-shrink-0"></i>
<span x-show="expanded" x-cloak>Редактор</span>
</a>
<a href="/sources" title="Источники" class="flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors {{ 'bg-app-primary/10 text-app-primary' if 'sources' in request.url.path else 'text-app-textMuted hover:bg-app-surfaceHover hover:text-app-textMain' }}" :class="expanded ? '' : 'justify-center'">
<i data-lucide="database" class="w-5 h-5 flex-shrink-0"></i>
<span x-show="expanded" x-cloak>Источники</span>
</a>
{% if user.role != "editor" %}
<a href="/workers" title="Воркеры" class="flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors {{ 'bg-app-primary/10 text-app-primary' if 'workers' in request.url.path else 'text-app-textMuted hover:bg-app-surfaceHover hover:text-app-textMain' }}" :class="expanded ? '' : 'justify-center'">
<i data-lucide="cpu" class="w-5 h-5 flex-shrink-0"></i>
<span x-show="expanded" x-cloak>Воркеры</span>
</a>
<a href="/users" title="Пользователи" class="flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors {{ 'bg-app-primary/10 text-app-primary' if 'users' in request.url.path else 'text-app-textMuted hover:bg-app-surfaceHover hover:text-app-textMain' }}" :class="expanded ? '' : 'justify-center'">
<i data-lucide="users" class="w-5 h-5 flex-shrink-0"></i>
<span x-show="expanded" x-cloak>Пользователи</span>
</a>
<a href="/logs" title="Логи" class="flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors {{ 'bg-app-primary/10 text-app-primary' if 'logs' in request.url.path else 'text-app-textMuted hover:bg-app-surfaceHover hover:text-app-textMain' }}" :class="expanded ? '' : 'justify-center'">
<i data-lucide="file-text" class="w-5 h-5 flex-shrink-0"></i>
<span x-show="expanded" x-cloak>Логи</span>
</a>
{% endif %}
</nav>
<div class="p-4 border-t border-app-border">
<button type="button" title="Переключить тему" class="w-full flex items-center justify-center gap-2 px-3 py-2 mb-2 rounded-lg text-sm font-medium text-app-textMuted hover:text-app-textMain hover:bg-app-surfaceHover transition-colors" onclick="toggleTheme()">
<i data-lucide="sun-moon" class="w-4 h-4"></i>
<span x-show="expanded" x-cloak>Тема</span>
</button>
<div class="flex items-center gap-3 px-3 py-2 mb-2" :class="expanded ? '' : 'justify-center'">
<div class="w-8 h-8 rounded-full bg-app-primary/20 flex items-center justify-center text-app-primary font-bold text-sm flex-shrink-0">
{{ user.login[0]|upper }}
</div>
<div x-show="expanded" x-cloak class="flex-1 min-w-0">
<div class="text-sm font-medium text-app-textMain truncate">{{ user.login }}</div>
<div class="text-xs text-app-textMuted truncate">{{ user.role }}</div>
</div>
</div>
<form method="post" action="/logout" class="m-0">
<input type="hidden" name="csrf_token" value="{{ user.csrf_token }}">
<button type="submit" title="Выйти" class="w-full flex items-center justify-center gap-2 px-3 py-2 rounded-lg text-sm font-medium text-app-error hover:bg-app-errorBg transition-colors">
<i data-lucide="log-out" class="w-4 h-4"></i>
<span x-show="expanded" x-cloak>Выйти</span>
</button>
</form>
</div>
</aside>
<!-- Mobile Topbar (Visible only on small screens) -->
<div class="md:hidden fixed top-0 left-0 right-0 h-16 bg-app-surface border-b border-app-border z-50 flex items-center justify-between px-4">
<div class="flex items-center gap-2 font-bold text-app-textMain">
{{ app_title or "Редакторская" }}
</div>
<div class="flex items-center gap-1">
<button class="btn btn-ghost btn-icon" type="button" onclick="toggleTheme()" title="Тема">
<i data-lucide="sun-moon" class="w-5 h-5"></i>
</button>
<button class="btn btn-ghost btn-icon" onclick="document.getElementById('mobile-menu').classList.toggle('hidden')">
<i data-lucide="menu" class="w-6 h-6"></i>
</button>
</div>
</div>
<!-- Mobile Menu Dropdown -->
<div id="mobile-menu" class="hidden md:hidden fixed top-16 left-0 right-0 bg-app-surface border-b border-app-border z-40 p-4 space-y-2 shadow-lg">
<a href="/raw" class="block px-3 py-2 rounded-lg text-sm {{ 'bg-app-bg text-app-textMain' if 'raw' in request.url.path else 'text-app-textMuted' }}">Сырые посты</a>
<a href="/editor" class="block px-3 py-2 rounded-lg text-sm {{ 'bg-app-bg text-app-textMain' if 'editor' in request.url.path else 'text-app-textMuted' }}">Редактор</a>
<a href="/sources" class="block px-3 py-2 rounded-lg text-sm {{ 'bg-app-bg text-app-textMain' if 'sources' in request.url.path else 'text-app-textMuted' }}">Источники</a>
{% if user.role != "editor" %}
<a href="/workers" class="block px-3 py-2 rounded-lg text-sm {{ 'bg-app-bg text-app-textMain' if 'workers' in request.url.path else 'text-app-textMuted' }}">Воркеры</a>
{% endif %}
<div class="pt-2 mt-2 border-t border-app-border">
<form method="post" action="/logout" class="m-0">
<input type="hidden" name="csrf_token" value="{{ user.csrf_token }}">
<button type="submit" class="w-full text-left px-3 py-2 rounded-lg text-sm text-app-error">Выйти</button>
</form>
</div>
</div>
{% endif %}
<!-- Main Content Area -->
<main class="flex-1 min-w-0 flex flex-col h-screen overflow-y-auto {% if user %}pt-16 md:pt-0{% endif %} relative">
<div class="p-4 md:p-8 max-w-[1600px] w-full mx-auto">
{% block body %}{% endblock %}
</div>
</main>
<script>
function toggleTheme() {
const light = document.documentElement.classList.toggle('dark') === false;
localStorage.setItem('admin-theme', light ? 'light' : 'dark');
}
// Initialize Lucide icons
lucide.createIcons();
// Re-initialize icons after HTMX swap
document.body.addEventListener('htmx:afterSwap', function() {
lucide.createIcons();
});
document.body.addEventListener('htmx:responseError', function(evt) {
alert("Ошибка при загрузке: " + evt.detail.xhr.status);
});
</script>
</body>
</html>
+33
View File
@@ -0,0 +1,33 @@
{% extends "base.html" %}
{% block body %}
<div class="mb-6 flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
<div>
<h1 class="text-3xl font-bold tracking-tight text-white flex items-center gap-3">
<i data-lucide="edit-3" class="text-app-primary w-8 h-8"></i>
Редакторская
</h1>
<div class="text-app-textMuted mt-1 text-sm">Проверка, правка и публикация сгенерированных постов.</div>
</div>
<!-- Status Tabs -->
<div class="w-full md:w-auto min-w-0 overflow-x-auto">
<div class="tabs p-1 w-max">
{% for st in status_options %}
<a class="tab {% if st.value in editorial_statuses %}tab-active{% endif %}" href="/editor?editorial_status={{ st.value }}">
{{ st.label }}
<span class="ml-2 px-1.5 py-0.5 rounded-full text-xs font-semibold {% if st.value in editorial_statuses %}bg-app-primary/20 text-app-primary{% else %}bg-app-surface text-app-textMuted{% endif %}">
{{ counts.get(st.value, 0) }}
</span>
</a>
{% endfor %}
</div>
</div>
</div>
<div id="content-area">
{% include "editor_content.html" %}
</div>
<!-- Modal container for HTMX forms -->
<div id="modal-container"></div>
{% endblock %}
@@ -0,0 +1,128 @@
<div class="flex flex-col xl:flex-row gap-6" x-data="{ filtersOpen: false }">
<!-- Sidebar Filters -->
<aside class="w-full flex-shrink-0 transition-all duration-200" :class="filtersOpen ? 'xl:w-72' : 'xl:w-16'">
<div class="card sticky top-6" :class="filtersOpen ? 'p-4' : 'p-2'">
<button type="button" class="btn btn-ghost btn-icon w-10 h-10 mx-auto mb-0" @click="filtersOpen = !filtersOpen; setTimeout(() => window.dispatchEvent(new Event('resize')), 250)" :aria-expanded="filtersOpen.toString()" :title="filtersOpen ? 'Свернуть фильтры' : 'Развернуть фильтры'">
<i data-lucide="sliders-horizontal" class="w-5 h-5"></i>
</button>
<form id="filter-form" hx-get="/editor" hx-target="#content-area" hx-push-url="true" hx-trigger="submit">
<input type="hidden" name="editorial_status" value="{{ status_filter }}">
<div x-show="filtersOpen" x-cloak class="flex justify-between items-center mt-4 mb-6">
<h2 class="text-sm font-bold text-app-textMuted uppercase tracking-wider">Фильтры</h2>
<a href="/editor" class="text-xs text-app-primary hover:text-app-primaryHover transition-colors" hx-boost="true" hx-target="#content-area">Сбросить</a>
</div>
<div x-show="filtersOpen" x-cloak class="space-y-6">
<!-- Sort -->
<div>
<label class="block text-xs font-semibold text-app-textMain mb-2">Сортировка</label>
<select name="sort" class="select 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>
<!-- Score -->
<div>
<label class="block text-xs font-semibold text-app-textMain mb-2">Оценка AI</label>
<div class="flex gap-2">
<input type="number" name="score_min" placeholder="От" value="{{ score_min }}" class="input w-full" />
<input type="number" name="score_max" placeholder="До" value="{{ score_max }}" class="input w-full" />
</div>
</div>
<!-- Dates -->
<div>
<label class="block text-xs font-semibold text-app-textMain mb-2">Дата поста VK</label>
<div class="space-y-2">
<input type="date" name="date_from" value="{{ date_from }}" class="input w-full" />
<input type="date" name="date_to" value="{{ date_to }}" class="input w-full" />
</div>
</div>
<!-- Source -->
<div>
<label class="block text-xs font-semibold text-app-textMain mb-2">Источник</label>
<div class="bg-app-bg border border-app-border rounded-lg p-2 max-h-48 overflow-y-auto space-y-1">
{% for item in facets.sources %}
<label class="flex items-center gap-2 px-2 py-1 hover:bg-app-surfaceHover rounded cursor-pointer transition-colors group">
<input type="checkbox" name="source_id" value="{{ item.id }}" class="checkbox" {% if item.id in source_ids %}checked{% endif %} />
<span class="text-sm text-app-textMain flex-1 truncate group-hover:text-white" title="{{ item.name }}">{{ item.name }}</span>
<span class="badge badge-neutral bg-transparent border-none">{{ item.count }}</span>
</label>
{% endfor %}
</div>
</div>
<!-- Category -->
<div>
<label class="block text-xs font-semibold text-app-textMain mb-2">Категория</label>
<div class="bg-app-bg border border-app-border rounded-lg p-2 max-h-48 overflow-y-auto space-y-1">
{% for item in facets.categories %}
<label class="flex items-center gap-2 px-2 py-1 hover:bg-app-surfaceHover rounded cursor-pointer transition-colors group">
<input type="checkbox" name="category" value="{{ item.value }}" class="checkbox" {% if item.value in categories_selected %}checked{% endif %} />
<span class="text-sm text-app-textMain flex-1 truncate group-hover:text-white" title="{{ item.value or 'Без категории' }}">{{ item.value or "Без категории" }}</span>
<span class="badge badge-neutral bg-transparent border-none">{{ item.count }}</span>
</label>
{% endfor %}
</div>
</div>
<button class="btn btn-primary w-full" type="submit">Применить фильтры</button>
</div>
</form>
</div>
</aside>
<!-- Main Content (List) -->
<div class="flex-1 min-w-0 flex flex-col gap-6">
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 bg-app-surface border border-app-border p-3 rounded-xl shadow-sm">
<div class="text-sm text-app-textMuted flex items-center gap-2">
<i data-lucide="layers" class="w-4 h-4"></i>
Найдено: <span class="font-bold text-white">{{ pagination.total }}</span>
</div>
<div class="flex items-center gap-2">
<span class="text-sm text-app-textMuted">На странице:</span>
<select name="per_page" class="select select-sm w-auto py-1 pr-8" form="filter-form">
{% 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 p-12 flex flex-col items-center justify-center text-center">
<i data-lucide="inbox" class="w-16 h-16 text-app-borderFocus mb-4"></i>
<h3 class="text-lg font-bold text-white mb-1">Ничего не найдено</h3>
<p class="text-app-textMuted text-sm">В этой очереди пока нет постов, подходящих под фильтры.</p>
</div>
{% endfor %}
</div>
<!-- Pagination -->
{% if pagination.pages > 1 %}
<div class="flex justify-center mt-4">
<div class="tabs p-1">
<button hx-get="?page=1" hx-include="#filter-form" hx-target="#content-area" class="btn btn-sm btn-ghost w-10 h-10 p-0 rounded-md" {% if pagination.page == 1 %}disabled{% endif %}><i data-lucide="chevrons-left" class="w-4 h-4"></i></button>
<button hx-get="?page={{ pagination.page - 1 }}" hx-include="#filter-form" hx-target="#content-area" class="btn btn-sm btn-ghost w-10 h-10 p-0 rounded-md" {% if pagination.page == 1 %}disabled{% endif %}><i data-lucide="chevron-left" class="w-4 h-4"></i></button>
<span class="flex items-center justify-center px-4 text-sm font-medium text-app-textMuted bg-app-bg rounded-md border border-app-border mx-1">
{{ pagination.page }} из {{ pagination.pages }}
</span>
<button hx-get="?page={{ pagination.page + 1 }}" hx-include="#filter-form" hx-target="#content-area" class="btn btn-sm btn-ghost w-10 h-10 p-0 rounded-md" {% if pagination.page >= pagination.pages %}disabled{% endif %}><i data-lucide="chevron-right" class="w-4 h-4"></i></button>
<button hx-get="?page={{ pagination.pages }}" hx-include="#filter-form" hx-target="#content-area" class="btn btn-sm btn-ghost w-10 h-10 p-0 rounded-md" {% if pagination.page >= pagination.pages %}disabled{% endif %}><i data-lucide="chevrons-right" class="w-4 h-4"></i></button>
</div>
</div>
{% endif %}
</div>
</div>
@@ -0,0 +1,213 @@
<form hx-post="/editor/{{ p.id }}/save" hx-target="#post-actions-{{ p.id }}" hx-swap="outerHTML" hx-encoding="multipart/form-data" id="post-container-{{ p.id }}" class="card flex flex-col group relative transition-colors border border-app-border {% if p.editorial_status == 'accepted' %}bg-app-successBg/10 border-app-success/20{% elif p.editorial_status == 'rejected' %}bg-app-errorBg/10 opacity-75{% else %}hover:border-app-borderFocus hover:shadow-glow{% endif %}">
<input type="hidden" name="csrf_token" value="{{ user.csrf_token }}">
<div class="flex flex-col">
<!-- Top: Meta Info & Status -->
<div class="w-full bg-app-bg/50 border-b border-app-border p-4 flex flex-col gap-3 md:flex-row md:flex-wrap md:items-center">
<div class="flex items-center justify-between gap-3 md:justify-start">
<span class="text-xs font-mono text-app-textMuted tracking-wider">#{{ p.id }}</span>
{% include "editor_post_status.html" %}
</div>
<div class="flex items-center gap-2">
<div class="w-8 h-8 rounded bg-app-surface border border-app-border flex items-center justify-center flex-shrink-0">
<i data-lucide="link" class="w-4 h-4 text-app-textMuted"></i>
</div>
<div class="flex flex-col min-w-0">
<a href="{{ p.original_url }}" target="_blank" class="text-sm font-semibold text-app-textMain hover:text-app-primary truncate transition-colors" title="{{ p.source_name }}">{{ p.source_name }}</a>
<div class="text-xs text-app-textMuted truncate">{{ p.posted_at_fmt or p.created_at_fmt }}</div>
</div>
</div>
<div class="space-y-1 md:ml-auto">
<div class="flex justify-between gap-2 text-xs">
<span class="text-app-textMuted">AI Оценка:</span>
<span class="font-mono text-white font-medium">{{ p.qualification_score or "?" }}/10</span>
</div>
</div>
{% if p.publication_platforms %}
<div class="pt-3 border-t border-app-border/50 md:pt-0 md:border-t-0">
<div class="flex flex-wrap items-center gap-2">
<div class="text-xs text-app-textMuted font-semibold">Куда публикуем:</div>
{% for pub in p.publication_platforms %}
{% if pub.url %}
<a href="{{ pub.url }}" target="_blank" class="badge badge-info hover:bg-blue-500/20 transition-colors" title="{{ pub.error or pub.status_label }}">
{{ pub.label }} <i data-lucide="external-link" class="w-3 h-3 ml-1"></i>
</a>
{% elif pub.status == 'published' %}
<span class="badge badge-info" title="{{ pub.error or pub.status_label }}">
{{ pub.label }}
</span>
{% else %}
<span class="badge {% if pub.status == 'error' %}badge-error{% else %}badge-neutral{% endif %}" title="{{ pub.error or pub.status_label }}">
{{ pub.label }}
</span>
{% endif %}
{% endfor %}
</div>
</div>
{% endif %}
</div>
<!-- Right: Edit Area & Spoilers -->
<div class="flex-1 flex flex-col min-w-0 p-5">
<!-- Photos (Moved above text, larger preview, alpine modal gallery) -->
{% if p.media_items %}
{% set photo_urls = [] %}
{% for m in p.media_items %}
{% if m.type == 'photo' and m.url %}
{% set _ = photo_urls.append(m.url) %}
{% endif %}
{% endfor %}
<div class="mb-4" x-data="{ galleryOpen: false, activeIdx: 0, images: {{ photo_urls | tojson | forceescape }} }"
@keydown.escape.window="galleryOpen = false"
@keydown.left.window="if(galleryOpen && images.length > 1) activeIdx = (activeIdx === 0) ? images.length - 1 : activeIdx - 1"
@keydown.right.window="if(galleryOpen && images.length > 1) activeIdx = (activeIdx === images.length - 1) ? 0 : activeIdx + 1">
<div class="flex flex-wrap gap-2">
{% for m in p.media_items %}
{% if m.type == "photo" and m.url %}
{% set photo_idx = photo_urls.index(m.url) %}
<div class="relative block w-32 h-32 rounded-lg overflow-hidden border border-app-border hover:border-app-primary transition-colors group/media">
<input id="media-delete-{{ p.id }}-{{ m.id }}" type="checkbox" name="delete_media_ids" value="{{ m.id }}" class="peer sr-only">
<img @click.prevent="activeIdx = {{ photo_idx }}; galleryOpen = true" src="{{ m.url }}" class="w-full h-full object-cover peer-checked:opacity-35" loading="lazy">
<label for="media-delete-{{ p.id }}-{{ m.id }}" class="absolute top-1.5 right-1.5 z-10 w-7 h-7 rounded-full bg-black/70 text-white border border-white/20 flex items-center justify-center opacity-80 group-hover/media:opacity-100 peer-checked:bg-app-error peer-checked:text-white transition-colors cursor-pointer" title="Не публиковать это медиа">
<i data-lucide="x" class="w-4 h-4"></i>
</label>
<span class="absolute inset-x-0 bottom-0 hidden peer-checked:block pointer-events-none bg-app-error text-white text-[10px] font-bold text-center py-1">Не публиковать</span>
</div>
{% elif m.url %}
<div class="relative w-32 h-32 rounded-lg bg-app-bg border border-app-border flex items-center justify-center hover:text-app-primary transition-colors text-app-textMuted group/media">
<input id="media-delete-{{ p.id }}-{{ m.id }}" type="checkbox" name="delete_media_ids" value="{{ m.id }}" class="peer sr-only">
<a href="{{ m.url }}" target="_blank" class="absolute inset-0 flex items-center justify-center peer-checked:opacity-35">
<i data-lucide="play-circle" class="w-10 h-10"></i>
</a>
<label for="media-delete-{{ p.id }}-{{ m.id }}" class="absolute top-1.5 right-1.5 z-10 w-7 h-7 rounded-full bg-black/70 text-white border border-white/20 flex items-center justify-center opacity-80 group-hover/media:opacity-100 peer-checked:bg-app-error peer-checked:text-white transition-colors cursor-pointer" title="Не публиковать это медиа">
<i data-lucide="x" class="w-4 h-4"></i>
</label>
<span class="absolute inset-x-0 bottom-0 hidden peer-checked:block pointer-events-none bg-app-error text-white text-[10px] font-bold text-center py-1">Не публиковать</span>
</div>
{% endif %}
{% endfor %}
</div>
<!-- Alpine Modal Gallery -->
<div x-show="galleryOpen" style="display: none;" x-transition.opacity class="media-gallery-layer fixed inset-0 z-[100] flex items-center justify-center bg-black/90 backdrop-blur-sm p-4">
<!-- Close button -->
<button @click.prevent="galleryOpen = false" type="button" class="absolute top-4 right-4 text-white hover:text-app-primary transition-colors p-2">
<svg xmlns="http://www.w3.org/2000/svg" class="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" /></svg>
</button>
<!-- Previous -->
<button x-show="images.length > 1" @click.prevent="activeIdx = (activeIdx === 0) ? images.length - 1 : activeIdx - 1" type="button" class="absolute left-4 top-1/2 -translate-y-1/2 text-white hover:text-app-primary p-2">
<svg xmlns="http://www.w3.org/2000/svg" class="w-10 h-10" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7" /></svg>
</button>
<!-- Image -->
<img :src="images[activeIdx]" class="max-w-full max-h-[90vh] object-contain rounded-lg shadow-2xl">
<!-- Next -->
<button x-show="images.length > 1" @click.prevent="activeIdx = (activeIdx === images.length - 1) ? 0 : activeIdx + 1" type="button" class="absolute right-4 top-1/2 -translate-y-1/2 text-white hover:text-app-primary p-2">
<svg xmlns="http://www.w3.org/2000/svg" class="w-10 h-10" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" /></svg>
</button>
<!-- Counter -->
<div x-show="images.length > 1" class="absolute bottom-4 left-1/2 -translate-x-1/2 text-white font-mono text-sm bg-black/50 px-3 py-1 rounded-full">
<span x-text="activeIdx + 1"></span> / <span x-text="images.length"></span>
</div>
</div>
</div>
{% endif %}
<!-- Original Spoiler -->
<details class="group/details mb-4">
<summary class="flex items-center gap-2 text-xs font-semibold tracking-wide text-app-textMuted hover:text-white cursor-pointer select-none">
<i data-lucide="chevron-right" class="w-4 h-4 transition-transform group-open/details:rotate-90"></i>
ОРИГИНАЛЬНЫЙ ТЕКСТ
</summary>
<div class="mt-3 bg-app-bg/50 rounded-lg p-4 border border-app-border">
<div class="whitespace-pre-wrap text-app-textMain/80 font-mono text-xs leading-relaxed max-h-[300px] overflow-y-auto">{{ p.raw_text }}</div>
</div>
</details>
<!-- Edit Area (Auto resize textarea) -->
<label class="block text-xs font-bold text-app-textMuted mb-2 uppercase tracking-wider">Финальный текст</label>
<textarea name="final_text"
x-data="{
ro: null,
resize() {
$el.style.height = '0px';
$el.style.height = $el.scrollHeight + 'px';
},
init() {
$el.value = $el.value.replace(/\s+$/u, '');
this.$nextTick(() => requestAnimationFrame(() => this.resize()));
this.ro = new ResizeObserver(() => this.$nextTick(() => this.resize()));
this.ro.observe($el);
},
destroy() {
this.ro?.disconnect();
}
}"
@input="resize()"
class="textarea w-full font-sans text-sm leading-relaxed resize-none overflow-hidden p-3 bg-app-bg border border-app-border min-h-[150px]"
placeholder="Напишите идеальный пост здесь...">{{ p.review_text.rstrip() }}</textarea>
<!-- Meta Inputs row -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mt-4">
<div>
<label class="block text-xs font-bold text-app-textMuted mb-1">Категория</label>
<select name="final_category" class="select select-sm w-full bg-app-bg">
{% 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>
<label class="block text-xs font-bold text-app-textMuted mb-1">Тег источника</label>
<input type="text" name="final_source_tag" value="{{ p.review_source_tag or p.source_tag or '' }}" class="input input-sm w-full bg-app-bg">
</div>
<div>
<label class="block text-xs font-bold text-app-textMuted mb-1">Доп. Медиа</label>
<input type="file" name="media_files" class="block w-full text-xs text-app-textMuted file:mr-2 file:py-1 file:px-2 file:rounded file:border-0 file:text-xs file:font-semibold file:bg-app-border file:text-white hover:file:bg-app-borderFocus cursor-pointer border border-app-border bg-app-bg rounded-md" accept="image/*,video/*,.pdf,.doc,.docx" multiple>
</div>
</div>
<!-- AI Notes Spoiler -->
<div class="mt-4">
<details class="group/details">
<summary class="flex items-center gap-2 text-xs font-semibold tracking-wide text-app-textMuted hover:text-white cursor-pointer select-none">
<i data-lucide="chevron-right" class="w-4 h-4 transition-transform group-open/details:rotate-90"></i>
AI ЗАМЕТКИ / РЕЗОЛЮЦИЯ
</summary>
<div class="mt-3 bg-app-bg/50 rounded-lg p-4 border border-app-border text-sm">
<div class="space-y-4">
<div>
<div class="text-xs text-app-textMuted font-bold mb-2 uppercase tracking-wider">AI заметки / резолюция</div>
<div class="text-app-textMain/80 whitespace-pre-wrap italic">{{ p.rewrite_notes or p.qualification_reason or "—" }}</div>
</div>
<div>
<div class="text-xs text-app-textMuted font-bold mb-2 uppercase tracking-wider">AI рерайт</div>
<div class="whitespace-pre-wrap text-app-textMain/90 font-mono text-xs leading-relaxed max-h-[360px] overflow-y-auto">{{ p.rewritten_text or "—" }}</div>
</div>
<div>
<label class="block text-xs font-bold text-app-textMuted mb-1 uppercase tracking-wider">Заметка редактора (внутренняя)</label>
<input type="text" name="editor_notes" value="{{ p.editor_notes or '' }}" class="input input-sm w-full text-app-warning placeholder:text-app-warning/50 bg-app-bg">
</div>
</div>
</div>
</details>
</div>
<!-- Actions -->
{% include "editor_post_actions.html" %}
</div>
</div>
</form>
@@ -0,0 +1,36 @@
{% if p.editorial_status != 'published' %}
<div id="post-actions-{{ p.id }}" class="mt-5 pt-4 border-t border-app-border flex flex-wrap items-center justify-between gap-3">
<div class="flex items-center gap-3">
{% if action_message %}
<div class="flex items-center gap-2 text-sm font-medium {% if action_tone == 'success' %}text-app-success{% elif action_tone == 'warning' %}text-app-warning{% elif action_tone == 'error' %}text-app-error{% else %}text-app-textMuted{% endif %}">
<i data-lucide="{% if action_tone == 'success' %}check-circle-2{% elif action_tone == 'warning' %}corner-up-left{% elif action_tone == 'error' %}x-circle{% else %}info{% endif %}" class="w-4 h-4"></i>
{{ action_message }}
</div>
{% endif %}
{% if p.editorial_status in ['rejected', 'accepted', 'publish_failed'] %}
<button class="btn btn-ghost btn-sm text-app-warning hover:bg-app-warning/10 hover:text-app-warning" type="button"
hx-post="/editor/{{ p.id }}/return" hx-target="#post-actions-{{ p.id }}" hx-swap="outerHTML"
hx-vals='{"csrf_token": "{{ user.csrf_token }}"}'>
<i data-lucide="corner-up-left" class="w-4 h-4"></i> Вернуть на проверку
</button>
{% else %}
<button class="btn btn-ghost btn-sm text-app-error hover:bg-app-errorBg hover:text-app-error" type="button"
hx-post="/editor/{{ p.id }}/reject" hx-target="#post-actions-{{ p.id }}" hx-swap="outerHTML"
hx-vals='{"csrf_token": "{{ user.csrf_token }}"}'>
<i data-lucide="x" class="w-4 h-4"></i> Отклонить
</button>
{% endif %}
</div>
{% if p.editorial_status not in ['rejected', 'accepted', 'publish_failed'] %}
<div class="flex gap-2">
<button type="submit" name="action" value="save" class="btn btn-surface btn-sm">
<i data-lucide="save" class="w-4 h-4"></i> Сохранить как черновик
</button>
<button type="submit" name="action" value="accept" class="btn btn-primary btn-sm">
<i data-lucide="send" class="w-4 h-4"></i> Сохранить и Опубликовать
</button>
</div>
{% endif %}
</div>
{% endif %}
@@ -0,0 +1,11 @@
<div id="post-status-{{ p.id }}" {% if status_oob %}hx-swap-oob="outerHTML"{% endif %} class="flex items-center gap-1 text-xs font-medium {% if p.editorial_status == 'accepted' or p.editorial_status == 'published' %}text-app-success bg-app-success/10{% elif p.editorial_status == 'rejected' or p.editorial_status == 'publish_failed' %}text-app-error bg-app-error/10{% elif p.editorial_status == 'regenerating' %}text-app-warning bg-app-warning/10{% else %}text-blue-400 bg-blue-400/10{% endif %} px-2 py-1 rounded-md">
{% if p.editorial_status == 'accepted' or p.editorial_status == 'published' %}
<i data-lucide="check-circle-2" class="w-3 h-3"></i> Принят
{% elif p.editorial_status == 'rejected' or p.editorial_status == 'publish_failed' %}
<i data-lucide="x-circle" class="w-3 h-3"></i> Отклонен
{% elif p.editorial_status == 'regenerating' %}
<i data-lucide="refresh-cw" class="w-3 h-3 animate-spin"></i> В работе
{% else %}
<i data-lucide="clock" class="w-3 h-3"></i> Ожидает
{% endif %}
</div>
@@ -0,0 +1,3 @@
{% include "editor_post_actions.html" %}
{% set status_oob = true %}
{% include "editor_post_status.html" %}
+30
View File
@@ -0,0 +1,30 @@
{% extends "base.html" %}
{% block body %}
<div class="flex items-center justify-center min-h-[70vh]">
<div class="card w-96 bg-base-100 shadow-xl border border-base-200">
<div class="card-body">
<h2 class="card-title text-2xl font-bold justify-center mb-4">Вход в систему</h2>
{% if error %}
<div class="alert alert-error text-sm py-2 mb-4">
<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>
{% endblock %}
+160
View File
@@ -0,0 +1,160 @@
{% extends "base.html" %}
{% block body %}
<div class="mb-8">
<h1 class="text-3xl font-bold tracking-tight text-white flex items-center gap-3 mb-2">
<i data-lucide="scroll-text" class="text-app-primary w-8 h-8"></i>
Логи
</h1>
<div class="text-app-textMuted text-sm">Журнал действий и запросов админки. События старше 30 дней удаляются автоматически.</div>
</div>
<div class="card overflow-hidden mb-8">
<div class="p-4 bg-app-surface border-b border-app-border">
<form method="get" action="/logs" class="flex flex-wrap items-end gap-4">
<div class="flex-1 min-w-[200px]">
<label class="block text-[10px] uppercase font-bold text-app-textMuted mb-1">Поиск</label>
<div class="relative">
<i data-lucide="search" class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-app-textMuted"></i>
<input name="q" value="{{ q }}" placeholder="action, entity, JSON" class="input w-full pl-9">
</div>
</div>
<div class="w-full sm:w-auto">
<label class="block text-[10px] uppercase font-bold text-app-textMuted mb-1">Действие</label>
<select name="action" class="select w-full sm:w-40">
<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="w-full sm:w-auto">
<label class="block text-[10px] uppercase font-bold text-app-textMuted mb-1">Пользователь</label>
<select name="actor_id" class="select w-full sm:w-40">
<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="w-full sm:w-auto">
<label class="block text-[10px] uppercase font-bold text-app-textMuted mb-1">Сущность</label>
<select name="entity_type" class="select w-full sm:w-40">
<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="w-full sm:w-auto">
<label class="block text-[10px] uppercase font-bold text-app-textMuted mb-1">Период</label>
<div class="flex gap-2">
<input type="date" name="date_from" value="{{ date_from }}" class="input w-[130px]">
<input type="date" name="date_to" value="{{ date_to }}" class="input w-[130px]">
</div>
</div>
<div class="flex gap-2 w-full lg:w-auto">
<button class="btn btn-primary flex-1 lg:flex-none" type="submit">Найти</button>
<a class="btn btn-surface flex-1 lg:flex-none border-app-border" href="/logs">Сбросить</a>
</div>
</form>
</div>
<div class="p-4 border-b border-app-border flex justify-between items-center bg-app-bg/50">
<div class="text-sm text-app-textMuted flex items-center gap-2">
<i data-lucide="layers" class="w-4 h-4"></i>
Всего записей: <span class="font-bold text-white">{{ pagination.total }}</span>
</div>
<div class="flex items-center gap-2">
<span class="text-sm text-app-textMuted">На странице:</span>
<form method="get" action="/logs" class="m-0 flex items-center gap-2">
{% for item in preserved_query %}
{% if item.key != "per_page" %}
<input type="hidden" name="{{ item.key }}" value="{{ item.value }}">
{% endif %}
{% endfor %}
<select name="per_page" class="select select-sm w-20 py-1 pr-8" onchange="this.form.submit()">
{% 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="w-full text-left text-sm whitespace-nowrap">
<thead class="bg-app-bg border-b border-app-border">
<tr class="text-app-textMuted font-semibold tracking-wide uppercase text-xs">
<th class="px-4 py-3 w-40">Время</th>
<th class="px-4 py-3 w-32">Пользователь</th>
<th class="px-4 py-3 w-48">Действие</th>
<th class="px-4 py-3 w-48">Сущность</th>
<th class="px-4 py-3 min-w-[300px] w-full">Детали</th>
</tr>
</thead>
<tbody class="divide-y divide-app-border text-xs">
{% for item in logs %}
<tr class="hover:bg-app-surfaceHover transition-colors group">
<td class="px-4 py-3 font-mono text-app-textMuted text-[10px]">{{ item.created_at_fmt }}</td>
<td class="px-4 py-3 font-bold text-white">{{ item.actor_label }}</td>
<td class="px-4 py-3"><span class="badge badge-neutral px-1.5 py-0.5 text-[10px] font-mono border-app-border">{{ item.action }}</span></td>
<td class="px-4 py-3 font-mono text-app-textMuted text-[10px]">
{{ item.entity_type }}
{% if item.entity_id %}
<span class="text-white ml-1">#{{ item.entity_id }}</span>
{% endif %}
</td>
<td class="px-4 py-3 whitespace-normal">
{% if item.details_pretty %}
<details class="group/json">
<summary class="cursor-pointer select-none flex items-center gap-1.5 text-app-primary hover:text-white transition-colors">
<i data-lucide="chevron-right" class="w-4 h-4 transition-transform group-open/json:rotate-90"></i>
<span class="text-[10px] font-bold uppercase tracking-wider">JSON Payload</span>
</summary>
<div class="mt-2 pl-5">
<div class="bg-[#0f1115] border border-app-border rounded-lg p-3 max-h-64 overflow-auto">
<pre class="font-mono text-[10px] text-app-textMuted leading-relaxed">{{ item.details_pretty }}</pre>
</div>
</div>
</details>
{% else %}
<span class="text-app-textMuted/50">—</span>
{% endif %}
</td>
</tr>
{% else %}
<tr>
<td colspan="5" class="px-4 py-12 text-center">
<div class="flex flex-col items-center justify-center text-app-textMuted">
<i data-lucide="search-x" class="w-12 h-12 mb-3 text-app-borderFocus"></i>
<p class="text-sm">Записей по заданным фильтрам не найдено</p>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if pagination.pages > 1 %}
<div class="p-4 border-t border-app-border flex justify-center bg-app-bg/50">
<div class="tabs p-1">
<a class="btn btn-sm btn-ghost w-10 h-10 p-0 rounded-md {% if pagination.page == 1 %}pointer-events-none opacity-50{% endif %}" href="?page=1{% for k,v in preserved_query %}{% if k != 'page' %}&{{ k }}={{ v }}{% endif %}{% endfor %}"><i data-lucide="chevrons-left" class="w-4 h-4"></i></a>
<a class="btn btn-sm btn-ghost w-10 h-10 p-0 rounded-md {% if pagination.page == 1 %}pointer-events-none opacity-50{% endif %}" href="?page={{ pagination.page - 1 }}{% for k,v in preserved_query %}{% if k != 'page' %}&{{ k }}={{ v }}{% endif %}{% endfor %}"><i data-lucide="chevron-left" class="w-4 h-4"></i></a>
<span class="flex items-center justify-center px-4 text-sm font-medium text-app-textMuted bg-app-bg rounded-md border border-app-border mx-1">
{{ pagination.page }} из {{ pagination.pages }}
</span>
<a class="btn btn-sm btn-ghost w-10 h-10 p-0 rounded-md {% if pagination.page >= pagination.pages %}pointer-events-none opacity-50{% endif %}" href="?page={{ pagination.page + 1 }}{% for k,v in preserved_query %}{% if k != 'page' %}&{{ k }}={{ v }}{% endif %}{% endfor %}"><i data-lucide="chevron-right" class="w-4 h-4"></i></a>
<a class="btn btn-sm btn-ghost w-10 h-10 p-0 rounded-md {% if pagination.page >= pagination.pages %}pointer-events-none opacity-50{% endif %}" href="?page={{ pagination.pages }}{% for k,v in preserved_query %}{% if k != 'page' %}&{{ k }}={{ v }}{% endif %}{% endfor %}"><i data-lucide="chevrons-right" class="w-4 h-4"></i></a>
</div>
</div>
{% endif %}
</div>
{% endblock %}
@@ -0,0 +1,37 @@
{% if pagination and pagination.pages > 1 %}
<div class="pagination">
<form method="get" class="pagination-form">
{% if preserved_query is defined %}
{% for item in preserved_query %}
<input type="hidden" name="{{ item.key }}" value="{{ item.value }}">
{% endfor %}
{% else %}
{% if q is defined %}<input type="hidden" name="q" value="{{ q }}">{% endif %}
{% if status_filter is defined %}<input type="hidden" name="status_filter" value="{{ status_filter }}">{% endif %}
{% if date_from is defined %}<input type="hidden" name="date_from" value="{{ date_from }}">{% endif %}
{% if date_to is defined %}<input type="hidden" name="date_to" value="{{ date_to }}">{% endif %}
{% if category_filter is defined %}<input type="hidden" name="category_filter" value="{{ category_filter }}">{% endif %}
{% if sort is defined %}<input type="hidden" name="sort" value="{{ sort }}">{% endif %}
{% if table_filters is defined %}
{% for item in table_filters %}
<input type="hidden" name="f_field" value="{{ item.field }}">
<input type="hidden" name="f_op" value="{{ item.op }}">
<input type="hidden" name="f_value" value="{{ item.value }}">
{% endfor %}
{% endif %}
{% if table_sorts is defined %}
{% for item in table_sorts %}
<input type="hidden" name="sort_field" value="{{ item.field }}">
<input type="hidden" name="sort_dir" value="{{ item.dir }}">
{% endfor %}
{% endif %}
{% endif %}
<input type="hidden" name="per_page" value="{{ pagination.per_page }}">
<button class="btn" name="page" value="{{ pagination.page - 1 }}" {% if not pagination.has_prev %}disabled{% endif %}>‹</button>
<span class="muted">Страница {{ pagination.page }} из {{ pagination.pages }} · всего {{ pagination.total }}</span>
<button class="btn" name="page" value="{{ pagination.page + 1 }}" {% if not pagination.has_next %}disabled{% endif %}>›</button>
</form>
</div>
{% else %}
{% if pagination %}<div class="pagination muted">Всего {{ pagination.total }}</div>{% endif %}
{% endif %}
@@ -0,0 +1,163 @@
{% extends "base.html" %}
{% block body %}
<div class="mb-8 flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
<div>
<h1 class="text-3xl font-bold">Тест промпта райтера</h1>
<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>
<a class="btn btn-outline" href="/workers">Настройки AI</a>
</div>
<div class="collapse collapse-arrow bg-base-100 shadow-sm border border-base-200 mb-8" 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 grid grid-cols-1 md:grid-cols-2 gap-6 text-sm">
<div>
<div class="font-bold uppercase tracking-widest text-xs text-base-content/60 mb-1">Что тестируем</div>
<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 class="font-bold uppercase tracking-widest text-xs text-base-content/60 mb-1">Что уйдёт в модель</div>
<p class="text-base-content/80">Один выбранный raw-пост в формате: categories, producer, ссылка, оценка, медиа и обрезанный исходный текст.</p>
</div>
<div>
<div class="font-bold uppercase tracking-widest text-xs text-base-content/60 mb-1">Что безопасно менять</div>
<p class="text-base-content/80">Голос канала, структуру текста, длину, правила про эмоджи, запрет выдумок, примеры формулировок.</p>
</div>
<div>
<div class="font-bold uppercase tracking-widest text-xs text-base-content/60 mb-1">Что не происходит</div>
<p class="text-base-content/80">Рерайт не сохраняется, batch не создаётся. Тест только тратит токены текущей модели райтера.</p>
</div>
</div>
</div>
<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 }}">
<section class="flex-1 flex flex-col gap-6">
<div class="card bg-base-100 shadow-sm border border-base-200">
<div class="card-body p-4 flex flex-col gap-4">
<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="q_status" data-status-select class="select select-bordered select-sm w-full">
{% for item in status_options %}
<option value="{{ item.value }}" {% if q_status == item.value %}selected{% endif %}>{{ item.label }}</option>
{% endfor %}
</select>
</div>
<div class="form-control">
<label class="label"><span class="label-text font-bold">Пост для теста</span></label>
<select name="post_id" data-post-select class="select select-bordered select-sm w-full">
{% for post in posts %}
<option value="{{ post.id }}" {% if selected and post.id == selected.id %}selected{% endif %}>{{ post.label }}</option>
{% endfor %}
</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>
{% if selected %}
<div class="card bg-base-200 border border-base-300">
<div class="card-body p-4 text-sm flex flex-col gap-2">
<div class="flex justify-between items-start">
<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>
{% endif %}
<div class="card bg-base-100 shadow-sm border border-base-200">
<div class="card-body p-4">
<div class="form-control">
<label class="label"><span class="label-text font-bold">ai_writer_prompt для теста</span></label>
<textarea name="prompt" rows="18" spellcheck="false" class="textarea textarea-bordered font-mono text-sm leading-relaxed w-full">{{ prompt }}</textarea>
</div>
<div class="mt-4 flex justify-end">
<button class="btn btn-primary" type="submit">Отправить тест</button>
</div>
</div>
</div>
</section>
<aside class="w-full lg:w-1/3 flex flex-col gap-6">
<div class="card bg-base-100 shadow-sm border border-base-200">
<div class="card-body p-4">
<h2 class="card-title text-base">Payload (Входные данные)</h2>
<p class="text-xs text-base-content/60 mb-2">Именно это отправится user-сообщением. Текст обрезан по настройке ai_writer_max_text_chars={{ max_text_chars }}.</p>
<pre class="bg-base-200 p-2 rounded text-xs font-mono overflow-auto max-h-64">{{ payload_json }}</pre>
</div>
</div>
<div class="card bg-base-100 shadow-sm border border-base-200">
<div class="card-body p-4 flex flex-col gap-4">
<h2 class="card-title text-base">Ответ модели</h2>
{% if error %}
<div class="alert alert-error text-sm">{{ error }}</div>
{% elif result %}
<div class="grid grid-cols-2 gap-2 text-xs bg-base-200 p-3 rounded">
<div class="font-bold text-base-content/60">Модель</div><div class="font-mono text-right truncate" title="{{ result.model }}">{{ result.model }}</div>
<div class="font-bold text-base-content/60">Токены</div><div class="text-right">{{ result.usage.total_tokens or "—" }}</div>
<div class="font-bold text-base-content/60">Стоимость</div><div class="text-right text-success">{{ result.usage.estimated_cost_usd or "—" }}</div>
</div>
{% if result.preview_text %}
<div>
<h3 class="font-bold text-sm mb-2">Как будет выглядеть</h3>
<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>
<div class="grid grid-cols-2 gap-2 text-xs mt-2">
<div class="font-bold text-base-content/60">Категория</div><div class="text-right">{{ result.preview_category or "—" }}</div>
<div class="font-bold text-base-content/60">Заметка</div><div class="text-right">{{ result.preview_notes or "—" }}</div>
</div>
</div>
{% endif %}
{% if result.parsed_json %}
<div>
<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>
</form>
<script>
const statusSelect = document.querySelector("[data-status-select]");
document.querySelector("[data-post-select]")?.addEventListener("change", (event) => {
const id = event.target.value;
const status = statusSelect?.value || "all";
if (id) window.location.href = `/prompt-test?post_id=${encodeURIComponent(id)}&q_status=${encodeURIComponent(status)}`;
});
statusSelect?.addEventListener("change", (event) => {
window.location.href = `/prompt-test?q_status=${encodeURIComponent(event.target.value)}`;
});
</script>
{% endblock %}
@@ -0,0 +1,194 @@
{% extends "base.html" %}
{% block body %}
<div class="toolbar">
<div>
<a class="muted" href="/raw">← Raw-посты</a>
<h1>Raw #{{ post.id }}</h1>
<div class="muted">
<a href="{{ post.original_url }}" target="_blank">{{ post.source_name }}</a>
· {{ post.source_platform }}
· #{{ post.source_tag or "—" }}
{% if post.posted_at_fmt %}· пост VK: {{ post.posted_at_fmt }}{% endif %}
</div>
</div>
<span class="pill {{ post.stage.class }}" title="{{ post.stage.key }}">{{ post.stage.label }}</span>
</div>
<div class="detail-grid">
<section class="panel">
<div class="media-strip detail-media">
{% for m in post.media_items %}
{% if m.type == "photo" and m.url %}
<button class="media-thumb detail-thumb" type="button" data-gallery-src="{{ m.url }}" data-gallery-title="#{{ post.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" title="{{ m.error or m.status }}">
<span>▶</span>
</a>
{% endif %}
{% endfor %}
</div>
{% if post.media_count %}<div class="muted media-count">{{ post.media_count }} медиа</div>{% endif %}
<div class="raw-full-text detail-text">{{ post.raw_text }}</div>
<div class="post-meta">Загружен парсером: {{ post.created_at_fmt }}</div>
</section>
<aside class="panel side-panel" id="ai">
<h2>AI-оценка</h2>
<a class="ai-badge detail-ai {{ post.ai_badge.class }}" href="#ai" title="{{ post.ai_badge.reason or post.ai_badge.sublabel }}">
<span>{{ post.ai_badge.label }}</span>
<small>{{ post.ai_badge.sublabel }}</small>
</a>
<dl class="kv">
<dt>Статус</dt>
<dd>{{ post.qualification_status or "pending" }}</dd>
<dt>Решение модели</dt>
<dd>{{ post.qualification_model_decision or post.qualification_decision or "—" }}</dd>
<dt>Итог по порогу</dt>
<dd>{{ post.qualification_decision or "—" }}</dd>
<dt>Причина</dt>
<dd>{{ post.qualification_reason or "—" }}</dd>
<dt>Reject tag</dt>
<dd>{{ post.qualification_reject_tag or "—" }}</dd>
<dt>Модель</dt>
<dd>{{ post.qualification_model or post.batch_model or "—" }}</dd>
<dt>Проверен</dt>
<dd>{{ post.qualified_at_fmt or "—" }}</dd>
<dt>Batch</dt>
<dd>{% if post.qualification_batch_id %}#{{ post.qualification_batch_id }}{% else %}—{% endif %}</dd>
<dt>Токены</dt>
<dd>{{ post.batch_total_tokens or "—" }}</dd>
</dl>
</aside>
</div>
<section class="panel" id="rewrite">
<div class="toolbar">
<div>
<h2>Рерайт</h2>
<div class="muted">Готовый текст для будущей редакторской проверки.</div>
</div>
<span class="pill {% if post.rewrite_status == 'ready' %}ok{% elif post.rewrite_status == 'failed' %}bad{% elif post.rewrite_status == 'processing' %}warn{% endif %}">
{{ post.rewrite_status or "pending" }}
</span>
</div>
{% if post.rewritten_text %}
<div class="rewrite-text">{{ post.rewritten_text }}</div>
{% else %}
<div class="muted">Рерайт ещё не готов.</div>
{% endif %}
<dl class="kv kv-wide rewrite-meta">
<dt>Категория</dt><dd>{{ post.rewrite_category or "—" }}</dd>
<dt>Хэштеги</dt>
<dd>
{% if post.rewrite_category_tag or post.rewrite_source_tag %}
#{{ post.rewrite_category_tag or "—" }} #{{ post.rewrite_source_tag or "—" }}
{% else %}
—
{% endif %}
</dd>
<dt>Заметка</dt><dd>{{ post.rewrite_notes or "—" }}</dd>
<dt>Модель</dt><dd>{{ post.rewrite_model or post.writer_batch_model or "—" }}</dd>
<dt>Готов</dt><dd>{{ post.rewritten_at_fmt or "—" }}</dd>
<dt>Batch</dt><dd>{% if post.rewrite_batch_id %}#{{ post.rewrite_batch_id }}{% else %}—{% endif %}</dd>
<dt>Токены</dt><dd>{{ post.writer_total_tokens or "—" }}</dd>
<dt>Стоимость</dt><dd>{% if post.writer_estimated_cost_usd %}${{ post.writer_estimated_cost_usd }}{% else %}—{% endif %}</dd>
</dl>
</section>
{% if post.qualification_batch_id %}
<details class="panel">
<summary><strong>Технический ответ AI</strong></summary>
<dl class="kv kv-wide">
<dt>Provider</dt><dd>{{ post.qualification_provider or "—" }}</dd>
<dt>Batch status</dt><dd>{{ post.batch_status or "—" }}</dd>
<dt>Создан</dt><dd>{{ post.batch_created_at_fmt or "—" }}</dd>
<dt>Завершён</dt><dd>{{ post.batch_completed_at_fmt or "—" }}</dd>
<dt>Постов в batch</dt><dd>{{ post.batch_posts_count or "—" }}</dd>
<dt>Accepted / rejected / maybe</dt>
<dd>{{ post.batch_accepted_count or 0 }} / {{ post.batch_rejected_count or 0 }} / {{ post.batch_maybe_count or 0 }}</dd>
<dt>Токены</dt>
<dd>{{ post.batch_prompt_tokens or 0 }} / {{ post.batch_completion_tokens or 0 }} / {{ post.batch_total_tokens or 0 }}</dd>
<dt>Стоимость</dt><dd>{% if post.batch_estimated_cost_usd %}${{ post.batch_estimated_cost_usd }}{% else %}—{% endif %}</dd>
<dt>Ошибка</dt><dd>{{ post.batch_error or "—" }}</dd>
</dl>
{% if post.batch_prompt_text %}
<details class="nested-details">
<summary>Использованный prompt квалификатора</summary>
<pre class="prompt-box">{{ post.batch_prompt_text }}</pre>
</details>
{% endif %}
<pre class="json-box">{{ post.batch_response_pretty or "{}" }}</pre>
</details>
{% endif %}
{% if post.rewrite_batch_id %}
<details class="panel">
<summary><strong>Технический ответ AI-райтера</strong></summary>
<dl class="kv kv-wide">
<dt>Provider</dt><dd>{{ post.rewrite_provider or "—" }}</dd>
<dt>Batch status</dt><dd>{{ post.writer_batch_status or "—" }}</dd>
<dt>Создан</dt><dd>{{ post.writer_batch_created_at_fmt or "—" }}</dd>
<dt>Завершён</dt><dd>{{ post.writer_batch_completed_at_fmt or "—" }}</dd>
<dt>Постов в batch</dt><dd>{{ post.writer_batch_posts_count or "—" }}</dd>
<dt>Ready / failed</dt><dd>{{ post.writer_batch_ready_count or 0 }} / {{ post.writer_batch_failed_count or 0 }}</dd>
<dt>Токены</dt>
<dd>{{ post.writer_prompt_tokens or 0 }} / {{ post.writer_completion_tokens or 0 }} / {{ post.writer_total_tokens or 0 }}</dd>
<dt>Стоимость</dt><dd>{% if post.writer_estimated_cost_usd %}${{ post.writer_estimated_cost_usd }}{% else %}—{% endif %}</dd>
<dt>Ошибка</dt><dd>{{ post.writer_batch_error or "—" }}</dd>
</dl>
{% if post.writer_batch_prompt_text %}
<details class="nested-details">
<summary>Использованный prompt райтера</summary>
<pre class="prompt-box">{{ post.writer_batch_prompt_text }}</pre>
</details>
{% endif %}
<pre class="json-box">{{ post.writer_batch_response_pretty or "{}" }}</pre>
</details>
{% endif %}
<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);
});
})();
</script>
{% endblock %}
@@ -0,0 +1,17 @@
{% extends "base.html" %}
{% block body %}
<div class="mb-6 flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
<div>
<h1 class="text-3xl font-bold tracking-tight text-white flex items-center gap-3">
<i data-lucide="inbox" class="text-app-primary w-8 h-8"></i>
Сырые посты
</h1>
<div class="text-app-textMuted mt-1 text-sm">Этапы обработки исходных постов и AI-квалификация.</div>
</div>
</div>
<div id="content-area">
{% include "raw_posts_content.html" %}
</div>
{% endblock %}
@@ -0,0 +1,223 @@
<div class="flex flex-col xl:flex-row gap-6">
<!-- Sidebar Filters -->
<aside class="w-full xl:w-72 flex-shrink-0">
<div class="card p-4 sticky top-6">
<form id="filter-form" hx-get="/raw" hx-target="#content-area" hx-push-url="true" hx-trigger="submit">
<input type="hidden" name="sort" value="{{ sort }}">
<div class="flex justify-between items-center mb-6">
<h2 class="text-sm font-bold text-app-textMuted uppercase tracking-wider">Фильтры</h2>
<a href="/raw" class="text-xs text-app-primary hover:text-app-primaryHover transition-colors" hx-boost="true" hx-target="#content-area">Сбросить</a>
</div>
<div class="space-y-6">
<!-- Score -->
<div>
<label class="block text-xs font-semibold text-app-textMain mb-2">Оценка AI</label>
<div class="flex gap-2">
<input type="number" name="score_min" placeholder="От" value="{{ score_min }}" class="input w-full" />
<input type="number" name="score_max" placeholder="До" value="{{ score_max }}" class="input w-full" />
</div>
</div>
<!-- Dates -->
<div>
<label class="block text-xs font-semibold text-app-textMain mb-2">Дата поста</label>
<div class="space-y-2">
<input type="date" name="date_from" value="{{ date_from }}" class="input w-full" />
<input type="date" name="date_to" value="{{ date_to }}" class="input w-full" />
</div>
</div>
<!-- Qualification Status -->
<div>
<label class="block text-xs font-semibold text-app-textMain mb-2">Квалификация</label>
<div class="bg-app-bg border border-app-border rounded-lg p-2 max-h-48 overflow-y-auto space-y-1 custom-scrollbar">
{% for item in facets.qualification_statuses %}
<label class="flex items-center gap-2 px-2 py-1 hover:bg-app-surfaceHover rounded cursor-pointer transition-colors group">
<input type="checkbox" name="qualification_status" value="{{ item.value }}" class="checkbox" {% if item.value in qualification_statuses %}checked{% endif %} />
<span class="text-sm text-app-textMain flex-1 group-hover:text-white">{{ item.value }}</span>
<span class="badge badge-neutral bg-transparent border-none">{{ item.count }}</span>
</label>
{% endfor %}
</div>
</div>
<!-- Rewrite Status -->
<div>
<label class="block text-xs font-semibold text-app-textMain mb-2">Рерайт</label>
<div class="bg-app-bg border border-app-border rounded-lg p-2 max-h-48 overflow-y-auto space-y-1 custom-scrollbar">
{% for item in facets.rewrite_statuses %}
<label class="flex items-center gap-2 px-2 py-1 hover:bg-app-surfaceHover rounded cursor-pointer transition-colors group">
<input type="checkbox" name="rewrite_status" value="{{ item.value }}" class="checkbox" {% if item.value in rewrite_statuses %}checked{% endif %} />
<span class="text-sm text-app-textMain flex-1 group-hover:text-white">{{ item.value }}</span>
<span class="badge badge-neutral bg-transparent border-none">{{ item.count }}</span>
</label>
{% endfor %}
</div>
</div>
<!-- Source -->
<div>
<label class="block text-xs font-semibold text-app-textMain mb-2">Источник</label>
<div class="bg-app-bg border border-app-border rounded-lg p-2 max-h-48 overflow-y-auto space-y-1 custom-scrollbar">
{% for item in facets.sources %}
<label class="flex items-center gap-2 px-2 py-1 hover:bg-app-surfaceHover rounded cursor-pointer transition-colors group">
<input type="checkbox" name="source_id" value="{{ item.id }}" class="checkbox" {% if item.id in source_ids %}checked{% endif %} />
<span class="text-xs text-app-textMain flex-1 truncate group-hover:text-white" title="{{ item.name }}">{{ item.name }}</span>
<span class="badge badge-neutral bg-transparent border-none text-[10px]">{{ item.count }}</span>
</label>
{% endfor %}
</div>
</div>
<button class="btn btn-primary w-full" type="submit">Применить фильтры</button>
</div>
</form>
</div>
</aside>
<!-- Main Content (Table) -->
<div class="flex-1 min-w-0 flex flex-col gap-6">
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 bg-app-surface border border-app-border p-3 rounded-xl shadow-sm">
<div class="text-sm text-app-textMuted flex items-center gap-2">
<i data-lucide="layers" class="w-4 h-4"></i>
Всего: <span class="font-bold text-white">{{ pagination.total }}</span>
</div>
<div class="flex items-center gap-2">
<span class="text-sm text-app-textMuted">На странице:</span>
<select name="per_page" class="select select-sm w-auto py-1 pr-8" form="filter-form" onchange="document.getElementById('filter-form').dispatchEvent(new Event('submit', {cancelable: true}))">
{% for n in [25,50,100,200] %}
<option value="{{ n }}" {% if pagination.per_page == n %}selected{% endif %}>{{ n }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="card overflow-hidden">
<div class="md:hidden divide-y divide-app-border">
{% for p in posts %}
<a href="/raw/{{ p.id }}" class="block p-4 hover:bg-app-surfaceHover transition-colors">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<div class="font-mono text-xs text-app-primary">#{{ p.id }}</div>
<div class="mt-1 font-semibold text-app-textMain truncate">{{ p.source_name }}</div>
<div class="mt-1 flex flex-wrap gap-1">
<span class="badge badge-neutral text-[9px] uppercase tracking-wider">{{ p.status }}</span>
{% if p.qualification_status %}
<span class="badge badge-success text-[9px] uppercase tracking-wider bg-app-success/10 text-app-success border-app-success/20">Квалиф: {{ p.qualification_status }}</span>
{% endif %}
{% if p.rewrite_status %}
<span class="badge badge-warning text-[9px] uppercase tracking-wider bg-app-warning/10 text-app-warning border-app-warning/20">Рерайт: {{ p.rewrite_status }}</span>
{% endif %}
</div>
</div>
<div class="flex-shrink-0 text-right">
{% if p.qualification_score is not none %}
<div class="font-mono font-bold text-lg {% if p.qualification_score >= 8 %}text-app-success{% elif p.qualification_score >= 5 %}text-app-warning{% else %}text-app-error{% endif %}">{{ p.qualification_score }}</div>
{% else %}
<span class="text-app-textMuted">—</span>
{% endif %}
</div>
</div>
<div class="mt-3 text-xs text-app-textMain/80 leading-relaxed line-clamp-3">{{ p.raw_text }}</div>
<div class="mt-2 text-[10px] text-app-textMuted">{{ p.posted_at_fmt or p.created_at_fmt }}</div>
</a>
{% else %}
<div class="px-4 py-12">
<div class="flex flex-col items-center justify-center text-app-textMuted">
<i data-lucide="inbox" class="w-12 h-12 mb-3 text-app-borderFocus"></i>
<p class="text-sm">Нет данных по заданным фильтрам</p>
</div>
</div>
{% endfor %}
</div>
<div class="hidden md:block overflow-x-auto">
<table class="w-full text-left text-sm whitespace-nowrap">
<thead class="bg-app-bg border-b border-app-border">
<tr class="text-app-textMuted font-semibold tracking-wide uppercase text-xs">
<th class="px-4 py-3">#</th>
<th class="px-4 py-3">Источник</th>
<th class="px-4 py-3">Этап</th>
<th class="px-4 py-3 min-w-[300px] w-full">Пост</th>
<th class="px-4 py-3 text-center">AI Оценка</th>
</tr>
</thead>
<tbody class="divide-y divide-app-border">
{% for p in posts %}
<tr class="hover:bg-app-surfaceHover transition-colors group">
<td class="px-4 py-3 font-mono text-xs">
<a href="/raw/{{ p.id }}" class="text-app-primary hover:text-white transition-colors">#{{ p.id }}</a>
</td>
<td class="px-4 py-3 whitespace-normal min-w-[200px]">
<a href="{{ p.original_url }}" target="_blank" class="font-semibold text-app-textMain hover:text-app-primary transition-colors">{{ p.source_name }}</a>
<div class="flex items-center gap-2 mt-1">
<span class="text-[10px] text-app-textMuted font-mono bg-app-bg px-1 rounded">{{ p.source_platform }}</span>
<span class="text-[10px] text-blue-400 font-mono">#{{ p.source_tag or "—" }}</span>
</div>
</td>
<td class="px-4 py-3">
<div class="flex flex-col items-start gap-1">
<span class="badge badge-neutral text-[9px] uppercase tracking-wider">{{ p.status }}</span>
{% if p.qualification_status %}
<span class="badge badge-success text-[9px] uppercase tracking-wider bg-app-success/10 text-app-success border-app-success/20">Квалиф: {{ p.qualification_status }}</span>
{% endif %}
{% if p.rewrite_status %}
<span class="badge badge-warning text-[9px] uppercase tracking-wider bg-app-warning/10 text-app-warning border-app-warning/20">Рерайт: {{ p.rewrite_status }}</span>
{% endif %}
</div>
{% if p.error %}
<div class="text-[10px] text-app-error mt-1 max-w-[200px] whitespace-normal leading-tight">{{ p.error }}</div>
{% endif %}
</td>
<td class="px-4 py-3 whitespace-normal w-full max-w-sm lg:max-w-xl xl:max-w-2xl">
<div class="text-xs text-app-textMain/80 leading-relaxed font-sans line-clamp-3">
{{ p.raw_text }}
</div>
{% if p.posted_at_fmt %}
<div class="text-[10px] text-app-textMuted mt-1">Опубликован: {{ p.posted_at_fmt }}</div>
{% else %}
<div class="text-[10px] text-app-textMuted mt-1">Создан: {{ p.created_at_fmt }}</div>
{% endif %}
</td>
<td class="px-4 py-3 text-center">
{% if p.qualification_score is not none %}
<div class="font-mono font-bold text-lg {% if p.qualification_score >= 8 %}text-app-success{% elif p.qualification_score >= 5 %}text-app-warning{% else %}text-app-error{% endif %}">{{ p.qualification_score }}</div>
{% else %}
<span class="text-app-textMuted">—</span>
{% endif %}
</td>
</tr>
{% else %}
<tr>
<td colspan="5" class="px-4 py-12">
<div class="flex flex-col items-center justify-center text-app-textMuted">
<i data-lucide="inbox" class="w-12 h-12 mb-3 text-app-borderFocus"></i>
<p class="text-sm">Нет данных по заданным фильтрам</p>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<!-- Pagination -->
{% if pagination.pages > 1 %}
<div class="flex justify-center mt-4">
<div class="tabs p-1">
<button hx-get="?page=1" hx-include="#filter-form" hx-target="#content-area" class="btn btn-sm btn-ghost w-10 h-10 p-0 rounded-md" {% if pagination.page == 1 %}disabled{% endif %}><i data-lucide="chevrons-left" class="w-4 h-4"></i></button>
<button hx-get="?page={{ pagination.page - 1 }}" hx-include="#filter-form" hx-target="#content-area" class="btn btn-sm btn-ghost w-10 h-10 p-0 rounded-md" {% if pagination.page == 1 %}disabled{% endif %}><i data-lucide="chevron-left" class="w-4 h-4"></i></button>
<span class="flex items-center justify-center px-4 text-sm font-medium text-app-textMuted bg-app-bg rounded-md border border-app-border mx-1">
{{ pagination.page }} из {{ pagination.pages }}
</span>
<button hx-get="?page={{ pagination.page + 1 }}" hx-include="#filter-form" hx-target="#content-area" class="btn btn-sm btn-ghost w-10 h-10 p-0 rounded-md" {% if pagination.page >= pagination.pages %}disabled{% endif %}><i data-lucide="chevron-right" class="w-4 h-4"></i></button>
<button hx-get="?page={{ pagination.pages }}" hx-include="#filter-form" hx-target="#content-area" class="btn btn-sm btn-ghost w-10 h-10 p-0 rounded-md" {% if pagination.page >= pagination.pages %}disabled{% endif %}><i data-lucide="chevrons-right" class="w-4 h-4"></i></button>
</div>
</div>
{% endif %}
</div>
</div>
@@ -0,0 +1,57 @@
{% extends "base.html" %}
{% block body %}
<div class="mb-6 flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold">{{ title }}</h1>
</div>
<a class="btn btn-outline" href="/sources">Назад</a>
</div>
<div class="card bg-base-100 shadow-sm border border-base-200 max-w-3xl">
<div class="card-body">
<form method="post" action="{{ action }}" class="flex flex-col gap-4">
<input type="hidden" name="csrf_token" value="{{ user.csrf_token }}">
<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="platform" class="select select-bordered w-full">
<option value="vk" {% if not source or source.platform == "vk" %}selected{% endif %}>VK</option>
</select>
</div>
<div class="form-control">
<label class="label"><span class="label-text font-bold">Приоритет</span></label>
<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>
{% endblock %}
@@ -0,0 +1,63 @@
<tr class="hover:bg-app-surfaceHover transition-colors" id="source-row-{{ s.id }}">
<td class="hidden sm:table-cell px-4 py-3 font-mono text-[10px] text-app-textMuted">{{ s.id }}</td>
<td class="hidden md:table-cell px-4 py-3 uppercase text-[10px] tracking-widest font-bold text-app-textMuted">{{ s.platform }}</td>
<td class="px-4 py-3 min-w-0">
<div class="flex items-center gap-2 min-w-0">
<span class="font-bold text-app-textMain truncate">{{ s.name }}</span>
</div>
<div class="sm:hidden text-[10px] font-mono text-app-textMuted mt-1">#{{ s.id }}</div>
<div class="text-[10px] font-mono text-app-textMuted mt-1 truncate" title="External ID">{{ s.external_id or "" }}</div>
<div class="sm:hidden text-[10px] font-mono text-blue-400 mt-1 truncate">#{{ s.tag or "—" }}</div>
<a href="{{ s.url }}" target="_blank" class="lg:hidden text-[10px] text-app-textMuted hover:text-white hover:underline truncate block mt-1" title="{{ s.url }}">{{ s.url }}</a>
</td>
<td class="hidden sm:table-cell px-4 py-3 font-mono text-[10px] text-blue-400 truncate">#{{ s.tag or "—" }}</td>
<td class="hidden lg:table-cell px-4 py-3">
<a href="{{ s.url }}" target="_blank" class="text-app-textMuted hover:text-white hover:underline truncate inline-block max-w-[12rem]" title="{{ s.url }}">{{ s.url }}</a>
</td>
<td class="px-3 sm:px-4 py-3">
<div class="flex flex-col gap-1 items-start">
<div class="flex items-center gap-2">
{% if s.active %}
<span class="flex items-center gap-1 text-[10px] font-medium text-app-success"><span class="w-1.5 h-1.5 rounded-full bg-app-success"></span> Работает</span>
{% else %}
<span class="flex items-center gap-1 text-[10px] font-medium text-app-error"><span class="w-1.5 h-1.5 rounded-full bg-app-error"></span> На паузе</span>
{% endif %}
</div>
<div class="flex items-center gap-1 mt-0.5">
<span class="text-[9px] text-app-textMuted uppercase tracking-wider">Парсер:</span>
<span class="badge {% if s.status == 'ok' %}bg-app-success/10 text-app-success border-app-success/20{% elif s.status == 'error' %}bg-app-error/10 text-app-error border-app-error/20{% else %}bg-app-bg text-app-textMuted border-app-border{% endif %} px-1.5 py-0.5 text-[9px] uppercase tracking-wider">{{ s.status }}</span>
</div>
{% if s.status_msg %}
<span class="text-[9px] text-app-textMuted truncate max-w-[10rem] mt-0.5" title="{{ s.status_msg }}">{{ s.status_msg }}</span>
{% endif %}
</div>
</td>
<td class="hidden sm:table-cell px-4 py-3 text-right">
<div class="font-bold text-app-textMain">{{ s.posts_count }}</div>
<div class="text-[10px] text-app-success" title="За последние 24 часа">+{{ s.posts_24h }}</div>
</td>
<td class="hidden xl:table-cell px-4 py-3 text-right text-[10px] text-app-textMuted">{{ s.last_parsed_at_fmt or "Никогда" }}</td>
<td class="px-3 sm:px-4 py-3 text-center">
<div class="flex justify-center items-center gap-1">
<a href="/sources/{{ s.id }}/edit" class="btn btn-ghost btn-icon w-8 h-8 rounded-md hover:bg-app-surface text-app-textMuted hover:text-white" title="Править">
<i data-lucide="edit-2" class="w-4 h-4"></i>
</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-icon w-8 h-8 rounded-md hover:bg-app-surface text-app-textMuted hover:text-white" type="submit" title="{% if s.active %}Поставить на паузу{% else %}Возобновить{% endif %}">
{% if s.active %}
<i data-lucide="pause-circle" class="w-4 h-4 text-app-warning"></i>
{% else %}
<i data-lucide="play-circle" class="w-4 h-4 text-app-success"></i>
{% 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-icon w-8 h-8 rounded-md hover:bg-app-errorBg text-app-textMuted hover:text-app-error" type="submit" title="Удалить">
<i data-lucide="trash-2" class="w-4 h-4"></i>
</button>
</form>
</div>
</td>
</tr>
+146
View File
@@ -0,0 +1,146 @@
{% extends "base.html" %}
{% block body %}
<div class="mb-8">
<h1 class="text-3xl font-bold tracking-tight text-white flex items-center gap-3 mb-2">
<i data-lucide="database" class="text-app-primary w-8 h-8"></i>
Источники
</h1>
<div class="text-app-textMuted text-sm">VK-источники для парсинга. Название идёт в prompt, тэг — в будущие хэштеги.</div>
</div>
<details class="card mb-8 group/details" {% if source_preview %}open{% endif %}>
<summary class="p-4 flex items-center justify-between cursor-pointer select-none hover:bg-app-surfaceHover transition-colors list-none">
<div class="flex items-center gap-3">
<div class="w-8 h-8 rounded-lg bg-app-primary/20 text-app-primary flex items-center justify-center">
<i data-lucide="plus" class="w-5 h-5"></i>
</div>
<span class="text-lg font-bold text-white">Добавить источники</span>
</div>
<i data-lucide="chevron-down" class="w-5 h-5 text-app-textMuted transition-transform group-open/details:rotate-180"></i>
</summary>
<div class="p-6 border-t border-app-border bg-app-bg/30">
<div class="max-w-2xl">
<form method="post" action="/sources/preview" class="flex flex-col gap-4">
<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="active_filter" value="{{ active_filter }}">
<input type="hidden" name="per_page" value="{{ pagination.per_page }}">
<div>
<label class="block text-xs font-bold text-app-textMuted mb-1 flex justify-between">
<span>Список (Название Тэг Ссылка)</span>
<span class="text-[10px] text-app-textMuted/60 font-normal">По 1 на строку</span>
</label>
<textarea name="bulk_text" class="textarea font-mono text-xs h-40 leading-relaxed" placeholder="Academy Gear academy_gear https://vk.com/academy_gear&#10;A2 Technologies a2technologies https://vk.com/a2technologies">{{ bulk_text }}</textarea>
</div>
<label class="flex items-center gap-3 mt-2 cursor-pointer group">
<input name="bulk_active" type="checkbox" class="checkbox" {% if bulk_active %}checked{% endif %}>
<span class="text-sm font-medium text-app-textMain group-hover:text-white transition-colors">Включить после добавления</span>
</label>
<button class="btn btn-surface mt-2 self-start" type="submit">
<i data-lucide="search" class="w-4 h-4"></i> Проверить список
</button>
</form>
</div>
{% if source_preview %}
<div class="mt-8 pt-6 border-t border-app-border">
<h3 class="font-bold text-white mb-4 flex items-center gap-2"><i data-lucide="eye" class="w-4 h-4 text-app-primary"></i> Результаты проверки</h3>
<div class="overflow-x-auto bg-app-bg border border-app-border rounded-xl">
<table class="w-full text-left text-sm whitespace-nowrap">
<thead class="border-b border-app-border bg-app-surface">
<tr class="text-app-textMuted font-semibold tracking-wide uppercase text-[10px]">
<th class="px-4 py-3"># Стр.</th>
<th class="px-4 py-3">Название</th>
<th class="px-4 py-3">Тэг</th>
<th class="px-4 py-3">Ссылка</th>
<th class="px-4 py-3">VK ID</th>
<th class="px-4 py-3">Статус</th>
</tr>
</thead>
<tbody class="divide-y divide-app-border text-xs">
{% for item in source_preview %}
<tr class="hover:bg-app-surfaceHover transition-colors">
<td class="px-4 py-3 text-app-textMuted font-mono">{{ item.line_no }}</td>
<td class="px-4 py-3 font-semibold text-app-textMain">{{ item.name or "—" }}</td>
<td class="px-4 py-3 font-mono text-blue-400">#{{ item.tag or "—" }}</td>
<td class="px-4 py-3"><a href="{{ item.url }}" target="_blank" class="text-app-textMuted hover:text-white hover:underline">{{ item.url }}</a></td>
<td class="px-4 py-3 font-mono text-app-textMuted text-[10px]">
{{ item.external_id }}<br>
{{ item.external_owner_id or "" }}
</td>
<td class="px-4 py-3">
{% if item.ok %}
<span class="badge badge-success">Готов</span>
{% else %}
<span class="badge badge-error" title="{{ item.error }}">Ошибка</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<form method="post" action="/sources/bulk" class="mt-6 flex flex-col sm:flex-row items-center gap-4 bg-app-warningBg border border-app-warning/20 p-4 rounded-xl">
<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" type="submit">
<i data-lucide="check-circle" class="w-4 h-4"></i> Добавить прошедшие проверку
</button>
<div class="text-sm text-app-warning flex items-center gap-2">
<i data-lucide="info" class="w-4 h-4"></i>
<span>Строки с ошибками и дубликаты будут автоматически пропущены.</span>
</div>
</form>
</div>
{% endif %}
</div>
</details>
<div class="card mb-6">
<div class="p-4 border-b border-app-border bg-app-bg/50">
<form id="filter-form" class="flex flex-wrap gap-4 items-end" hx-get="/sources" hx-target="#content-area" hx-push-url="true">
<div class="w-full sm:flex-1 min-w-[250px]">
<label class="block text-xs font-bold text-app-textMuted mb-1">Поиск</label>
<div class="relative">
<i data-lucide="search" class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-app-textMuted"></i>
<input name="q" value="{{ q }}" placeholder="Название, ссылка, id" class="input w-full pl-9">
</div>
</div>
<div class="w-full sm:w-48">
<label class="block text-xs font-bold text-app-textMuted mb-1">Состояние</label>
<select name="active_filter" class="select w-full">
<option value="">Все состояния</option>
<option value="on" {% if active_filter == "on" %}selected{% endif %}>Включен (ON)</option>
<option value="off" {% if active_filter == "off" %}selected{% endif %}>Выключен (OFF)</option>
</select>
</div>
<div class="w-full sm:w-48">
<label class="block text-xs font-bold text-app-textMuted mb-1">Статус Парсера</label>
<select name="status_filter" class="select w-full">
<option value="">Все статусы</option>
{% for st in ["new","ok","paused","error"] %}
<option value="{{ st }}" {% if status_filter == st %}selected{% endif %}>{{ st }}</option>
{% endfor %}
</select>
</div>
<div class="w-full sm:w-auto">
<button class="btn btn-surface w-full sm:w-auto" type="submit">Найти</button>
</div>
</form>
</div>
<div id="content-area">
{% include "sources_content.html" %}
</div>
</div>
{% endblock %}
@@ -0,0 +1,48 @@
<div class="overflow-hidden">
<table class="w-full table-fixed text-left text-sm">
<thead class="bg-app-bg border-b border-app-border">
<tr class="text-app-textMuted font-semibold tracking-wide uppercase text-xs">
<th class="hidden sm:table-cell px-4 py-3 w-16">#</th>
<th class="hidden md:table-cell px-4 py-3 w-24">Платформа</th>
<th class="px-4 py-3">Название</th>
<th class="hidden sm:table-cell px-4 py-3 w-32">Тэг</th>
<th class="hidden lg:table-cell px-4 py-3 w-48">Ссылка</th>
<th class="px-3 py-3 w-28 sm:w-32">Статус</th>
<th class="hidden sm:table-cell px-4 py-3 w-24 text-right">Посты</th>
<th class="hidden xl:table-cell px-4 py-3 w-40 text-right">Последний парсинг</th>
<th class="px-3 py-3 w-24 sm:w-32 text-center">Действия</th>
</tr>
</thead>
<tbody class="divide-y divide-app-border text-xs">
{% for s in sources %}
{% include "source_row.html" %}
{% else %}
<tr>
<td colspan="9" class="px-4 py-12 text-center">
<div class="flex flex-col items-center justify-center text-app-textMuted">
<i data-lucide="database" class="w-12 h-12 mb-3 text-app-borderFocus"></i>
<p class="text-sm">Нет источников, удовлетворяющих фильтрам.</p>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Pagination bottom -->
{% if pagination.pages > 1 %}
<div class="flex justify-center p-4 border-t border-app-border">
<div class="tabs p-1">
<button hx-get="?page=1" hx-include="#filter-form" hx-target="#content-area" class="btn btn-sm btn-ghost w-10 h-10 p-0 rounded-md" {% if pagination.page == 1 %}disabled{% endif %}><i data-lucide="chevrons-left" class="w-4 h-4"></i></button>
<button hx-get="?page={{ pagination.page - 1 }}" hx-include="#filter-form" hx-target="#content-area" class="btn btn-sm btn-ghost w-10 h-10 p-0 rounded-md" {% if pagination.page == 1 %}disabled{% endif %}><i data-lucide="chevron-left" class="w-4 h-4"></i></button>
<span class="flex items-center justify-center px-4 text-sm font-medium text-app-textMuted bg-app-bg rounded-md border border-app-border mx-1">
{{ pagination.page }} из {{ pagination.pages }}
</span>
<button hx-get="?page={{ pagination.page + 1 }}" hx-include="#filter-form" hx-target="#content-area" class="btn btn-sm btn-ghost w-10 h-10 p-0 rounded-md" {% if pagination.page >= pagination.pages %}disabled{% endif %}><i data-lucide="chevron-right" class="w-4 h-4"></i></button>
<button hx-get="?page={{ pagination.pages }}" hx-include="#filter-form" hx-target="#content-area" class="btn btn-sm btn-ghost w-10 h-10 p-0 rounded-md" {% if pagination.page >= pagination.pages %}disabled{% endif %}><i data-lucide="chevrons-right" class="w-4 h-4"></i></button>
</div>
</div>
{% endif %}
@@ -0,0 +1,49 @@
<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 %}>
<input type="checkbox" {% if table_filters|selectattr("field")|list or table_sorts|selectattr("field")|list %}checked{% endif %} />
<div class="collapse-title text-sm font-medium">Расширенные фильтры и сортировка</div>
<div class="collapse-content border-t border-base-300 pt-4 flex flex-col lg:flex-row gap-6">
<div class="flex-1">
<div class="font-bold text-xs uppercase tracking-widest text-base-content/60 mb-2">Фильтры</div>
<div class="flex flex-col gap-2">
{% for item in table_filters %}
<div class="flex flex-col sm:flex-row gap-2">
<select name="f_field" class="select select-bordered select-sm w-full sm:w-1/3">
<option value="">Поле</option>
{% for field in field_options %}
<option value="{{ field.value }}" {% if item.field == field.value %}selected{% endif %}>{{ field.label }}</option>
{% endfor %}
</select>
<select name="f_op" class="select select-bordered select-sm w-full sm:w-1/4">
{% for op in filter_op_options %}
<option value="{{ op.value }}" {% if item.op == op.value %}selected{% endif %}>{{ op.label }}</option>
{% 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 class="text-xs text-base-content/50 mt-2">Пустые строки игнорируются. Для дат используй YYYY-MM-DD, для булевых значений — true/false.</div>
</div>
<div class="flex-1">
<div class="font-bold text-xs uppercase tracking-widest text-base-content/60 mb-2">Сортировка</div>
<div class="flex flex-col gap-2">
{% for item in table_sorts %}
<div class="flex flex-col sm:flex-row gap-2">
<select name="sort_field" class="select select-bordered select-sm w-full sm:w-1/2">
<option value="">Поле</option>
{% for field in field_options %}
<option value="{{ field.value }}" {% if item.field == field.value %}selected{% endif %}>{{ field.label }}</option>
{% endfor %}
</select>
<select name="sort_dir" class="select select-bordered select-sm w-full sm:w-1/2">
<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 class="text-xs text-base-content/50 mt-2">Сортировки применяются сверху вниз. Заменяют базовую сортировку страницы.</div>
</div>
</div>
</div>
+126
View File
@@ -0,0 +1,126 @@
{% extends "base.html" %}
{% block body %}
<div class="mb-8">
<h1 class="text-3xl font-bold tracking-tight text-white flex items-center gap-3 mb-2">
<i data-lucide="users" class="text-app-primary w-8 h-8"></i>
Пользователи
</h1>
<div class="text-app-textMuted text-sm">Управление доступом к админ-панели.</div>
</div>
<div class="card overflow-hidden mb-8">
<div class="overflow-x-auto">
<table class="w-full text-left text-sm whitespace-nowrap">
<thead class="bg-app-bg border-b border-app-border">
<tr class="text-app-textMuted font-semibold tracking-wide uppercase text-xs">
<th class="px-4 py-3 w-16">ID</th>
<th class="px-4 py-3">Логин</th>
<th class="px-4 py-3">Роль</th>
<th class="px-4 py-3">Создан</th>
<th class="px-4 py-3">Статус</th>
<th class="px-4 py-3 w-32 text-right">Действия</th>
</tr>
</thead>
<tbody class="divide-y divide-app-border text-xs">
{% for u in users %}
<tr class="hover:bg-app-surfaceHover transition-colors">
<td class="px-4 py-3 font-mono text-app-textMuted">{{ u.id }}</td>
<td class="px-4 py-3 font-bold text-app-textMain">{{ u.login }}</td>
<td class="px-4 py-3">
<span class="badge badge-neutral px-2 py-0.5">{{ u.role }}</span>
</td>
<td class="px-4 py-3 text-app-textMuted">{{ u.created_at_fmt }}</td>
<td class="px-4 py-3">
{% if u.is_active %}
<span class="badge badge-success px-2 py-0.5 flex items-center gap-1.5 w-max">
<span class="w-1.5 h-1.5 rounded-full bg-app-success"></span> Активен
</span>
{% else %}
<span class="badge badge-error px-2 py-0.5 flex items-center gap-1.5 w-max">
<span class="w-1.5 h-1.5 rounded-full bg-app-error"></span> Заблокирован
</span>
{% endif %}
</td>
<td class="px-4 py-3 text-right">
{% if user.role == "admin" and user.id != u.id %}
<div class="flex items-center justify-end gap-2">
<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 {% if u.is_active %}btn-surface{% else %}btn-primary-outline{% endif %}" type="submit">
{% if u.is_active %}Блокировать{% else %}Разблокировать{% endif %}
</button>
</form>
<form method="post" action="/users/{{ u.id }}/delete" class="m-0 inline-block" onsubmit="return confirm('Точно удалить пользователя?')">
<input type="hidden" name="csrf_token" value="{{ user.csrf_token }}">
<button class="btn btn-xs text-app-error hover:bg-app-errorBg hover:text-app-error" type="submit">
Удалить
</button>
</form>
</div>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if pagination.pages > 1 %}
<div class="p-4 border-t border-app-border flex justify-center bg-app-bg/50">
<div class="tabs p-1">
<a class="btn btn-sm btn-ghost w-10 h-10 p-0 rounded-md {% if pagination.page == 1 %}pointer-events-none opacity-50{% endif %}" href="?page=1"><i data-lucide="chevrons-left" class="w-4 h-4"></i></a>
<a class="btn btn-sm btn-ghost w-10 h-10 p-0 rounded-md {% if pagination.page == 1 %}pointer-events-none opacity-50{% endif %}" href="?page={{ pagination.page - 1 }}"><i data-lucide="chevron-left" class="w-4 h-4"></i></a>
<span class="flex items-center justify-center px-4 text-sm font-medium text-app-textMuted bg-app-bg rounded-md border border-app-border mx-1">
{{ pagination.page }} из {{ pagination.pages }}
</span>
<a class="btn btn-sm btn-ghost w-10 h-10 p-0 rounded-md {% if pagination.page >= pagination.pages %}pointer-events-none opacity-50{% endif %}" href="?page={{ pagination.page + 1 }}"><i data-lucide="chevron-right" class="w-4 h-4"></i></a>
<a class="btn btn-sm btn-ghost w-10 h-10 p-0 rounded-md {% if pagination.page >= pagination.pages %}pointer-events-none opacity-50{% endif %}" href="?page={{ pagination.pages }}"><i data-lucide="chevrons-right" class="w-4 h-4"></i></a>
</div>
</div>
{% endif %}
</div>
{% if user.role == "admin" %}
<details class="card group/details">
<summary class="p-4 flex items-center justify-between cursor-pointer select-none hover:bg-app-surfaceHover transition-colors list-none">
<div class="flex items-center gap-3">
<div class="w-8 h-8 rounded-lg bg-app-primary/20 text-app-primary flex items-center justify-center">
<i data-lucide="user-plus" class="w-5 h-5"></i>
</div>
<span class="text-lg font-bold text-white">Добавить пользователя</span>
</div>
<i data-lucide="chevron-down" class="w-5 h-5 text-app-textMuted transition-transform group-open/details:rotate-180"></i>
</summary>
<div class="p-6 border-t border-app-border bg-app-bg/30">
<form method="post" action="/users/create" class="flex flex-col md:flex-row gap-6 items-end">
<input type="hidden" name="csrf_token" value="{{ user.csrf_token }}">
<div class="w-full">
<label class="block text-xs font-bold text-app-textMuted mb-1">Логин</label>
<input name="login" class="input w-full" required>
</div>
<div class="w-full">
<label class="block text-xs font-bold text-app-textMuted mb-1">Пароль</label>
<input name="password" type="password" class="input w-full" required minlength="10" placeholder="Минимум 10 символов">
</div>
<div class="w-full md:max-w-xs">
<label class="block text-xs font-bold text-app-textMuted mb-1">Роль</label>
<select name="role" class="select w-full">
<option value="editor">Редактор (editor)</option>
<option value="viewer">Читатель (viewer)</option>
<option value="admin">Администратор (admin)</option>
</select>
</div>
<button class="btn btn-primary" type="submit">
<i data-lucide="plus" class="w-4 h-4"></i> Создать
</button>
</form>
</div>
</details>
{% endif %}
{% endblock %}
@@ -0,0 +1,45 @@
{% extends "base.html" %}
{% block body %}
<div class="panel" style="max-width:760px;margin:8vh auto 0;">
<h1>VK OAuth</h1>
{% if result %}
{% if result.ok %}
<p class="pill ok">Токен VK-постера сохранён</p>
<dl class="kv compact-kv">
<dt>Сообщество</dt><dd>{{ result.owner_id }}</dd>
<dt>User ID</dt><dd>{{ result.user_id or "—" }}</dd>
<dt>Живёт, сек</dt><dd>{{ result.expires_in or "—" }}</dd>
<dt>Истекает</dt><dd>{{ result.expires_at or "—" }}</dd>
<dt>Refresh token</dt><dd>{% if result.has_refresh_token %}OK{% else %}Не получен{% endif %}</dd>
<dt>wall.get</dt><dd>{% if result.wall_get_ok %}OK{% else %}Ошибка: {{ result.wall_get_error }}{% endif %}</dd>
<dt>Фото upload</dt>
<dd>
{% if result.photo_upload_server_ok %}
OK
{% else %}
Недоступно: {{ result.photo_upload_server_error }}
<div class="muted">VK выдаёт право photos для upload API отдельно; постер всё равно опубликует текст и существующие VK-вложения.</div>
{% endif %}
</dd>
</dl>
<p><a class="btn primary" href="/workers">Вернуться к воркерам</a></p>
{% else %}
<p class="pill bad">VK OAuth не завершился</p>
<p>{{ result.error_description or result.error or "Неизвестная ошибка" }}</p>
<p><a class="btn" href="/workers">Вернуться к воркерам</a> <a class="btn primary" href="/vk-oauth/start">Повторить</a></p>
{% endif %}
{% elif code %}
<p class="muted">VK вернул code. Новый обработчик должен обменивать его автоматически через <span class="mono">/vk-oauth/callback</span>.</p>
<label>code</label>
<textarea readonly style="min-height:110px;" onclick="this.select()">{{ code }}</textarea>
{% if state and expected_state and state != expected_state %}
<p class="pill bad">state не совпал, такой code лучше не использовать.</p>
{% endif %}
{% elif error %}
<p class="pill bad">{{ error }}</p>
<p>{{ error_description }}</p>
{% else %}
<p class="muted">VK вернул callback без данных.</p>
{% endif %}
</div>
{% endblock %}
+396
View File
@@ -0,0 +1,396 @@
{% extends "base.html" %}
{% block body %}
<div class="mb-8">
<h1 class="text-3xl font-bold tracking-tight text-white flex items-center gap-3 mb-2">
<i data-lucide="cpu" class="text-app-primary w-8 h-8"></i>
Система и Воркеры
</h1>
<div class="text-app-textMuted text-sm">Управление фоновыми процессами, категориями, расписанием и настройками AI.</div>
</div>
<!-- Workers Panel -->
<div class="card overflow-hidden mb-8">
<div class="overflow-x-auto">
<table class="w-full text-left text-sm whitespace-nowrap">
<thead class="bg-app-bg border-b border-app-border">
<tr class="text-app-textMuted font-semibold tracking-wide uppercase text-xs">
<th class="px-4 py-3">Воркер</th>
<th class="px-4 py-3">Состояние</th>
<th class="px-4 py-3">Heartbeat</th>
<th class="px-4 py-3">Статус</th>
<th class="px-4 py-3">Текущая задача</th>
<th class="px-4 py-3">Действия</th>
</tr>
</thead>
<tbody class="divide-y divide-app-border text-xs">
{% for w in workers %}
<tr class="hover:bg-app-surfaceHover transition-colors">
<td class="px-4 py-3 font-mono font-bold text-white">{{ w.name }}</td>
<td class="px-4 py-3">
{% if w.enabled %}
<span class="badge badge-success px-2 py-0.5 flex items-center gap-1.5 w-max">
<span class="w-1.5 h-1.5 rounded-full bg-app-success"></span> Включен
</span>
{% else %}
<span class="badge badge-error px-2 py-0.5 flex items-center gap-1.5 w-max">
<span class="w-1.5 h-1.5 rounded-full bg-app-error"></span> Выключен
</span>
{% endif %}
</td>
<td class="px-4 py-3 text-app-textMuted">{{ w.heartbeat_at or "—" }}</td>
<td class="px-4 py-3 max-w-xs text-app-textMain">
<div class="truncate">{{ w.status or "—" }}</div>
{% if w.meta_json and w.meta_json.error %}
<div class="mt-1 text-[10px] text-app-error whitespace-normal break-words">{{ w.meta_json.error }}</div>
{% endif %}
</td>
<td class="px-4 py-3 font-mono text-app-textMuted">{{ w.current_job_id or "—" }}</td>
<td class="px-4 py-3">
<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-surface" 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 -->
<details class="card group/details" open>
<summary class="p-4 flex items-center justify-between cursor-pointer select-none hover:bg-app-surfaceHover transition-colors border-b border-app-border list-none">
<div class="flex items-center gap-2 font-bold text-lg text-white">
<i data-lucide="tags" class="w-5 h-5 text-app-primary"></i> Категории публикаций
</div>
<i data-lucide="chevron-down" class="w-5 h-5 text-app-textMuted transition-transform group-open/details:rotate-180"></i>
</summary>
<div class="p-4 flex flex-col gap-6 bg-app-bg/30">
<div class="text-sm text-app-textMuted">
Название описывает категорию для AI, тэг используется в соцсетях.
</div>
<form method="post" action="/categories/create" class="flex flex-col gap-4 bg-app-surface p-4 rounded-xl border border-app-border">
<div class="font-bold text-sm text-white">Добавить категорию</div>
<input type="hidden" name="csrf_token" value="{{ user.csrf_token }}">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-[10px] uppercase font-bold text-app-textMuted mb-1">Название (для AI)</label>
<input name="name" class="input w-full" required>
</div>
<div>
<label class="block text-[10px] uppercase font-bold text-app-textMuted mb-1">Тэг (для хэштегов)</label>
<input name="tag" class="input w-full">
</div>
<div>
<label class="block text-[10px] uppercase font-bold text-app-textMuted mb-1">На сайте</label>
<input name="site_name" class="input w-full" required>
</div>
<div>
<label class="block text-[10px] uppercase font-bold text-app-textMuted mb-1">URL-slug</label>
<input name="site_slug" class="input w-full" pattern="[a-z0-9-]+" required>
</div>
</div>
<div class="flex items-center justify-between mt-2">
<label class="cursor-pointer flex items-center gap-2 group">
<input type="checkbox" name="site_enabled" class="checkbox" checked>
<span class="text-sm font-medium text-app-textMain group-hover:text-white transition-colors">Публиковать</span>
</label>
<button class="btn btn-primary btn-sm" type="submit">Добавить</button>
</div>
</form>
<div class="overflow-x-auto border border-app-border rounded-xl">
<table class="w-full text-left text-xs whitespace-nowrap bg-app-surface">
<thead class="bg-app-bg border-b border-app-border text-app-textMuted uppercase tracking-wider text-[10px]">
<tr>
<th class="px-3 py-2">Название / Тэг</th>
<th class="px-3 py-2">Сайт / Slug</th>
<th class="px-3 py-2 text-center">ID</th>
<th class="px-3 py-2">Статус</th>
<th class="px-3 py-2 w-16"></th>
</tr>
</thead>
<tbody class="divide-y divide-app-border">
{% for c in categories %}
<tr class="hover:bg-app-surfaceHover transition-colors">
<td class="px-3 py-2">
<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.5">
<input form="category-update-{{ c.id }}" name="name" value="{{ c.name }}" class="input input-sm w-full bg-app-bg text-white h-7">
<input form="category-update-{{ c.id }}" name="tag" value="{{ c.tag }}" class="input input-sm w-full bg-app-bg text-blue-400 font-mono h-7">
</div>
</td>
<td class="px-3 py-2">
<div class="flex flex-col gap-1.5">
<input form="category-update-{{ c.id }}" name="site_name" value="{{ c.site_name }}" class="input input-sm w-full bg-app-bg text-white h-7" required>
<input form="category-update-{{ c.id }}" name="site_slug" value="{{ c.site_slug }}" pattern="[a-z0-9-]+" class="input input-sm w-full bg-app-bg text-app-textMuted font-mono h-7" required>
</div>
</td>
<td class="px-3 py-2 text-center font-mono text-app-textMuted text-[10px]">{{ c.sort_order }}</td>
<td class="px-3 py-2">
<div class="flex flex-col gap-2">
<span class="badge {% if c.is_active %}badge-success{% else %}badge-neutral{% endif %} w-full py-0.5 text-[10px]">{% if c.is_active %}Активна{% else %}Выкл{% endif %}</span>
<label class="cursor-pointer flex items-center gap-2 bg-app-bg px-2 py-1 rounded border border-app-border">
<input form="category-update-{{ c.id }}" type="checkbox" name="site_enabled" class="checkbox w-3.5 h-3.5 rounded-sm" {% if c.site_enabled %}checked{% endif %}>
<span class="text-[10px] font-medium text-app-textMuted">Сайт</span>
</label>
</div>
</td>
<td class="px-3 py-2">
<div class="flex flex-col gap-1 items-center">
<button form="category-update-{{ c.id }}" type="submit" class="btn btn-ghost btn-icon w-7 h-7 hover:text-app-success" title="Сохранить"><i data-lucide="check" class="w-4 h-4"></i></button>
<form method="post" action="/categories/{{ c.id }}/toggle" class="m-0 w-full flex justify-center">
<input type="hidden" name="csrf_token" value="{{ user.csrf_token }}">
<button type="submit" class="btn btn-ghost btn-icon w-7 h-7" title="Вкл/Выкл"><i data-lucide="{% if c.is_active %}pause{% else %}play{% endif %}" class="w-4 h-4"></i></button>
</form>
<form method="post" action="/categories/{{ c.id }}/delete" class="m-0 w-full flex justify-center" onsubmit="return confirm('Удалить категорию?');">
<input type="hidden" name="csrf_token" value="{{ user.csrf_token }}">
<button type="submit" class="btn btn-ghost btn-icon w-7 h-7 text-app-textMuted hover:text-app-error hover:bg-app-errorBg" title="Удалить"><i data-lucide="trash-2" class="w-4 h-4"></i></button>
</form>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</details>
<div class="flex flex-col gap-8">
<!-- TG Schedule -->
<details class="card group/details" open>
<summary class="p-4 flex items-center justify-between cursor-pointer select-none hover:bg-app-surfaceHover transition-colors border-b border-app-border list-none">
<div class="flex items-center gap-2 font-bold text-lg text-white">
<i data-lucide="send" class="w-5 h-5 text-[#0088cc]"></i> Расписание TG-постера
</div>
<i data-lucide="chevron-down" class="w-5 h-5 text-app-textMuted transition-transform group-open/details:rotate-180"></i>
</summary>
<div class="p-4 flex flex-col gap-6 bg-app-bg/30">
<div class="text-xs text-app-textMuted leading-relaxed">
Время по Екб. Нужны: воркер <span class="font-mono bg-app-surface border border-app-border px-1.5 py-0.5 rounded text-[10px] text-white">tg-poster</span> и настройка <span class="font-mono bg-app-surface border border-app-border px-1.5 py-0.5 rounded text-[10px] text-white">tg_poster_enabled</span>.
</div>
<!-- Active Schedule Chips -->
<div class="flex flex-wrap gap-2">
{% for row in tg_schedule %}
{% if row.enabled %}
<div class="inline-flex items-center gap-2 px-3 py-1.5 bg-app-surface border border-app-primary/30 rounded-lg shadow-sm group">
<span class="font-mono text-sm font-bold text-white">{{ row.time[:2] }}:00</span>
<span class="text-xs text-app-textMuted border-l border-app-border pl-2">{{ row.count }} шт.</span>
<form method="post" action="/tg-poster-schedule/{{ row.id }}/delete" class="m-0 ml-1 flex items-center">
<input type="hidden" name="csrf_token" value="{{ user.csrf_token }}">
<button type="submit" class="text-app-textMuted hover:text-app-error transition-colors" title="Удалить">
<i data-lucide="x" class="w-3.5 h-3.5"></i>
</button>
</form>
</div>
{% endif %}
{% else %}
<div class="text-sm text-app-textMuted italic py-2">Расписание пусто</div>
{% endfor %}
</div>
<form method="post" action="/tg-poster-schedule/create" class="flex flex-wrap sm:flex-nowrap items-end gap-3 bg-app-surface p-4 rounded-xl border border-app-border">
<input type="hidden" name="csrf_token" value="{{ user.csrf_token }}">
<input type="hidden" name="enabled" value="on">
<div class="w-full sm:w-32">
<label class="block text-[10px] uppercase font-bold text-app-textMuted mb-1">Время (час)</label>
<select name="time" class="select select-sm w-full font-mono">
{% for h in range(24) %}
{% set hh = "%02d" % h %}
<option value="{{ hh }}:00">{{ hh }}:00</option>
{% endfor %}
</select>
</div>
<div class="w-full sm:w-24">
<label class="block text-[10px] uppercase font-bold text-app-textMuted mb-1">Кол-во</label>
<input type="number" name="count" value="1" min="1" max="20" class="input input-sm w-full text-center" required>
</div>
<button class="btn btn-primary btn-sm h-8 w-full sm:w-auto" type="submit">
<i data-lucide="plus" class="w-4 h-4"></i> Добавить
</button>
</form>
</div>
</details>
<!-- VK Schedule -->
<details class="card group/details" open>
<summary class="p-4 flex items-center justify-between cursor-pointer select-none hover:bg-app-surfaceHover transition-colors border-b border-app-border list-none">
<div class="flex items-center gap-2 font-bold text-lg text-white">
<i data-lucide="layout-template" class="w-5 h-5 text-[#4680C2]"></i> Расписание VK-постера
</div>
<i data-lucide="chevron-down" class="w-5 h-5 text-app-textMuted transition-transform group-open/details:rotate-180"></i>
</summary>
<div class="p-4 flex flex-col gap-6 bg-app-bg/30">
<div class="text-xs text-app-textMuted leading-relaxed">
Время по Екб. Нужны: воркер <span class="font-mono bg-app-surface border border-app-border px-1.5 py-0.5 rounded text-[10px] text-white">vk-poster</span> и <span class="font-mono bg-app-surface border border-app-border px-1.5 py-0.5 rounded text-[10px] text-white">vk_poster_enabled</span>.
</div>
<div><a class="btn btn-surface btn-sm border-app-border" href="/vk-oauth/start"><i data-lucide="key" class="w-3.5 h-3.5"></i> Получить VK user token</a></div>
<!-- Active Schedule Chips -->
<div class="flex flex-wrap gap-2">
{% for row in vk_schedule %}
{% if row.enabled %}
<div class="inline-flex items-center gap-2 px-3 py-1.5 bg-app-surface border border-app-primary/30 rounded-lg shadow-sm group">
<span class="font-mono text-sm font-bold text-white">{{ row.time[:2] }}:00</span>
<span class="text-xs text-app-textMuted border-l border-app-border pl-2">{{ row.count }} шт.</span>
<form method="post" action="/vk-poster-schedule/{{ row.id }}/delete" class="m-0 ml-1 flex items-center">
<input type="hidden" name="csrf_token" value="{{ user.csrf_token }}">
<button type="submit" class="text-app-textMuted hover:text-app-error transition-colors" title="Удалить">
<i data-lucide="x" class="w-3.5 h-3.5"></i>
</button>
</form>
</div>
{% endif %}
{% else %}
<div class="text-sm text-app-textMuted italic py-2">Расписание пусто</div>
{% endfor %}
</div>
<form method="post" action="/vk-poster-schedule/create" class="flex flex-wrap sm:flex-nowrap items-end gap-3 bg-app-surface p-4 rounded-xl border border-app-border">
<input type="hidden" name="csrf_token" value="{{ user.csrf_token }}">
<input type="hidden" name="enabled" value="on">
<div class="w-full sm:w-32">
<label class="block text-[10px] uppercase font-bold text-app-textMuted mb-1">Время (час)</label>
<select name="time" class="select select-sm w-full font-mono">
{% for h in range(24) %}
{% set hh = "%02d" % h %}
<option value="{{ hh }}:00">{{ hh }}:00</option>
{% endfor %}
</select>
</div>
<div class="w-full sm:w-24">
<label class="block text-[10px] uppercase font-bold text-app-textMuted mb-1">Кол-во</label>
<input type="number" name="count" value="1" min="1" max="20" class="input input-sm w-full text-center" required>
</div>
<button class="btn btn-primary btn-sm h-8 w-full sm:w-auto" type="submit">
<i data-lucide="plus" class="w-4 h-4"></i> Добавить
</button>
</form>
</div>
</details>
</div>
</div>
<div class="flex items-center gap-4 mb-6 mt-12">
<div class="h-px bg-app-border flex-1"></div>
<h2 class="text-2xl font-bold text-white flex items-center gap-3">
<i data-lucide="settings" class="w-6 h-6 text-app-primary"></i> Глобальные настройки
</h2>
<div class="h-px bg-app-border flex-1"></div>
</div>
<div class="flex flex-col gap-6">
{% for category, rows in settings|groupby("category") %}
<details class="card group/details" {% if category == "AI Qualifier" or loop.first %}open{% endif %}>
<summary class="p-4 flex items-center justify-between cursor-pointer select-none hover:bg-app-surfaceHover transition-colors border-b border-app-border list-none">
<div class="text-lg font-bold text-white">{{ category_titles.get(category, category) }}</div>
<i data-lucide="chevron-down" class="w-5 h-5 text-app-textMuted transition-transform group-open/details:rotate-180"></i>
</summary>
<div class="p-6 bg-app-bg/30">
<div class="flex flex-col gap-8">
{% for s in rows %}
<div class="flex flex-col md:flex-row gap-6 p-5 bg-app-surface border border-app-border rounded-xl shadow-sm">
<!-- Description Sidebar -->
<div class="w-full md:w-1/3 flex flex-col gap-1 border-b md:border-b-0 md:border-r border-app-border pb-4 md:pb-0 pr-0 md:pr-4">
<div class="font-mono font-bold text-sm text-app-primary">{{ s.key }}</div>
<div class="text-sm font-semibold text-white mt-1">{{ s.title }}</div>
<div class="text-xs text-app-textMuted mt-2 leading-relaxed">{{ s.description }}</div>
</div>
<!-- Control Panel -->
<div class="flex-1">
<form method="post" action="/settings/save" class="flex flex-col items-start gap-3 w-full h-full justify-center">
<input type="hidden" name="csrf_token" value="{{ user.csrf_token }}">
<input type="hidden" name="key" value="{{ s.key }}">
<div class="flex gap-3 w-full items-start">
<div class="flex-1">
{% if s.value_type == "bool" %}
<select name="value" class="select w-32">
<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" class="select max-w-sm w-full">
{% 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 == "site_poster_provider" %}
<select name="value" class="select max-w-sm w-full">
{% for p in site_poster_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 max-w-sm w-full">
{% 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 max-w-sm w-full">
{% 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 w-full max-w-md" autocomplete="off" spellcheck="false" placeholder="Секретный ключ">
{% elif s.value_type == "text" %}
<div class="w-full flex flex-col gap-3">
{% if prompt_hints and s.key in prompt_hints %}
<div class="bg-app-primary/10 border border-app-primary/30 text-app-textMain text-xs p-4 rounded-lg flex flex-col gap-2">
<strong class="text-app-primary font-bold flex items-center gap-2"><i data-lucide="info" class="w-4 h-4"></i> {{ prompt_hints[s.key].title }}</strong>
<div class="leading-relaxed">{{ prompt_hints[s.key].body }}</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 mt-2 pt-2 border-t border-app-primary/20">
<div><span class="font-bold text-app-textMain uppercase tracking-wider text-[10px]">Как безопасно менять:</span><br>{{ prompt_hints[s.key].safe }}</div>
<div><span class="font-bold text-app-textMain uppercase tracking-wider text-[10px]">На вход:</span><br>{{ prompt_hints[s.key].input }}</div>
</div>
</div>
{% endif %}
<textarea name="value" class="textarea font-mono text-xs w-full leading-relaxed p-4 bg-app-bg" rows="12">{{ s.value_json }}</textarea>
</div>
{% elif s.value_type == "json" %}
<textarea name="value" class="textarea font-mono text-xs w-full max-w-2xl leading-relaxed p-4 bg-app-bg" rows="5" spellcheck="false">{{ s.value_json | tojson }}</textarea>
{% else %}
<input name="value" value="{{ s.value_json }}" class="input w-full max-w-md">
{% endif %}
</div>
<button class="btn btn-primary" type="submit">
<i data-lucide="save" class="w-4 h-4"></i> Сохранить
</button>
</div>
</form>
</div>
</div>
{% endfor %}
</div>
</div>
</details>
{% endfor %}
</div>
{% endblock %}