<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>KZ Channels — Dashboard</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #0f1117;
color: #e4e4e7;
min-height: 100vh;
}
.header {
background: linear-gradient(135deg, #1a1b2e 0%, #16213e 100%);
border-bottom: 1px solid #2a2d3e;
padding: 24px 32px;
display: flex;
justify-content: space-between;
align-items: center;
}
.header h1 { font-size: 24px; font-weight: 700; }
.header h1 span { color: #00bcd4; }
.header .status { display: flex; align-items: center; gap: 8px; font-size: 14px; color: #9ca3af; }
.header .status .dot { width: 8px; height: 8px; border-radius: 50%; background: #22c55e; animation: pulse 2s infinite; }
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }
.container { max-width: 1200px; margin: 0 auto; padding: 32px 24px; }
/* KPI Cards */
.kpi-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
margin-bottom: 32px;
}
.kpi-card {
background: #1a1b2e;
border: 1px solid #2a2d3e;
border-radius: 12px;
padding: 20px;
}
.kpi-card .label { font-size: 12px; color: #9ca3af; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 8px; }
.kpi-card .value { font-size: 28px; font-weight: 700; color: #fff; }
.kpi-card .sub { font-size: 12px; color: #6b7280; margin-top: 4px; }
/* Channels */
.section-title { font-size: 18px; font-weight: 600; margin-bottom: 16px; color: #fff; }
.channels-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(340px, 1fr));
gap: 16px;
margin-bottom: 32px;
}
.channel-card {
background: #1a1b2e;
border: 1px solid #2a2d3e;
border-radius: 12px;
padding: 20px;
transition: border-color 0.2s;
}
.channel-card:hover { border-color: #00bcd4; }
.channel-card .ch-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
.channel-card .ch-name { font-size: 16px; font-weight: 600; }
.channel-card .ch-type {
font-size: 11px;
padding: 3px 8px;
border-radius: 6px;
font-weight: 500;
}
.type-news { background: #1e3a5f; color: #60a5fa; }
.type-humor { background: #3b2f1e; color: #fbbf24; }
.type-earnings { background: #1e3b2f; color: #34d399; }
.type-sports { background: #3b1e2f; color: #f472b6; }
.channel-card .ch-stats { display: flex; gap: 20px; margin-bottom: 12px; }
.channel-card .ch-stat { }
.channel-card .ch-stat .num { font-size: 18px; font-weight: 600; color: #fff; }
.channel-card .ch-stat .lbl { font-size: 11px; color: #9ca3af; }
/* Recent posts */
.posts-channel { margin-bottom: 16px; }
.posts-channel-name { font-size: 13px; font-weight: 600; color: #00bcd4; margin-bottom: 8px; }
.post-preview {
background: #1a1b2e; border: 1px solid #2a2d3e; border-radius: 10px;
padding: 12px 16px; margin-bottom: 6px; font-size: 13px; line-height: 1.5;
max-height: 80px; overflow: hidden; position: relative;
}
.post-preview .post-fade {
position: absolute; bottom: 0; left: 0; right: 0; height: 30px;
background: linear-gradient(transparent, #1a1b2e);
}
.post-preview .post-time { font-size: 11px; color: #6b7280; margin-bottom: 4px; }
.post-preview .post-type-badge {
font-size: 10px; padding: 1px 6px; border-radius: 4px;
background: #2a2d3e; color: #9ca3af; margin-left: 6px;
}
.posts-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(340px, 1fr));
gap: 16px;
}
/* Error log */
.error-item {
display: flex; gap: 12px; align-items: baseline;
padding: 8px 14px; background: #1a1b2e; border: 1px solid #2a2d3e;
border-left: 3px solid #ef4444; border-radius: 8px; margin-bottom: 6px; font-size: 13px;
}
.error-item .err-time { color: #6b7280; font-size: 11px; white-space: nowrap; }
.error-item .err-type {
font-size: 10px; padding: 1px 6px; border-radius: 4px;
background: #450a0a; color: #f87171; white-space: nowrap;
}
.error-item .err-ch { color: #00bcd4; font-size: 11px; white-space: nowrap; }
.error-item .err-msg { color: #e4e4e7; flex: 1; }
.no-errors { text-align: center; padding: 16px; color: #22c55e; font-size: 14px;
background: #1a1b2e; border: 1px solid #2a2d3e; border-radius: 12px; }
.channel-card .ch-progress { margin-top: 8px; }
.channel-card .ch-progress .bar-bg {
height: 6px; background: #2a2d3e; border-radius: 3px; overflow: hidden;
}
.channel-card .ch-progress .bar-fill {
height: 100%; border-radius: 3px; transition: width 0.3s;
}
.channel-card .ch-progress .bar-label { font-size: 11px; color: #9ca3af; margin-top: 3px; }
.channel-card .ch-last { font-size: 11px; color: #6b7280; margin-top: 6px; }
.channel-card .ch-last .ago { color: #9ca3af; font-weight: 500; }
.channel-card .ch-last .stale { color: #f87171; }
.channel-card .ch-link {
display: inline-block;
margin-top: 8px;
padding: 6px 16px;
background: #00bcd4;
color: #000;
text-decoration: none;
border-radius: 8px;
font-size: 13px;
font-weight: 600;
transition: background 0.2s;
}
.channel-card .ch-link:hover { background: #00acc1; }
/* Links section */
.links-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 16px;
margin-bottom: 32px;
}
.link-card {
background: #1a1b2e;
border: 1px solid #2a2d3e;
border-radius: 12px;
padding: 16px 20px;
display: flex;
justify-content: space-between;
align-items: center;
}
.link-card .lc-info { }
.link-card .lc-title { font-size: 14px; font-weight: 600; }
.link-card .lc-desc { font-size: 12px; color: #6b7280; }
.link-card a {
padding: 6px 14px;
background: #2a2d3e;
color: #e4e4e7;
text-decoration: none;
border-radius: 8px;
font-size: 13px;
transition: background 0.2s;
}
.link-card a:hover { background: #3a3d4e; }
/* Schedule */
.schedule-table {
width: 100%;
background: #1a1b2e;
border: 1px solid #2a2d3e;
border-radius: 12px;
overflow: hidden;
margin-bottom: 32px;
}
.schedule-table table { width: 100%; border-collapse: collapse; }
.schedule-table th {
text-align: left;
padding: 12px 16px;
background: #22243a;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 1px;
color: #9ca3af;
}
.schedule-table td { padding: 10px 16px; border-top: 1px solid #2a2d3e; font-size: 14px; }
.schedule-table tr:hover td { background: #1e1f32; }
.footer { text-align: center; padding: 32px; color: #4b5563; font-size: 12px; }
/* Mobile */
@media (max-width: 600px) {
.header { padding: 16px; flex-direction: column; gap: 8px; }
.header h1 { font-size: 18px; }
.container { padding: 16px 12px; }
.kpi-grid { grid-template-columns: repeat(2, 1fr); gap: 8px; }
.kpi-card { padding: 14px; }
.kpi-card .value { font-size: 22px; }
.channels-grid { grid-template-columns: 1fr; }
.posts-grid { grid-template-columns: 1fr; }
.links-grid { grid-template-columns: 1fr; }
.channel-card .ch-stats { flex-wrap: wrap; gap: 12px; }
.modal { width: 95%; max-width: none; padding: 16px; }
.form-row { flex-direction: column; }
.form-row select { flex: 1; }
.schedule-table { font-size: 12px; }
.schedule-table th, .schedule-table td { padding: 8px 10px; }
}
/* Refresh */
#last-update { font-size: 12px; color: #6b7280; }
/* Modal */
.modal-overlay {
display: none;
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.7);
z-index: 1000;
justify-content: center; align-items: center;
}
.modal-overlay.active { display: flex; }
.modal {
background: #1a1b2e;
border: 1px solid #2a2d3e;
border-radius: 16px;
width: 90%; max-width: 600px; max-height: 80vh;
overflow-y: auto;
padding: 24px;
}
.modal-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
.modal-header h2 { font-size: 18px; font-weight: 600; }
.modal-close {
background: none; border: none; color: #9ca3af; font-size: 24px;
cursor: pointer; padding: 4px 8px; border-radius: 6px;
}
.modal-close:hover { background: #2a2d3e; color: #fff; }
/* Source list */
.source-item {
display: flex; justify-content: space-between; align-items: center;
padding: 12px 14px; border: 1px solid #2a2d3e; border-radius: 10px;
margin-bottom: 8px;
}
.source-item .si-info { flex: 1; }
.source-item .si-name { font-size: 14px; font-weight: 500; }
.source-item .si-url { font-size: 11px; color: #6b7280; word-break: break-all; }
.source-item .si-badge {
font-size: 10px; padding: 2px 8px; border-radius: 6px;
font-weight: 600; margin-left: 8px; white-space: nowrap;
}
.badge-approved { background: #14532d; color: #4ade80; }
.badge-pending { background: #422006; color: #fbbf24; }
.badge-rejected { background: #450a0a; color: #f87171; }
.source-item .si-actions { display: flex; gap: 6px; margin-left: 12px; }
.si-btn {
padding: 4px 10px; border: none; border-radius: 6px;
font-size: 12px; cursor: pointer; font-weight: 500;
}
.si-btn-approve { background: #166534; color: #4ade80; }
.si-btn-approve:hover { background: #15803d; }
.si-btn-reject { background: #7f1d1d; color: #fca5a5; }
.si-btn-reject:hover { background: #991b1b; }
.si-btn-delete { background: #2a2d3e; color: #9ca3af; }
.si-btn-delete:hover { background: #3a3d4e; color: #fff; }
/* Add source form */
.add-source-form {
margin-top: 16px; padding: 16px;
background: #22243a; border-radius: 10px;
}
.add-source-form h3 { font-size: 14px; margin-bottom: 12px; color: #9ca3af; }
.form-row { display: flex; gap: 8px; margin-bottom: 8px; }
.form-row input, .form-row select {
flex: 1; padding: 8px 12px;
background: #1a1b2e; border: 1px solid #2a2d3e; border-radius: 8px;
color: #e4e4e7; font-size: 13px; outline: none;
}
.form-row input:focus, .form-row select:focus { border-color: #00bcd4; }
.form-row select { flex: 0.4; }
.btn-add {
padding: 8px 20px; background: #00bcd4; color: #000;
border: none; border-radius: 8px; font-size: 13px;
font-weight: 600; cursor: pointer;
}
.btn-add:hover { background: #00acc1; }
.btn-add:disabled { opacity: 0.5; cursor: not-allowed; }
.no-sources { text-align: center; padding: 20px; color: #6b7280; font-size: 14px; }
/* Queue items */
.queue-item {
background: #1a1b2e;
border: 1px solid #2a2d3e;
border-radius: 12px;
padding: 16px 20px;
margin-bottom: 12px;
}
.queue-item .qi-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
.queue-item .qi-channel { font-size: 12px; color: #00bcd4; font-weight: 500; }
.queue-item .qi-source { font-size: 11px; color: #6b7280; }
.queue-item .qi-content {
font-size: 13px; line-height: 1.5;
background: #22243a; border-radius: 8px;
padding: 12px; margin-bottom: 12px;
max-height: 200px; overflow-y: auto;
white-space: pre-wrap;
}
.queue-item .qi-actions { display: flex; gap: 8px; }
.qi-btn {
padding: 6px 16px; border: none; border-radius: 8px;
font-size: 13px; cursor: pointer; font-weight: 600;
}
.qi-btn-approve { background: #166534; color: #4ade80; }
.qi-btn-approve:hover { background: #15803d; }
.qi-btn-reject { background: #7f1d1d; color: #fca5a5; }
.qi-btn-reject:hover { background: #991b1b; }
.qi-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.no-queue { text-align: center; padding: 24px; color: #4b5563; font-size: 14px;
background: #1a1b2e; border: 1px solid #2a2d3e; border-radius: 12px; }
</style>
</head>
<body>
<div class="header">
<h1>🇰🇿 <span>KZ Channels</span> Dashboard</h1>
<div class="status">
<div class="dot"></div>
Bot Online
<span id="last-update"></span>
</div>
</div>
<div class="container">
<!-- KPIs -->
<div class="kpi-grid" id="kpi-grid">
<div class="kpi-card">
<div class="label">Подписчиков</div>
<div class="value" id="kpi-subs-total">—</div>
<div class="sub">по всем каналам</div>
</div>
<div class="kpi-card">
<div class="label">Постов сегодня</div>
<div class="value" id="kpi-posts-today">—</div>
<div class="sub">по всем каналам</div>
</div>
<div class="kpi-card">
<div class="label">Реклама сегодня</div>
<div class="value" id="kpi-ads-today">—</div>
<div class="sub">Pin-Up нативки</div>
</div>
<div class="kpi-card">
<div class="label">Постов за 7 дней</div>
<div class="value" id="kpi-posts-week">—</div>
<div class="sub">всего</div>
</div>
<div class="kpi-card" id="kpi-queue-card" style="cursor:pointer" onclick="document.getElementById('queue-section').scrollIntoView({behavior:'smooth'})">
<div class="label">На модерации</div>
<div class="value" id="kpi-queue">0</div>
<div class="sub">постов ждут одобрения</div>
</div>
</div>
<!-- Channels -->
<div class="section-title">Каналы</div>
<div class="channels-grid" id="channels-grid">
<!-- filled by JS -->
</div>
<!-- Recent Posts -->
<div class="section-title">Последние посты</div>
<div id="recent-posts" style="margin-bottom:32px"></div>
<!-- Ad Tracker -->
<div class="section-title">💰 Реклама (Pin-Up)</div>
<div id="ad-tracker" style="margin-bottom:32px">
<div class="kpi-grid" id="ad-kpis"></div>
<div id="ad-ab" style="margin-top:12px"></div>
</div>
<!-- Links -->
<div class="section-title">Полезные ссылки</div>
<div class="links-grid">
<div class="link-card">
<div class="lc-info">
<div class="lc-title">Pin-Up Partners</div>
<div class="lc-desc">CPA кабинет, статистика, офферы</div>
</div>
<a href="https://pinuppartner.com" target="_blank">Открыть</a>
</div>
<div class="link-card">
<div class="lc-info">
<div class="lc-title">TGStat Kazakhstan</div>
<div class="lc-desc">Аналитика каналов KZ</div>
</div>
<a href="https://kaz.tgstat.com" target="_blank">Открыть</a>
</div>
<div class="link-card">
<div class="lc-info">
<div class="lc-title">OpenRouter</div>
<div class="lc-desc">AI API для контента</div>
</div>
<a href="https://openrouter.ai" target="_blank">Открыть</a>
</div>
<div class="link-card">
<div class="lc-info">
<div class="lc-title">Telega.in</div>
<div class="lc-desc">Биржа рекламы в TG</div>
</div>
<a href="https://telega.in" target="_blank">Открыть</a>
</div>
</div>
<!-- Moderation Queue -->
<div class="section-title" id="queue-section">Модерация постов <span id="queue-badge" style="background:#422006;color:#fbbf24;padding:2px 8px;border-radius:6px;font-size:12px;margin-left:8px;display:none">0</span></div>
<div id="queue-list" style="margin-bottom:32px"></div>
<!-- Error Log -->
<div class="section-title">Ошибки <span id="errors-badge" style="background:#450a0a;color:#f87171;padding:2px 8px;border-radius:6px;font-size:12px;margin-left:8px;display:none">0</span></div>
<div id="errors-list" style="margin-bottom:32px"></div>
<!-- Schedule -->
<div class="section-title">Расписание постинга (Астана UTC+5)</div>
<div class="schedule-table">
<table>
<thead>
<tr>
<th>Время</th>
<th>Тип</th>
<th>Описание</th>
</tr>
</thead>
<tbody>
<tr><td>08:15</td><td>Дайджест</td><td>Утренние новости / контент дня</td></tr>
<tr><td>10:30</td><td>Контент</td><td>Тематический пост</td></tr>
<tr><td>12:15</td><td>Контент</td><td>Обеденный пост</td></tr>
<tr><td>14:30</td><td>Контент</td><td>Дневной пост</td></tr>
<tr><td>16:00</td><td>Контент / Реклама</td><td>Конец рабочего дня — высокий CTR</td></tr>
<tr><td>18:30</td><td>Контент / Реклама</td><td>Вечер — лучшее для офферов</td></tr>
<tr><td>21:15</td><td>Интерактив</td><td>Итоги дня, опросы</td></tr>
</tbody>
</table>
</div>
</div>
<!-- Sources Modal -->
<div class="modal-overlay" id="sources-modal">
<div class="modal">
<div class="modal-header">
<h2 id="modal-title">Источники</h2>
<button class="modal-close" onclick="closeModal()">×</button>
</div>
<div id="sources-list"></div>
<div style="margin-bottom:12px"><button class="si-btn" style="background:#1e3a5f;color:#60a5fa" onclick="checkSourceHealth()">🔍 Проверить доступность</button> <span id="health-status" style="font-size:12px;color:#6b7280"></span></div>
<div class="add-source-form">
<h3>Добавить источник</h3>
<div class="form-row">
<input type="text" id="new-src-name" placeholder="Название (напр. Tengrinews)">
<select id="new-src-type">
<option value="rss">RSS</option>
<option value="scrape">Scrape</option>
</select>
</div>
<div class="form-row">
<input type="text" id="new-src-url" placeholder="URL (напр. https://site.kz/rss)">
<button class="btn-add" id="btn-add-source" onclick="addSource()">Добавить</button>
</div>
</div>
</div>
</div>
<!-- Manual Post Modal -->
<div class="modal-overlay" id="post-modal">
<div class="modal">
<div class="modal-header">
<h2 id="post-modal-title">Опубликовать</h2>
<button class="modal-close" onclick="closePostModal()">×</button>
</div>
<div style="margin-bottom:12px;font-size:12px;color:#6b7280">HTML теги: <b>жирный</b>, <i>курсив</i>, <a href="...">ссылка</a></div>
<textarea id="post-text" rows="8" style="width:100%;padding:12px;background:#22243a;border:1px solid #2a2d3e;border-radius:8px;color:#e4e4e7;font-size:14px;resize:vertical;outline:none;font-family:inherit" placeholder="Текст поста..."></textarea>
<input type="text" id="post-image" style="width:100%;padding:10px 12px;background:#22243a;border:1px solid #2a2d3e;border-radius:8px;color:#e4e4e7;font-size:13px;outline:none;margin-top:8px" placeholder="URL картинки (необязательно)">
<div id="post-image-preview" style="margin-top:8px;display:none"><img style="max-height:120px;border-radius:8px;border:1px solid #2a2d3e"></div>
<div style="display:flex;justify-content:flex-end;gap:8px;margin-top:12px">
<button class="si-btn si-btn-delete" onclick="closePostModal()">Отмена</button>
<button class="btn-add" id="btn-send-post" onclick="sendManualPost()">Отправить</button>
</div>
<div id="post-result" style="margin-top:12px;font-size:13px;display:none"></div>
</div>
</div>
<div class="footer">KZ Channels Dashboard v0.3 | Bot: @kz_channels_bot</div>
<script>
const CHANNELS = [
{ id: 'almaty-news', name: 'Алматы Сегодня', username: 'almaty_segodnya1', type: 'news', typeLabel: 'Новости' },
{ id: 'astana-news', name: 'Астана Сегодня', username: 'astana_segodnya', type: 'news', typeLabel: 'Новости' },
{ id: 'typical-kz', name: 'Типичный Казахстан', username: 'typical_kz1', type: 'humor', typeLabel: 'Юмор' },
{ id: 'dengi-kz', name: 'Деньги KZ', username: 'dengi_kz1', type: 'earnings', typeLabel: 'Заработок' },
{ id: 'kz-champions', name: 'KZ Champions', username: 'kz_champions', type: 'sports', typeLabel: 'Спорт' }
];
const TOTAL_SLOTS = 7;
function renderProgress(todayPosts) {
const pct = Math.min(100, Math.round((todayPosts / TOTAL_SLOTS) * 100));
const color = pct >= 100 ? '#22c55e' : pct >= 50 ? '#eab308' : '#ef4444';
return `<div class="ch-progress">
<div class="bar-bg"><div class="bar-fill" style="width:${pct}%;background:${color}"></div></div>
<div class="bar-label">${todayPosts}/${TOTAL_SLOTS} слотов (${pct}%)</div>
</div>`;
}
function formatLastPost(utcStr) {
if (!utcStr) return 'Ещё не постил';
const postDate = new Date(utcStr + 'Z');
const now = new Date();
const diffMin = Math.floor((now - postDate) / 60000);
if (diffMin < 1) return 'Последний пост: <span class="ago">только что</span>';
if (diffMin < 60) return `Последний пост: <span class="ago">${diffMin} мин назад</span>`;
const diffH = Math.floor(diffMin / 60);
if (diffH < 24) return `Последний пост: <span class="ago">${diffH}ч назад</span>`;
const diffD = Math.floor(diffH / 24);
const cls = diffD >= 2 ? 'stale' : 'ago';
return `Последний пост: <span class="${cls}">${diffD}д назад</span>`;
}
function renderChannels(stats) {
const grid = document.getElementById('channels-grid');
grid.innerHTML = CHANNELS.map(ch => {
const s = stats?.[ch.id] || { today: 0, ads: 0, week: 0 };
return `
<div class="channel-card">
<div class="ch-header">
<div class="ch-name">${ch.name}</div>
<span class="ch-type type-${ch.type}">${ch.typeLabel}</span>
</div>
<div class="ch-stats">
<div class="ch-stat"><div class="num">${s.subscribers || '—'}</div><div class="lbl">подписчиков</div></div>
<div class="ch-stat"><div class="num">${s.today}</div><div class="lbl">сегодня</div></div>
<div class="ch-stat"><div class="num">${s.ads}</div><div class="lbl">реклама</div></div>
<div class="ch-stat"><div class="num">${s.week}</div><div class="lbl">за 7 дней</div></div>
</div>
${renderProgress(s.today)}
<div class="ch-last">${formatLastPost(s.lastPost)}</div>
<a class="ch-link" href="https://t.me/${ch.username}" target="_blank">Открыть канал</a>
<button class="ch-link" style="background:#2a2d3e;color:#e4e4e7;margin-left:8px" onclick="openSources('${ch.id}','${ch.name}')">Источники</button>
<button class="ch-link" style="background:#166534;color:#4ade80;margin-left:8px" onclick="openPostModal('${ch.id}','${ch.name}')">✏️ Пост</button>
</div>
`;
}).join('');
}
async function fetchStats() {
try {
const res = await fetch('/api/stats');
if (!res.ok) throw new Error('API error');
const data = await res.json();
// KPIs
let totalToday = 0, totalAds = 0, totalWeek = 0, totalSubs = 0;
const stats = {};
for (const ch of CHANNELS) {
const s = data[ch.id] || { today: 0, ads: 0, week: 0, subscribers: 0 };
stats[ch.id] = s;
totalToday += s.today;
totalAds += s.ads;
totalWeek += s.week;
totalSubs += s.subscribers || 0;
}
document.getElementById('kpi-subs-total').textContent = totalSubs;
document.getElementById('kpi-posts-today').textContent = totalToday;
document.getElementById('kpi-ads-today').textContent = totalAds;
document.getElementById('kpi-posts-week').textContent = totalWeek;
renderChannels(stats);
document.getElementById('last-update').textContent =
`Обновлено: ${new Date().toLocaleTimeString('ru')}`;
} catch (err) {
console.error('Stats fetch error:', err);
renderChannels(null);
}
}
// --- Sources Modal ---
let currentChannelId = null;
function openSources(channelId, channelName) {
currentChannelId = channelId;
document.getElementById('modal-title').textContent = `Источники — ${channelName}`;
document.getElementById('sources-modal').classList.add('active');
document.getElementById('new-src-name').value = '';
document.getElementById('new-src-url').value = '';
loadSources();
}
function closeModal() {
document.getElementById('sources-modal').classList.remove('active');
currentChannelId = null;
}
// Close on overlay click
document.getElementById('sources-modal').addEventListener('click', (e) => {
if (e.target.classList.contains('modal-overlay')) closeModal();
});
async function loadSources() {
const list = document.getElementById('sources-list');
try {
const res = await fetch(`/api/sources?channel=${currentChannelId}`);
const { data } = await res.json();
if (!data || data.length === 0) {
list.innerHTML = '<div class="no-sources">Нет источников</div>';
return;
}
list.innerHTML = data.map(s => {
const badgeClass = s.status === 'approved' ? 'badge-approved' : s.status === 'pending' ? 'badge-pending' : 'badge-rejected';
const statusLabel = s.status === 'approved' ? 'Активен' : s.status === 'pending' ? 'На модерации' : 'Отклонён';
let actions = '';
if (s.status === 'pending') {
actions = `
<button class="si-btn si-btn-approve" onclick="approveSource(${s.id})">Одобрить</button>
<button class="si-btn si-btn-reject" onclick="rejectSource(${s.id})">Отклонить</button>
`;
} else if (s.status === 'rejected') {
actions = `
<button class="si-btn si-btn-approve" onclick="approveSource(${s.id})">Одобрить</button>
<button class="si-btn si-btn-delete" onclick="deleteSource(${s.id})">Удалить</button>
`;
} else {
actions = `<button class="si-btn si-btn-delete" onclick="deleteSource(${s.id})">Удалить</button>`;
}
return `
<div class="source-item">
<div class="si-info">
<div class="si-name">${s.name} <span class="si-badge ${badgeClass}">${statusLabel}</span></div>
<div class="si-url">${s.url}</div>
</div>
<div class="si-actions">${actions}</div>
</div>
`;
}).join('');
} catch (err) {
list.innerHTML = '<div class="no-sources">Ошибка загрузки</div>';
}
}
async function checkSourceHealth() {
const statusEl = document.getElementById('health-status');
statusEl.textContent = 'Проверяю...';
statusEl.style.color = '#fbbf24';
try {
const res = await fetch('/api/sources/health');
const { data } = await res.json();
const channelSources = data[currentChannelId] || [];
// Update source items with health indicators
const items = document.querySelectorAll('.source-item .si-name');
items.forEach(el => {
const name = el.textContent.split(' ')[0].trim();
const src = channelSources.find(s => el.textContent.includes(s.name));
if (!src) return;
// Remove old indicator
const old = el.querySelector('.health-dot');
if (old) old.remove();
const dot = document.createElement('span');
dot.className = 'health-dot';
dot.style.cssText = `display:inline-block;width:8px;height:8px;border-radius:50%;margin-left:6px;vertical-align:middle;`;
if (src.status === 'ok') {
dot.style.background = '#22c55e';
dot.title = `OK (${src.ms}ms)`;
} else if (src.status === 'skip') {
dot.style.background = '#6b7280';
dot.title = 'Scrape (не проверяется)';
} else {
dot.style.background = '#ef4444';
dot.title = `Ошибка: ${src.error}`;
}
el.appendChild(dot);
});
const ok = channelSources.filter(s => s.status === 'ok').length;
const total = channelSources.filter(s => s.type !== 'scrape' || s.status !== 'skip').length;
statusEl.textContent = `${ok}/${channelSources.length} доступны`;
statusEl.style.color = ok === total ? '#22c55e' : '#ef4444';
} catch {
statusEl.textContent = 'Ошибка проверки';
statusEl.style.color = '#ef4444';
}
}
async function addSource() {
const name = document.getElementById('new-src-name').value.trim();
const url = document.getElementById('new-src-url').value.trim();
const type = document.getElementById('new-src-type').value;
if (!name || !url) return;
const btn = document.getElementById('btn-add-source');
btn.disabled = true;
try {
await fetch('/api/sources', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ channelId: currentChannelId, name, url, type })
});
document.getElementById('new-src-name').value = '';
document.getElementById('new-src-url').value = '';
loadSources();
} catch (err) {
alert('Ошибка: ' + err.message);
}
btn.disabled = false;
}
async function approveSource(id) {
await fetch(`/api/sources/${id}/approve`, { method: 'PATCH' });
loadSources();
}
async function rejectSource(id) {
await fetch(`/api/sources/${id}/reject`, { method: 'PATCH' });
loadSources();
}
async function deleteSource(id) {
if (!confirm('Удалить источник?')) return;
await fetch(`/api/sources/${id}`, { method: 'DELETE' });
loadSources();
}
// --- Moderation Queue ---
const channelNames = {};
CHANNELS.forEach(ch => { channelNames[ch.id] = ch.name; });
async function loadQueue() {
const list = document.getElementById('queue-list');
const badge = document.getElementById('queue-badge');
try {
const res = await fetch('/api/queue');
const { data, count } = await res.json();
// Badge
if (count > 0) {
badge.textContent = count;
badge.style.display = 'inline';
document.getElementById('kpi-queue-card').style.borderColor = '#fbbf24';
} else {
badge.style.display = 'none';
document.getElementById('kpi-queue-card').style.borderColor = '#2a2d3e';
}
document.getElementById('kpi-queue').textContent = count;
if (!data || data.length === 0) {
list.innerHTML = '<div class="no-queue">Нет постов на модерации</div>';
return;
}
list.innerHTML = data.map(item => {
const chName = channelNames[item.channel_id] || item.channel_id;
const srcName = item.source_name || 'Неизвестный источник';
// Очистить HTML теги для превью
const preview = item.content.replace(/<[^>]+>/g, '').substring(0, 300);
return `
<div class="queue-item" id="qi-${item.id}">
<div class="qi-header">
<span class="qi-channel">${chName}</span>
<span class="qi-source">Источник: ${srcName}</span>
</div>
<div class="qi-content">${item.content}</div>
<div class="qi-actions">
<button class="qi-btn qi-btn-approve" onclick="approvePost(${item.id}, this)">Одобрить и отправить</button>
<button class="qi-btn qi-btn-reject" onclick="rejectPost(${item.id}, this)">Отклонить</button>
</div>
</div>
`;
}).join('');
} catch (err) {
list.innerHTML = '<div class="no-queue">Ошибка загрузки очереди</div>';
}
}
async function approvePost(id, btn) {
btn.disabled = true;
btn.textContent = 'Отправка...';
try {
const res = await fetch(`/api/queue/${id}/approve`, { method: 'PATCH' });
const result = await res.json();
if (result.success) {
document.getElementById(`qi-${id}`).style.display = 'none';
loadQueue();
fetchStats();
} else {
alert('Ошибка: ' + (result.error || 'unknown'));
btn.disabled = false;
btn.textContent = 'Одобрить и отправить';
}
} catch (err) {
alert('Ошибка: ' + err.message);
btn.disabled = false;
btn.textContent = 'Одобрить и отправить';
}
}
async function rejectPost(id, btn) {
btn.disabled = true;
try {
await fetch(`/api/queue/${id}/reject`, { method: 'PATCH' });
document.getElementById(`qi-${id}`).style.display = 'none';
loadQueue();
} catch (err) {
alert('Ошибка: ' + err.message);
btn.disabled = false;
}
}
// --- Manual Post ---
let postChannelId = null;
function openPostModal(channelId, channelName) {
postChannelId = channelId;
document.getElementById('post-modal-title').textContent = `Опубликовать → ${channelName}`;
document.getElementById('post-text').value = '';
document.getElementById('post-image').value = '';
document.getElementById('post-image-preview').style.display = 'none';
document.getElementById('post-result').style.display = 'none';
document.getElementById('btn-send-post').disabled = false;
document.getElementById('post-modal').classList.add('active');
document.getElementById('post-text').focus();
}
function closePostModal() {
document.getElementById('post-modal').classList.remove('active');
postChannelId = null;
}
document.getElementById('post-modal').addEventListener('click', (e) => {
if (e.target.classList.contains('modal-overlay')) closePostModal();
});
// Превью картинки
document.getElementById('post-image').addEventListener('input', (e) => {
const url = e.target.value.trim();
const preview = document.getElementById('post-image-preview');
if (url) {
const img = preview.querySelector('img');
img.src = url;
img.onload = () => { preview.style.display = 'block'; };
img.onerror = () => { preview.style.display = 'none'; };
} else {
preview.style.display = 'none';
}
});
async function sendManualPost() {
const text = document.getElementById('post-text').value.trim();
const imageUrl = document.getElementById('post-image').value.trim() || null;
if (!text || !postChannelId) return;
const btn = document.getElementById('btn-send-post');
const result = document.getElementById('post-result');
btn.disabled = true;
btn.textContent = 'Отправка...';
try {
const res = await fetch('/api/post', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ channelId: postChannelId, text, imageUrl })
});
const data = await res.json();
if (data.success) {
result.style.display = 'block';
result.style.color = '#4ade80';
result.textContent = `✅ Опубликовано! msg #${data.messageId}`;
fetchStats();
setTimeout(closePostModal, 1500);
} else {
result.style.display = 'block';
result.style.color = '#f87171';
result.textContent = `❌ ${data.error}`;
btn.disabled = false;
btn.textContent = 'Отправить';
}
} catch (err) {
result.style.display = 'block';
result.style.color = '#f87171';
result.textContent = `❌ ${err.message}`;
btn.disabled = false;
btn.textContent = 'Отправить';
}
}
// --- Recent Posts ---
async function loadRecentPosts() {
const container = document.getElementById('recent-posts');
const chunks = [];
for (const ch of CHANNELS) {
try {
const res = await fetch(`/api/posts?channel=${ch.id}&limit=3`);
const { data } = await res.json();
if (!data || data.length === 0) continue;
const postsHtml = data.map(p => {
const time = new Date(p.created_at + 'Z').toLocaleString('ru', { timeZone: 'Asia/Almaty', day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' });
const text = p.content.replace(/<[^>]+>/g, '').substring(0, 150);
const badge = p.post_type === 'ad' ? '<span class="post-type-badge">💰 реклама</span>' : '';
return `<div class="post-preview"><div class="post-time">${time}${badge}</div>${text}<div class="post-fade"></div></div>`;
}).join('');
chunks.push(`<div class="posts-channel"><div class="posts-channel-name">${ch.name}</div>${postsHtml}</div>`);
} catch {}
}
container.innerHTML = chunks.length > 0
? `<div class="posts-grid">${chunks.join('')}</div>`
: '<div style="text-align:center;color:#4b5563;font-size:14px;padding:16px">Нет постов</div>';
}
// --- Ad Tracker ---
async function loadAdStats() {
try {
const res = await fetch('/api/ads');
const data = await res.json();
const grid = document.getElementById('ad-kpis');
grid.innerHTML = `
<div class="kpi-card">
<div class="label">Реклама сегодня</div>
<div class="value">${data.todayAds}</div>
<div class="sub">постов Pin-Up</div>
</div>
<div class="kpi-card">
<div class="label">За 7 дней</div>
<div class="value">${data.weekAds}</div>
<div class="sub">рекламных постов</div>
</div>
<div class="kpi-card">
<div class="label">Всего</div>
<div class="value">${data.totalAds}</div>
<div class="sub">за всё время</div>
</div>
<div class="kpi-card">
<div class="label">Частота</div>
<div class="value">${data.weekAds > 0 ? Math.round(data.weekAds / 5) : 0}/кан</div>
<div class="sub">в неделю на канал</div>
</div>
`;
// A/B templates
const ab = document.getElementById('ad-ab');
if (data.templates && data.templates.length > 0) {
const total = data.templates.reduce((s, t) => s + t.count, 0);
ab.innerHTML = '<div style="font-size:13px;color:#9ca3af;margin-bottom:8px">A/B шаблоны:</div>' +
data.templates.map(t => {
const pct = Math.round((t.count / total) * 100);
return `<div style="display:flex;align-items:center;gap:8px;margin-bottom:4px;font-size:13px">
<span style="color:#fbbf24;width:80px">${t.template_id}</span>
<div style="flex:1;height:6px;background:#2a2d3e;border-radius:3px;overflow:hidden">
<div style="width:${pct}%;height:100%;background:#eab308;border-radius:3px"></div>
</div>
<span style="color:#9ca3af;width:60px;text-align:right">${t.count}× (${pct}%)</span>
</div>`;
}).join('');
} else {
ab.innerHTML = '';
}
} catch {}
}
// --- Error Log ---
async function loadErrors() {
const list = document.getElementById('errors-list');
const badge = document.getElementById('errors-badge');
try {
const res = await fetch('/api/errors?limit=15');
const { data, total } = await res.json();
if (total > 0) {
badge.textContent = total;
badge.style.display = 'inline';
} else {
badge.style.display = 'none';
}
if (!data || data.length === 0) {
list.innerHTML = '<div class="no-errors">✅ Ошибок нет</div>';
return;
}
list.innerHTML = data.map(e => {
const chName = channelNames[e.channel] || e.channel;
return `<div class="error-item">
<span class="err-time">${e.time}</span>
<span class="err-ch">${chName}</span>
<span class="err-type">${e.type}</span>
<span class="err-msg">${e.message}</span>
</div>`;
}).join('');
} catch {
list.innerHTML = '<div class="no-errors" style="color:#6b7280">Не удалось загрузить</div>';
}
}
// Initial render + auto-refresh every 60s
fetchStats();
loadQueue();
loadRecentPosts();
loadErrors();
loadAdStats();
setInterval(fetchStats, 60000);
setInterval(loadQueue, 30000);
setInterval(loadRecentPosts, 60000);
setInterval(loadErrors, 30000);
setInterval(loadAdStats, 60000);
</script>
</body>
</html>
📜 Git History
045688afeat: dashboard v0.3 — 9 новых источников + 10 улучшений дашборда7 weeks ago
5481deffeat: post moderation queue for new sources7 weeks ago
6ff5fa6feat: source management with moderation via dashboard modal7 weeks ago
0d25952feat: add dashboard on arb.szhub.space (port 3220)7 weeks ago
Show last diff
Loading...