feat: переделана лента на AJAX загрузку через API с infinite scroll
- Создан модуль feedList.js для клиентской загрузки постов - Посты загружаются напрямую из браузера через API - Медиа URL формируются правильно (как в модале) - Добавлен infinite scroll для автоматической подгрузки - Упрощен view feed_list - только новые сотрудники с сервера - Убрана серверная пагинация, заменена на клиентскую
This commit is contained in:
parent
32b7aba4dd
commit
421fdf878b
@ -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,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
401
backend/static/js/components/feedList.js
Normal file
401
backend/static/js/components/feedList.js
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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',
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user