Add feeds listing page with pagination and filtering
This commit is contained in:
parent
8393182b48
commit
55dfc77116
|
|
@ -59,6 +59,12 @@ $app->get('/', function (Request $request, Response $response) {
|
||||||
return $response->withHeader('Content-Type', 'text/html');
|
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);
|
$errorMiddleware = $app->addErrorMiddleware($_ENV['APP_DEBUG'] ?? false, true, true);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,12 +23,23 @@ class ApiController
|
||||||
try {
|
try {
|
||||||
$params = $request->getQueryParams();
|
$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
|
$sql = "SELECT f.*, c.name as category_name, o.name as owner_name
|
||||||
FROM feeds f
|
FROM feeds f
|
||||||
LEFT JOIN categories c ON f.category_id = c.id
|
LEFT JOIN categories c ON f.category_id = c.id
|
||||||
LEFT JOIN owners o ON f.owner_id = o.id
|
LEFT JOIN owners o ON f.owner_id = o.id
|
||||||
WHERE f.status = 'active'";
|
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 = [];
|
$conditions = [];
|
||||||
$bindings = [];
|
$bindings = [];
|
||||||
|
|
||||||
|
|
@ -48,10 +59,21 @@ class ApiController
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!empty($conditions)) {
|
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 = $this->db->prepare($sql);
|
||||||
$stmt->execute($bindings);
|
$stmt->execute($bindings);
|
||||||
|
|
@ -69,7 +91,18 @@ class ApiController
|
||||||
$feed['tags'] = array_column($tagStmt->fetchAll(), 'name');
|
$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');
|
return $response->withHeader('Content-Type', 'application/json');
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
$response->getBody()->write(json_encode(['error' => $e->getMessage()]));
|
$response->getBody()->write(json_encode(['error' => $e->getMessage()]));
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
Loading…
Reference in New Issue