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');
|
||||
});
|
||||
|
||||
// Маршрут для страницы лент
|
||||
$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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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()]));
|
||||
|
|
|
|||
|
|
@ -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