Add feeds listing page with pagination and filtering

This commit is contained in:
Vladimir Mirivlad 2026-02-06 06:01:34 +00:00
parent 8393182b48
commit 55dfc77116
3 changed files with 493 additions and 3 deletions

View File

@ -59,6 +59,12 @@ $app->get('/', function (Request $request, Response $response) {
return $response->withHeader('Content-Type', 'text/html');
});
// Маршрут для страницы лент
$app->get('/feeds', function (Request $request, Response $response) {
$response->getBody()->write(file_get_contents(__DIR__ . '/../templates/feeds.html'));
return $response->withHeader('Content-Type', 'text/html');
});
// Обработка ошибок
$errorMiddleware = $app->addErrorMiddleware($_ENV['APP_DEBUG'] ?? false, true, true);

View File

@ -23,12 +23,23 @@ class ApiController
try {
$params = $request->getQueryParams();
// Параметры пагинации
$page = isset($params['page']) ? max(1, (int)$params['page']) : 1;
$limit = isset($params['limit']) ? min(100, max(1, (int)$params['limit'])) : 20; // максимум 100 на страницу
$offset = ($page - 1) * $limit;
$sql = "SELECT f.*, c.name as category_name, o.name as owner_name
FROM feeds f
LEFT JOIN categories c ON f.category_id = c.id
LEFT JOIN owners o ON f.owner_id = o.id
WHERE f.status = 'active'";
$countSql = "SELECT COUNT(*)
FROM feeds f
LEFT JOIN categories c ON f.category_id = c.id
LEFT JOIN owners o ON f.owner_id = o.id
WHERE f.status = 'active'";
$conditions = [];
$bindings = [];
@ -48,10 +59,21 @@ class ApiController
}
if (!empty($conditions)) {
$sql .= " AND " . implode(" AND ", $conditions);
$whereClause = " AND " . implode(" AND ", $conditions);
$sql .= $whereClause;
$countSql .= $whereClause;
}
$sql .= " ORDER BY f.created_at DESC";
$sql .= " ORDER BY f.created_at DESC LIMIT :limit OFFSET :offset";
// Подсчет общего количества
$countStmt = $this->db->prepare($countSql);
$countStmt->execute($bindings);
$total = $countStmt->fetchColumn();
// Выборка данных
$bindings[':limit'] = $limit;
$bindings[':offset'] = $offset;
$stmt = $this->db->prepare($sql);
$stmt->execute($bindings);
@ -69,7 +91,18 @@ class ApiController
$feed['tags'] = array_column($tagStmt->fetchAll(), 'name');
}
$response->getBody()->write(json_encode($feeds));
// Подготовка ответа с пагинацией
$responseData = [
'data' => $feeds,
'pagination' => [
'current_page' => $page,
'per_page' => $limit,
'total' => $total,
'total_pages' => ceil($total / $limit)
]
];
$response->getBody()->write(json_encode($responseData));
return $response->withHeader('Content-Type', 'application/json');
} catch (\Exception $e) {
$response->getBody()->write(json_encode(['error' => $e->getMessage()]));

451
templates/feeds.html Normal file
View File

@ -0,0 +1,451 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Список RSS лент - RSS Hub for Agents</title>
<style>
body {
font-family: 'Courier New', Courier, monospace;
background-color: #000;
color: #00ff00;
margin: 0;
padding: 20px;
line-height: 1.4;
}
.container {
max-width: 1200px;
margin: 0 auto;
border: 1px solid #00ff00;
padding: 20px;
background-color: #001100;
}
header {
text-align: center;
margin-bottom: 30px;
border-bottom: 1px solid #00ff00;
padding-bottom: 20px;
}
h1 {
color: #00ff00;
font-size: 2em;
margin: 0;
text-shadow: 0 0 5px rgba(0, 255, 0, 0.5);
}
.controls {
margin: 20px 0;
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 15px;
}
.search-box {
background-color: #002200;
border: 1px solid #00ff00;
padding: 8px;
color: #00ff00;
font-family: 'Courier New', monospace;
width: 300px;
}
.filters {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.filter-select {
background-color: #002200;
border: 1px solid #00ff00;
color: #00ff00;
padding: 5px;
font-family: 'Courier New', monospace;
}
.pagination {
text-align: center;
margin: 20px 0;
display: flex;
justify-content: center;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.page-link {
background-color: #003300;
border: 1px solid #00aa00;
color: #00ffaa;
padding: 8px 12px;
text-decoration: none;
cursor: pointer;
}
.page-link.active {
background-color: #005500;
border: 1px solid #00cc00;
font-weight: bold;
}
.page-link.disabled {
color: #006600;
cursor: not-allowed;
}
.table-container {
overflow-x: auto;
margin: 20px 0;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 0.9em;
}
th, td {
border: 1px solid #00aa00;
padding: 10px;
text-align: left;
}
th {
background-color: #003300;
color: #00ffaa;
}
tr:nth-child(even) {
background-color: #001800;
}
tr:hover {
background-color: #002a00;
}
.tags-container {
display: flex;
flex-wrap: wrap;
gap: 5px;
}
.tag {
background-color: #004400;
border: 1px solid #008800;
color: #00ccaa;
padding: 2px 6px;
border-radius: 3px;
font-size: 0.8em;
}
.loading {
text-align: center;
padding: 20px;
color: #008800;
}
.footer {
margin-top: 40px;
text-align: center;
color: #008800;
font-size: 0.9em;
}
a {
color: #00ffaa;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
@media (max-width: 768px) {
.controls {
flex-direction: column;
align-items: stretch;
}
.search-box {
width: 100%;
}
table {
font-size: 0.8em;
}
th, td {
padding: 5px;
}
}
</style>
</head>
<body>
<div class="container">
<header>
<h1>Список RSS лент</h1>
<p>Просмотр зарегистрированных RSS/Atom лент с фильтрацией и пагинацией</p>
</header>
<div class="controls">
<input type="text" class="search-box" placeholder="Поиск по лентам..." id="searchInput">
<div class="filters">
<select class="filter-select" id="categoryFilter">
<option value="">Все категории</option>
<!-- Categories will be populated dynamically -->
</select>
<select class="filter-select" id="ownerFilter">
<option value="">Все владельцы</option>
<!-- Owners will be populated dynamically -->
</select>
</div>
</div>
<div class="table-container">
<table id="feedsTable">
<thead>
<tr>
<th>ID</th>
<th>Название</th>
<th>URL</th>
<th>Категория</th>
<th>Теги</th>
<th>Владелец</th>
<th>Дата добавления</th>
</tr>
</thead>
<tbody id="feedsTableBody">
<tr>
<td colspan="7" class="loading">Загрузка данных...</td>
</tr>
</tbody>
</table>
</div>
<div class="pagination" id="pagination">
<!-- Pagination will be generated dynamically -->
</div>
<div class="footer">
<p>RSS Hub for Agents v1.0</p>
<p>Всего лент: <span id="totalCount">0</span></p>
</div>
</div>
<script>
// Текущие параметры запроса
let currentPage = 1;
const itemsPerPage = 20;
// Загрузка данных
async function loadFeeds(page = 1, search = '', category = '', owner = '') {
// Показываем индикатор загрузки
document.getElementById('feedsTableBody').innerHTML = '<tr><td colspan="7" class="loading">Загрузка данных...</td></tr>';
try {
// Формируем параметры запроса
let params = new URLSearchParams({
page: page,
limit: itemsPerPage
});
if (search) params.append('q', search);
if (category) params.append('category', category);
if (owner) params.append('owner', owner);
const response = await fetch(`/api/feeds?${params}`);
const result = await response.json();
if (response.ok) {
renderFeeds(result.data);
renderPagination(page, result.pagination.total_pages, result.pagination.total);
} else {
throw new Error(`Ошибка: ${result.error}`);
}
} catch (error) {
console.error('Ошибка при загрузке лент:', error);
document.getElementById('feedsTableBody').innerHTML = `<tr><td colspan="7" class="loading">Ошибка загрузки: ${error.message}</td></tr>`;
}
}
// Загрузка категорий
async function loadCategories() {
try {
const response = await fetch('/api/categories');
const categories = await response.json();
const categorySelect = document.getElementById('categoryFilter');
// Очищаем текущие опции
categorySelect.innerHTML = '<option value="">Все категории</option>';
// Добавляем новые опции
categories.forEach(category => {
const option = document.createElement('option');
option.value = category.name;
option.textContent = category.name;
categorySelect.appendChild(option);
});
} catch (error) {
console.error('Ошибка при загрузке категорий:', error);
}
}
// Загрузка владельцев
async function loadOwners() {
try {
// Для простоты пока не реализуем отдельный эндпоинт для владельцев
// Можно будет добавить позже
} catch (error) {
console.error('Ошибка при загрузке владельцев:', error);
}
}
// Отображение лент
function renderFeeds(feeds) {
const tbody = document.getElementById('feedsTableBody');
tbody.innerHTML = '';
if (!feeds || feeds.length === 0) {
tbody.innerHTML = '<tr><td colspan="7">Нет данных для отображения</td></tr>';
return;
}
// Обновляем счетчик
document.getElementById('totalCount').textContent = feeds.length;
// Показываем только текущую страницу
const startIndex = (currentPage - 1) * itemsPerPage;
const endIndex = startIndex + itemsPerPage;
const pageFeeds = feeds.slice(startIndex, endIndex);
pageFeeds.forEach(feed => {
const row = document.createElement('tr');
// Теги в виде элементов
let tagsHtml = '';
if (feed.tags && Array.isArray(feed.tags)) {
tagsHtml = '<div class="tags-container">';
feed.tags.forEach(tag => {
tagsHtml += `<span class="tag">${tag}</span>`;
});
tagsHtml += '</div>';
} else {
tagsHtml = '-';
}
row.innerHTML = `
<td>${feed.id || '-'}</td>
<td>${feed.title || '-'}</td>
<td><a href="${feed.url}" target="_blank">${truncateUrl(feed.url)}</a></td>
<td>${feed.category_name || '-'}</td>
<td>${tagsHtml}</td>
<td>${feed.owner_name || '-'}</td>
<td>${formatDate(feed.created_at || '-')}</td>
`;
tbody.appendChild(row);
});
}
// Отображение пагинации
function renderPagination(currentPage, totalPages, totalCount) {
const paginationDiv = document.getElementById('pagination');
paginationDiv.innerHTML = '';
// Обновляем счетчик
document.getElementById('totalCount').textContent = totalCount;
if (totalPages <= 1) return;
// Кнопка "Назад"
const prevButton = document.createElement('a');
prevButton.href = '#';
prevButton.className = `page-link ${currentPage === 1 ? 'disabled' : ''}`;
prevButton.textContent = 'Пред.';
if (currentPage > 1) {
prevButton.onclick = (e) => {
e.preventDefault();
loadFeeds(currentPage - 1);
};
}
paginationDiv.appendChild(prevButton);
// Диапазон страниц для отображения
const startPage = Math.max(1, currentPage - 2);
const endPage = Math.min(totalPages, currentPage + 2);
// Кнопки страниц
for (let i = startPage; i <= endPage; i++) {
const pageButton = document.createElement('a');
pageButton.href = '#';
pageButton.className = `page-link ${i === currentPage ? 'active' : ''}`;
pageButton.textContent = i;
pageButton.onclick = (e) => {
e.preventDefault();
loadFeeds(i);
};
paginationDiv.appendChild(pageButton);
}
// Кнопка "Вперед"
const nextButton = document.createElement('a');
nextButton.href = '#';
nextButton.className = `page-link ${currentPage === totalPages ? 'disabled' : ''}`;
nextButton.textContent = 'След.';
if (currentPage < totalPages) {
nextButton.onclick = (e) => {
e.preventDefault();
loadFeeds(currentPage + 1);
};
}
paginationDiv.appendChild(nextButton);
}
// Укорачивание URL для отображения
function truncateUrl(url) {
if (!url) return '-';
return url.length > 50 ? url.substring(0, 47) + '...' : url;
}
// Форматирование даты
function formatDate(dateString) {
if (!dateString || dateString === '-') return '-';
const date = new Date(dateString);
return date.toLocaleString('ru-RU');
}
// Поиск при вводе
document.getElementById('searchInput').addEventListener('input', function() {
const search = this.value.trim();
loadFeeds(1, search, document.getElementById('categoryFilter').value, document.getElementById('ownerFilter').value);
});
// Фильтр по категории
document.getElementById('categoryFilter').addEventListener('change', function() {
const category = this.value;
loadFeeds(1, document.getElementById('searchInput').value.trim(), category, document.getElementById('ownerFilter').value);
});
// Фильтр по владельцу
document.getElementById('ownerFilter').addEventListener('change', function() {
const owner = this.value;
loadFeeds(1, document.getElementById('searchInput').value.trim(), document.getElementById('categoryFilter').value, owner);
});
// Инициализация
document.addEventListener('DOMContentLoaded', function() {
loadCategories();
loadOwners();
loadFeeds(1);
});
</script>
</body>
</html>