Add lightweight web control panel
This commit is contained in:
+177
@@ -0,0 +1,177 @@
|
||||
const state = {
|
||||
dashboard: null,
|
||||
};
|
||||
|
||||
async function api(path, options = {}) {
|
||||
const response = await fetch(path, {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
...options,
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || "Request failed");
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) return "—";
|
||||
return value.replace("T", " ").replace("+00:00", " UTC");
|
||||
}
|
||||
|
||||
function setBusy(button, busy) {
|
||||
if (!button) return;
|
||||
button.disabled = busy;
|
||||
}
|
||||
|
||||
function renderAuth(auth) {
|
||||
document.getElementById("auth-status").textContent = auth.status;
|
||||
document.getElementById("auth-details").textContent = auth.details || "";
|
||||
}
|
||||
|
||||
function renderSummary(data) {
|
||||
document.getElementById("channel-count").textContent = String(data.state.channel_count);
|
||||
document.getElementById("forwarding-count").textContent = String(data.state.forwarding_rules.length);
|
||||
const toggle = document.getElementById("scrape-media-toggle");
|
||||
toggle.checked = Boolean(data.state.scrape_media);
|
||||
document.getElementById("scrape-media-label").textContent = toggle.checked ? "ON" : "OFF";
|
||||
}
|
||||
|
||||
function renderChannels(channels) {
|
||||
const tbody = document.getElementById("channels-table");
|
||||
tbody.innerHTML = "";
|
||||
const template = document.getElementById("channel-row-template");
|
||||
|
||||
channels.forEach((channel) => {
|
||||
const node = template.content.firstElementChild.cloneNode(true);
|
||||
node.querySelector(".channel-name").textContent = channel.name;
|
||||
node.querySelector(".channel-id").textContent = channel.channel_id;
|
||||
node.querySelector(".message-count").textContent = String(channel.message_count);
|
||||
node.querySelector(".media-count").textContent = String(channel.media_count);
|
||||
node.querySelector(".last-date").textContent = channel.last_date || "—";
|
||||
|
||||
node.querySelector(".scrape-btn").addEventListener("click", async () => {
|
||||
await api("/api/jobs/scrape", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ channel_id: channel.channel_id }),
|
||||
});
|
||||
await refreshDashboard();
|
||||
});
|
||||
|
||||
node.querySelector(".export-view-btn").addEventListener("click", () => {
|
||||
window.location.href = `/viewer?channel=${encodeURIComponent(channel.channel_id)}`;
|
||||
});
|
||||
|
||||
node.querySelector(".media-btn").addEventListener("click", async () => {
|
||||
await api("/api/jobs/rescrape-media", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ channel_id: channel.channel_id }),
|
||||
});
|
||||
await refreshDashboard();
|
||||
});
|
||||
|
||||
node.querySelector(".remove-btn").addEventListener("click", async () => {
|
||||
await api("/api/channels/remove", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ channel_id: channel.channel_id }),
|
||||
});
|
||||
await refreshDashboard();
|
||||
});
|
||||
|
||||
tbody.appendChild(node);
|
||||
});
|
||||
}
|
||||
|
||||
function renderJobs(jobs) {
|
||||
const root = document.getElementById("jobs-list");
|
||||
root.innerHTML = "";
|
||||
const template = document.getElementById("job-template");
|
||||
|
||||
if (!jobs.length) {
|
||||
root.innerHTML = '<div class="muted">Пока задач нет.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
jobs.forEach((job) => {
|
||||
const node = template.content.firstElementChild.cloneNode(true);
|
||||
node.querySelector(".job-title").textContent = job.title;
|
||||
node.querySelector(".job-status").textContent = job.status;
|
||||
node.querySelector(".job-time").textContent = [job.started_at, job.finished_at].filter(Boolean).join(" -> ");
|
||||
node.querySelector(".job-logs").textContent = job.logs || job.error || "Без логов";
|
||||
root.appendChild(node);
|
||||
});
|
||||
}
|
||||
|
||||
async function refreshDashboard() {
|
||||
const data = await api("/api/dashboard");
|
||||
state.dashboard = data;
|
||||
renderAuth(data.auth);
|
||||
renderSummary(data);
|
||||
renderChannels(data.channels);
|
||||
renderJobs(data.jobs);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
document.getElementById("scrape-all-btn").addEventListener("click", async (event) => {
|
||||
setBusy(event.currentTarget, true);
|
||||
try {
|
||||
await api("/api/jobs/scrape", { method: "POST", body: JSON.stringify({}) });
|
||||
await refreshDashboard();
|
||||
} finally {
|
||||
setBusy(event.currentTarget, false);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("export-all-btn").addEventListener("click", async (event) => {
|
||||
setBusy(event.currentTarget, true);
|
||||
try {
|
||||
await api("/api/jobs/export", { method: "POST", body: JSON.stringify({}) });
|
||||
await refreshDashboard();
|
||||
} finally {
|
||||
setBusy(event.currentTarget, false);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("refresh-dialogs-btn").addEventListener("click", async (event) => {
|
||||
setBusy(event.currentTarget, true);
|
||||
try {
|
||||
await api("/api/jobs/refresh-dialogs", { method: "POST", body: JSON.stringify({}) });
|
||||
await refreshDashboard();
|
||||
} finally {
|
||||
setBusy(event.currentTarget, false);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("refresh-jobs-btn").addEventListener("click", refreshDashboard);
|
||||
|
||||
document.getElementById("scrape-media-toggle").addEventListener("change", async (event) => {
|
||||
const checked = event.currentTarget.checked;
|
||||
document.getElementById("scrape-media-label").textContent = checked ? "ON" : "OFF";
|
||||
await api("/api/settings/media", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ value: checked }),
|
||||
});
|
||||
await refreshDashboard();
|
||||
});
|
||||
|
||||
document.getElementById("add-channel-form").addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const channelId = document.getElementById("add-channel-id").value.trim();
|
||||
const name = document.getElementById("add-channel-name").value.trim();
|
||||
if (!channelId) return;
|
||||
await api("/api/channels/add", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ channel_id: channelId, name }),
|
||||
});
|
||||
event.currentTarget.reset();
|
||||
await refreshDashboard();
|
||||
});
|
||||
|
||||
await refreshDashboard();
|
||||
window.setInterval(refreshDashboard, 5000);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
alert(error.message);
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Telegram Scraper Control Panel</title>
|
||||
<link rel="stylesheet" href="/static/style.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-shell">
|
||||
<aside class="sidebar">
|
||||
<div class="brand-block">
|
||||
<div class="eyebrow">Telegram Scraper</div>
|
||||
<h1>Control Panel</h1>
|
||||
<p class="muted">Обычная админка для сервера: состояния, каналы, фоновые задачи.</p>
|
||||
</div>
|
||||
|
||||
<nav class="nav-links">
|
||||
<a class="nav-link active" href="/">Панель</a>
|
||||
<a class="nav-link" href="/viewer">Просмотр сообщений</a>
|
||||
</nav>
|
||||
|
||||
<section class="status-card">
|
||||
<div class="section-title">Состояние Telegram</div>
|
||||
<div id="auth-status" class="status-badge">Проверяем...</div>
|
||||
<p id="auth-details" class="muted small"></p>
|
||||
</section>
|
||||
|
||||
<section class="status-card">
|
||||
<div class="section-title">Быстрые действия</div>
|
||||
<button class="button primary" id="scrape-all-btn">Скрапить все каналы</button>
|
||||
<button class="button" id="export-all-btn">Экспортировать всё</button>
|
||||
<button class="button" id="refresh-dialogs-btn">Обновить список диалогов</button>
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
<main class="content">
|
||||
<section class="panel-grid">
|
||||
<div class="panel stat-panel">
|
||||
<div class="section-title">Каналы</div>
|
||||
<div id="channel-count" class="stat-value">0</div>
|
||||
</div>
|
||||
<div class="panel stat-panel">
|
||||
<div class="section-title">Media scraping</div>
|
||||
<label class="toggle-row">
|
||||
<span id="scrape-media-label">OFF</span>
|
||||
<input id="scrape-media-toggle" type="checkbox" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="panel stat-panel">
|
||||
<div class="section-title">Forwarding rules</div>
|
||||
<div id="forwarding-count" class="stat-value">0</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<h2>Отслеживаемые каналы</h2>
|
||||
<p class="muted">Берутся из <code>data/state.json</code>, статистика подтягивается из SQLite.</p>
|
||||
</div>
|
||||
<form id="add-channel-form" class="inline-form">
|
||||
<input id="add-channel-id" name="channel_id" placeholder="ID или @username" required />
|
||||
<input id="add-channel-name" name="name" placeholder="Псевдоним (необязательно)" />
|
||||
<button class="button primary" type="submit">Добавить</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Канал</th>
|
||||
<th>Сообщений</th>
|
||||
<th>Медиа</th>
|
||||
<th>Последнее сообщение</th>
|
||||
<th>Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="channels-table"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel jobs-panel">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<h2>Фоновые задачи</h2>
|
||||
<p class="muted">Очередь простая: задачи выполняются по одной, чтобы не драться за Telegram session.</p>
|
||||
</div>
|
||||
<button class="button" id="refresh-jobs-btn">Обновить</button>
|
||||
</div>
|
||||
<div id="jobs-list" class="jobs-list"></div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<template id="channel-row-template">
|
||||
<tr>
|
||||
<td>
|
||||
<div class="channel-name"></div>
|
||||
<div class="muted small channel-id"></div>
|
||||
</td>
|
||||
<td class="message-count"></td>
|
||||
<td class="media-count"></td>
|
||||
<td class="last-date"></td>
|
||||
<td>
|
||||
<div class="action-row">
|
||||
<button class="button button-small scrape-btn">Scrape</button>
|
||||
<button class="button button-small export-view-btn">Viewer</button>
|
||||
<button class="button button-small media-btn">Rescrape media</button>
|
||||
<button class="button button-small danger remove-btn">Удалить</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
|
||||
<template id="job-template">
|
||||
<article class="job-card">
|
||||
<div class="job-head">
|
||||
<div class="job-title"></div>
|
||||
<div class="job-status"></div>
|
||||
</div>
|
||||
<div class="muted small job-time"></div>
|
||||
<pre class="job-logs"></pre>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<script src="/static/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+365
@@ -0,0 +1,365 @@
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--bg: #0f1723;
|
||||
--panel: #17212b;
|
||||
--panel-alt: #101922;
|
||||
--line: #253241;
|
||||
--soft: #8ea2b5;
|
||||
--text: #edf3f9;
|
||||
--accent: #53a7ff;
|
||||
--accent-strong: #2f8cff;
|
||||
--danger: #ff6b6b;
|
||||
--bubble: #182533;
|
||||
--bubble-self: #1f3a4d;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: Inter, Arial, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
code,
|
||||
pre,
|
||||
input,
|
||||
button {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.app-shell,
|
||||
.viewer-shell {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
grid-template-columns: 320px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.sidebar,
|
||||
.viewer-sidebar {
|
||||
background: var(--panel-alt);
|
||||
border-right: 1px solid var(--line);
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.content,
|
||||
.viewer-main {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.brand-block h1,
|
||||
.viewer-sidebar-head h1,
|
||||
.viewer-header h2,
|
||||
.panel-header h2 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
color: var(--accent);
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--soft);
|
||||
}
|
||||
|
||||
.small {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin: 24px 0;
|
||||
}
|
||||
|
||||
.nav-link,
|
||||
.button {
|
||||
border: 1px solid var(--line);
|
||||
background: var(--panel);
|
||||
color: var(--text);
|
||||
padding: 10px 14px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.nav-link.active,
|
||||
.button.primary {
|
||||
background: var(--accent-strong);
|
||||
border-color: var(--accent-strong);
|
||||
}
|
||||
|
||||
.button.danger {
|
||||
color: #ffd0d0;
|
||||
}
|
||||
|
||||
.button.button-small {
|
||||
padding: 8px 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.status-card,
|
||||
.panel,
|
||||
.stat-panel {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.status-card {
|
||||
padding: 16px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
color: var(--soft);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border-radius: 999px;
|
||||
padding: 8px 12px;
|
||||
background: #183248;
|
||||
color: #a7d8ff;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.panel-grid {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.stat-panel {
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.toggle-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
padding: 18px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.panel-header,
|
||||
.viewer-sidebar-head,
|
||||
.viewer-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.inline-form {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
input {
|
||||
min-width: 160px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--line);
|
||||
background: #0d141c;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--line);
|
||||
padding: 14px 10px;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
tbody tr:hover {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
.action-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.jobs-list {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.job-card {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
padding: 14px;
|
||||
background: #121c25;
|
||||
}
|
||||
|
||||
.job-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.job-status {
|
||||
text-transform: uppercase;
|
||||
font-size: 12px;
|
||||
color: var(--soft);
|
||||
}
|
||||
|
||||
.job-logs {
|
||||
margin: 10px 0 0;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
background: #0d141c;
|
||||
max-height: 240px;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.viewer-shell {
|
||||
grid-template-columns: 320px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.viewer-channel-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.viewer-channel-item {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--panel);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.viewer-channel-item.active {
|
||||
border-color: var(--accent);
|
||||
background: #193246;
|
||||
}
|
||||
|
||||
.viewer-channel-name {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.viewer-channel-meta {
|
||||
font-size: 12px;
|
||||
color: var(--soft);
|
||||
}
|
||||
|
||||
.messages-list {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.message-card {
|
||||
max-width: 760px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
padding: 14px;
|
||||
background: var(--bubble);
|
||||
}
|
||||
|
||||
.message-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.message-author {
|
||||
color: #8fd3ff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.message-reply:empty,
|
||||
.message-text:empty,
|
||||
.message-footer:empty,
|
||||
.message-media:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.message-text {
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.message-media {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.message-media img,
|
||||
.message-media video {
|
||||
max-width: 100%;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--line);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.message-footer {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.app-shell,
|
||||
.viewer-shell {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.sidebar,
|
||||
.viewer-sidebar {
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.panel-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Telegram Scraper Viewer</title>
|
||||
<link rel="stylesheet" href="/static/style.css" />
|
||||
</head>
|
||||
<body class="viewer-body">
|
||||
<div class="viewer-shell">
|
||||
<aside class="viewer-sidebar">
|
||||
<div class="viewer-sidebar-head">
|
||||
<div>
|
||||
<div class="eyebrow">Export Viewer</div>
|
||||
<h1>Сообщения</h1>
|
||||
</div>
|
||||
<a class="button button-small" href="/">Панель</a>
|
||||
</div>
|
||||
<div id="viewer-channel-list" class="viewer-channel-list"></div>
|
||||
</aside>
|
||||
|
||||
<main class="viewer-main">
|
||||
<header class="viewer-header">
|
||||
<div>
|
||||
<h2 id="viewer-title">Выберите канал</h2>
|
||||
<p id="viewer-subtitle" class="muted">Показываем данные из локальной SQLite базы.</p>
|
||||
</div>
|
||||
<div class="viewer-actions">
|
||||
<button class="button" id="load-older-btn" disabled>Загрузить старее</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section id="messages-list" class="messages-list"></section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<template id="viewer-channel-template">
|
||||
<button class="viewer-channel-item">
|
||||
<span class="viewer-channel-name"></span>
|
||||
<span class="viewer-channel-meta"></span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<template id="message-template">
|
||||
<article class="message-card">
|
||||
<div class="message-meta">
|
||||
<span class="message-author"></span>
|
||||
<span class="message-date"></span>
|
||||
</div>
|
||||
<div class="message-reply muted small"></div>
|
||||
<div class="message-text"></div>
|
||||
<div class="message-media"></div>
|
||||
<div class="message-footer muted small"></div>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<script src="/static/viewer.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
const viewerState = {
|
||||
channels: [],
|
||||
channelId: null,
|
||||
oldestMessageId: null,
|
||||
};
|
||||
|
||||
async function api(path) {
|
||||
const response = await fetch(path);
|
||||
const data = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || "Request failed");
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
function textNodeWithBreaks(text) {
|
||||
const fragment = document.createDocumentFragment();
|
||||
const parts = (text || "").split("\n");
|
||||
parts.forEach((part, index) => {
|
||||
if (index > 0) fragment.appendChild(document.createElement("br"));
|
||||
fragment.appendChild(document.createTextNode(part));
|
||||
});
|
||||
return fragment;
|
||||
}
|
||||
|
||||
function renderChannelList() {
|
||||
const root = document.getElementById("viewer-channel-list");
|
||||
root.innerHTML = "";
|
||||
const template = document.getElementById("viewer-channel-template");
|
||||
viewerState.channels.forEach((channel) => {
|
||||
const node = template.content.firstElementChild.cloneNode(true);
|
||||
node.querySelector(".viewer-channel-name").textContent = channel.name;
|
||||
node.querySelector(".viewer-channel-meta").textContent = `${channel.message_count} сообщений`;
|
||||
if (channel.channel_id === viewerState.channelId) {
|
||||
node.classList.add("active");
|
||||
}
|
||||
node.addEventListener("click", () => {
|
||||
viewerState.oldestMessageId = null;
|
||||
loadChannel(channel.channel_id);
|
||||
});
|
||||
root.appendChild(node);
|
||||
});
|
||||
}
|
||||
|
||||
function renderMessages(payload, append = false) {
|
||||
const root = document.getElementById("messages-list");
|
||||
const template = document.getElementById("message-template");
|
||||
if (!append) root.innerHTML = "";
|
||||
|
||||
const channel = payload.channel;
|
||||
document.getElementById("viewer-title").textContent = channel?.name || payload.channel_id;
|
||||
document.getElementById("viewer-subtitle").textContent = channel
|
||||
? `${channel.message_count} сообщений, последнее: ${channel.last_date || "—"}`
|
||||
: "База данных для канала пока не найдена.";
|
||||
|
||||
payload.messages.forEach((message) => {
|
||||
const node = template.content.firstElementChild.cloneNode(true);
|
||||
node.dataset.messageId = String(message.message_id);
|
||||
node.querySelector(".message-author").textContent = message.sender_name || "Unknown";
|
||||
node.querySelector(".message-date").textContent = message.date || "";
|
||||
|
||||
const reply = node.querySelector(".message-reply");
|
||||
if (message.reply_to) {
|
||||
reply.textContent = `Ответ на сообщение #${message.reply_to}`;
|
||||
}
|
||||
|
||||
node.querySelector(".message-text").appendChild(textNodeWithBreaks(message.text || ""));
|
||||
|
||||
const media = node.querySelector(".message-media");
|
||||
if (message.media_url && message.media_kind === "image") {
|
||||
const img = document.createElement("img");
|
||||
img.src = message.media_url;
|
||||
img.loading = "lazy";
|
||||
media.appendChild(img);
|
||||
} else if (message.media_url && message.media_kind === "video") {
|
||||
const video = document.createElement("video");
|
||||
video.src = message.media_url;
|
||||
video.controls = true;
|
||||
video.preload = "metadata";
|
||||
media.appendChild(video);
|
||||
} else if (message.media_url) {
|
||||
const link = document.createElement("a");
|
||||
link.href = message.media_url;
|
||||
link.target = "_blank";
|
||||
link.rel = "noreferrer";
|
||||
link.textContent = "Открыть файл";
|
||||
media.appendChild(link);
|
||||
}
|
||||
|
||||
const footerParts = [];
|
||||
if (message.views) footerParts.push(`views: ${message.views}`);
|
||||
if (message.forwards) footerParts.push(`forwards: ${message.forwards}`);
|
||||
if (message.reactions) footerParts.push(`reactions: ${message.reactions}`);
|
||||
node.querySelector(".message-footer").textContent = footerParts.join(" | ");
|
||||
|
||||
if (append) {
|
||||
root.prepend(node);
|
||||
} else {
|
||||
root.appendChild(node);
|
||||
}
|
||||
});
|
||||
|
||||
if (payload.messages.length) {
|
||||
viewerState.oldestMessageId = payload.messages[0].message_id;
|
||||
}
|
||||
|
||||
document.getElementById("load-older-btn").disabled = !payload.messages.length;
|
||||
}
|
||||
|
||||
async function loadChannel(channelId, append = false) {
|
||||
viewerState.channelId = channelId;
|
||||
renderChannelList();
|
||||
const before = append && viewerState.oldestMessageId ? `&before=${viewerState.oldestMessageId}` : "";
|
||||
const payload = await api(`/api/channels/${encodeURIComponent(channelId)}/messages?limit=80${before}`);
|
||||
renderMessages(payload, append);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
viewerState.channels = await api("/api/channels");
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
viewerState.channelId = params.get("channel") || viewerState.channels[0]?.channel_id || null;
|
||||
renderChannelList();
|
||||
|
||||
if (viewerState.channelId) {
|
||||
await loadChannel(viewerState.channelId);
|
||||
}
|
||||
|
||||
document.getElementById("load-older-btn").addEventListener("click", async () => {
|
||||
if (!viewerState.channelId || !viewerState.oldestMessageId) return;
|
||||
await loadChannel(viewerState.channelId, true);
|
||||
});
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
alert(error.message);
|
||||
});
|
||||
Reference in New Issue
Block a user