feat: переделана лента на AJAX загрузку через API с infinite scroll

- Создан модуль feedList.js для клиентской загрузки постов
- Посты загружаются напрямую из браузера через API
- Медиа URL формируются правильно (как в модале)
- Добавлен infinite scroll для автоматической подгрузки
- Упрощен view feed_list - только новые сотрудники с сервера
- Убрана серверная пагинация, заменена на клиентскую
This commit is contained in:
admin i 2025-12-05 10:35:47 +03:00
parent 32b7aba4dd
commit 421fdf878b
3 changed files with 426 additions and 60 deletions

View File

@ -133,49 +133,17 @@ def _post_back_url(post: dict, user) -> str:
def feed_list(request):
"""
Лента компании из API (?type=company).
Блок «новые сотрудники» — тоже из API.
Лента компании - посты загружаются через API на клиенте.
Блок «новые сотрудники» загружается с сервера.
"""
# Получаем параметры пагинации
page = request.GET.get("page", 1)
# Запрашиваем посты с пагинацией
client = _api(request)
resp = client.get(API_POSTS, params={"type": TYPE_COMPANY, "page": page})
if not resp.ok:
messages.error(request, f"Не удалось получить публикации: {resp.status}")
posts, count, next_url, prev_url = [], 0, None, None
else:
posts, count, next_url, prev_url = _extract_page_data(resp)
# Преобразуем API URLs в frontend URLs
from urllib.parse import urlparse, parse_qs, urlencode
def convert_url(api_url):
if not api_url:
return None
parsed = urlparse(api_url)
query_params = parse_qs(parsed.query)
clean_params = {k: v[0] if isinstance(v, list) and len(v) == 1 else v
for k, v in query_params.items()}
query_string = urlencode(clean_params, doseq=True)
return f"{request.path}?{query_string}" if query_string else request.path
next_url = convert_url(next_url)
prev_url = convert_url(prev_url)
# Загружаем только новых сотрудников с сервера
new_employees = _api_list_new_employees(request, limit=10)
return render(
request,
"feed/feed_list.html",
{
"posts": posts,
"new_employees": new_employees,
"count": count,
"next_url": next_url,
"prev_url": prev_url,
},
)

View File

@ -0,0 +1,401 @@
/**
* feedList.js
* Загрузка и рендеринг ленты новостей через API
*/
export class FeedList {
constructor(options = {}) {
this.containerId = options.containerId || 'feedList';
this.apiUrl = options.apiUrl || '/api/v1/posts/';
this.params = options.params || { type: 'company' };
this.currentPage = 1;
this.isLoading = false;
this.hasMore = true;
this.posts = [];
this.container = document.getElementById(this.containerId);
if (!this.container) {
console.error(`Container #${this.containerId} not found`);
return;
}
this.init();
}
init() {
this.loadPosts();
this.attachScrollHandler();
}
/**
* Загрузка постов с API
*/
async loadPosts(page = 1) {
if (this.isLoading) return;
this.isLoading = true;
this.showLoader();
const params = new URLSearchParams({
...this.params,
page: page
});
try {
const response = await fetch(`${this.apiUrl}?${params}`, {
method: 'GET',
headers: {
'Accept': 'application/json'
}
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
// API возвращает paginated response
const newPosts = data.results || [];
if (page === 1) {
this.posts = newPosts;
this.renderPosts();
} else {
this.posts = [...this.posts, ...newPosts];
this.appendPosts(newPosts);
}
this.currentPage = page;
this.hasMore = !!data.next;
} catch (error) {
console.error('Error loading posts:', error);
this.showError('Не удалось загрузить публикации');
} finally {
this.isLoading = false;
this.hideLoader();
}
}
/**
* Полный рендеринг списка постов
*/
renderPosts() {
this.container.innerHTML = '';
if (this.posts.length === 0) {
this.container.innerHTML = `
<div class="alert alert-info shadow-sm mt-2">
Пока нет новостей.
</div>
`;
return;
}
this.posts.forEach(post => {
this.container.appendChild(this.createPostCard(post));
});
}
/**
* Добавление новых постов в конец списка
*/
appendPosts(posts) {
posts.forEach(post => {
this.container.appendChild(this.createPostCard(post));
});
}
/**
* Создание карточки поста
*/
createPostCard(post) {
const article = document.createElement('article');
article.className = 'feed-post';
article.id = `post-${post.id}`;
article.dataset.postCard = '';
article.dataset.searchtitle = post.title || 'Без названия';
article.dataset.searchauthor = post.author?.full_name || 'Автор';
article.dataset.searchbody = (post.body || 'Без содержания').substring(0, 200);
// Header
const header = this.createPostHeader(post);
article.appendChild(header);
// Image
if (post.image) {
const imageDiv = this.createPostImage(post);
article.appendChild(imageDiv);
}
// Body
const body = this.createPostBody(post);
article.appendChild(body);
// Footer
const footer = this.createPostFooter(post);
article.appendChild(footer);
return article;
}
/**
* Создание заголовка поста
*/
createPostHeader(post) {
const header = document.createElement('header');
header.className = 'card-header';
// Avatar
const iconDiv = document.createElement('div');
iconDiv.className = 'card-icon d-flex align-items-center justify-content-center';
if (post.author?.avatar) {
const img = document.createElement('img');
img.src = post.author.avatar;
img.alt = '';
img.loading = 'lazy';
iconDiv.appendChild(img);
} else {
const icon = document.createElement('i');
icon.className = 'bi-person';
iconDiv.appendChild(icon);
}
header.appendChild(iconDiv);
// Meta
const metaDiv = document.createElement('div');
metaDiv.className = 'card-meta';
const titleDiv = document.createElement('div');
titleDiv.className = 'card-title';
if (post.author?.id) {
const authorLink = document.createElement('a');
authorLink.href = `/employees/${post.author.id}/`;
authorLink.className = 'text-decoration-none';
authorLink.textContent = post.author.full_name || 'Сотрудник';
titleDiv.appendChild(authorLink);
} else {
titleDiv.textContent = 'Автор не указан';
}
metaDiv.appendChild(titleDiv);
// Subtitle
const subtitleDiv = document.createElement('div');
subtitleDiv.className = 'card-subtitle';
subtitleDiv.textContent = post.created_at_display || '';
if (post.type === 'department' && post.department?.name) {
subtitleDiv.textContent += ` · ${post.department.name}`;
}
metaDiv.appendChild(subtitleDiv);
header.appendChild(metaDiv);
// Pin indicator
if (post.pinned) {
const pin = document.createElement('span');
pin.className = 'feed-pin';
pin.innerHTML = '<i class="bi-pin-angle"></i>';
header.appendChild(pin);
}
return header;
}
/**
* Создание блока изображения
*/
createPostImage(post) {
const div = document.createElement('div');
div.className = 'feed-img';
const link = document.createElement('a');
link.href = '#';
link.dataset.postLink = '';
link.dataset.postId = post.id;
link.className = 'stretched-link';
const img = document.createElement('img');
img.src = post.image;
img.alt = post.title || 'Публикация';
img.loading = 'lazy';
img.decoding = 'async';
div.appendChild(link);
div.appendChild(img);
return div;
}
/**
* Создание тела поста
*/
createPostBody(post) {
const div = document.createElement('div');
div.className = 'card-body';
// Title
const title = document.createElement('h3');
title.className = 'feed-title mb-1';
const titleLink = document.createElement('a');
titleLink.href = '#';
titleLink.dataset.postLink = '';
titleLink.dataset.postId = post.id;
titleLink.className = 'text-decoration-none stretched-link';
titleLink.textContent = post.title || 'Без названия';
title.appendChild(titleLink);
div.appendChild(title);
// Body text (truncated)
if (post.body) {
const textDiv = document.createElement('div');
textDiv.className = 'feed-text mb-1';
const words = post.body.split(/\s+/);
if (words.length > 42) {
textDiv.textContent = words.slice(0, 42).join(' ') + '...';
} else {
textDiv.innerHTML = post.body.replace(/\n/g, '<br>');
}
div.appendChild(textDiv);
}
return div;
}
/**
* Создание футера поста
*/
createPostFooter(post) {
const footer = document.createElement('footer');
footer.className = 'feed-ft';
const statsDiv = document.createElement('div');
statsDiv.className = 'feed-stats';
// Likes
const likeBtn = document.createElement('button');
likeBtn.className = 'btn btn-link p-0';
likeBtn.dataset.action = 'like';
likeBtn.dataset.postId = post.id;
const likeIcon = document.createElement('i');
likeIcon.className = post.is_liked ? 'bi-heart-fill text-danger' : 'bi-heart';
likeBtn.appendChild(likeIcon);
likeBtn.appendChild(document.createTextNode(` ${post.likes_count || 0}`));
statsDiv.appendChild(likeBtn);
// Comments
if (post.comments_count > 0) {
const commentBtn = document.createElement('button');
commentBtn.className = 'btn btn-link p-0 ms-3';
commentBtn.dataset.postLink = '';
commentBtn.dataset.postId = post.id;
const commentIcon = document.createElement('i');
commentIcon.className = 'bi-chat-dots';
commentBtn.appendChild(commentIcon);
commentBtn.appendChild(document.createTextNode(` ${post.comments_count}`));
statsDiv.appendChild(commentBtn);
}
footer.appendChild(statsDiv);
return footer;
}
/**
* Показать лоадер
*/
showLoader() {
let loader = document.getElementById('feedLoader');
if (!loader) {
loader = document.createElement('div');
loader.id = 'feedLoader';
loader.className = 'text-center py-4';
loader.innerHTML = `
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Загрузка...</span>
</div>
`;
this.container.parentElement.appendChild(loader);
}
loader.style.display = 'block';
}
/**
* Скрыть лоадер
*/
hideLoader() {
const loader = document.getElementById('feedLoader');
if (loader) {
loader.style.display = 'none';
}
}
/**
* Показать ошибку
*/
showError(message) {
const alert = document.createElement('div');
alert.className = 'alert alert-danger shadow-sm mt-2';
alert.textContent = message;
this.container.innerHTML = '';
this.container.appendChild(alert);
}
/**
* Обработчик прокрутки для бесконечной загрузки
*/
attachScrollHandler() {
let ticking = false;
window.addEventListener('scroll', () => {
if (!ticking) {
window.requestAnimationFrame(() => {
this.checkScrollPosition();
ticking = false;
});
ticking = true;
}
});
}
/**
* Проверка позиции прокрутки
*/
checkScrollPosition() {
if (this.isLoading || !this.hasMore) return;
const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
const windowHeight = window.innerHeight;
const documentHeight = document.documentElement.scrollHeight;
// Загружаем следующую страницу за 500px до конца
if (scrollTop + windowHeight >= documentHeight - 500) {
this.loadPosts(this.currentPage + 1);
}
}
/**
* Обновление ленты
*/
refresh() {
this.currentPage = 1;
this.hasMore = true;
this.posts = [];
this.loadPosts(1);
}
}

View File

@ -75,33 +75,10 @@
<div class="card-header">
</div>
<div id="feedList">
{% if posts %}
{% include "feed/_feed_cards.html" with items=posts %}
{% else %}
<div class="alert alert-info shadow-sm mt-2">Пока нет новостей.</div>
{% endif %}
{# Посты загружаются через JavaScript из feedList.js #}
</div>
</section>
{# Пагинация #}
{% if next_url or prev_url %}
<div class="d-flex justify-content-between align-items-center mt-3">
<a class="btn btn-outline-secondary {% if not prev_url %}disabled{% endif %}"
href="{{ prev_url|default:'#' }}"
{% if not prev_url %}aria-disabled="true"{% endif %}>
<i class="bi-arrow-left"></i> Назад
</a>
{% if count %}
<span class="text-body-secondary small">Всего: {{ count }}</span>
{% endif %}
<a class="btn btn-outline-secondary {% if not next_url %}disabled{% endif %}"
href="{{ next_url|default:'#' }}"
{% if not next_url %}aria-disabled="true"{% endif %}>
Вперёд <i class="bi-arrow-right"></i>
</a>
</div>
{% endif %}
</div>
</div>
</div>
@ -119,6 +96,7 @@
<script type="module">
import { initAutoScroller } from "{% static 'js/components/autoScrollerHandler.js' %}";
import { initCreatePostModal } from "{% static 'js/components/createPostModal.js' %}";
import { FeedList } from '{% static "js/components/feedList.js" %}';
import { ListFilter, createDataAttrMatcher } from '{% static "js/components/listFilter.js" %}';
document.addEventListener('DOMContentLoaded', () => {
@ -128,10 +106,29 @@
});
// Инициализация модала создания публикации
initCreatePostModal({
const createModal = initCreatePostModal({
apiUrl: '{% url "api:v1:posts-list" %}'
});
// ✨ Новая загрузка ленты через API
window.feedList = new FeedList({
containerId: 'feedList',
apiUrl: '{% url "api:v1:posts-list" %}',
params: { type: 'company' }
});
// Обновляем ленту после создания поста
if (createModal) {
document.getElementById('createPostForm')?.addEventListener('submit', async (e) => {
// Ждем немного и обновляем ленту
setTimeout(() => {
if (window.feedList) {
window.feedList.refresh();
}
}, 1000);
});
}
// Инициализация поиска по публикациям
new ListFilter({
listSelector: '#feedList',