From 0144cd38de141da08a9b43a4151f1b46a8f8d733 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=98=D0=B3=D0=BE=D1=80=D1=8C=20=D0=9D=D0=B0=D0=B4=D0=B5?= =?UTF-8?q?=D0=B8=D0=BD?= Date: Fri, 5 Dec 2025 10:35:47 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=BF=D0=B5=D1=80=D0=B5=D0=B4=D0=B5?= =?UTF-8?q?=D0=BB=D0=B0=D0=BD=D0=B0=20=D0=BB=D0=B5=D0=BD=D1=82=D0=B0=20?= =?UTF-8?q?=D0=BD=D0=B0=20AJAX=20=D0=B7=D0=B0=D0=B3=D1=80=D1=83=D0=B7?= =?UTF-8?q?=D0=BA=D1=83=20=D1=87=D0=B5=D1=80=D0=B5=D0=B7=20API=20=D1=81=20?= =?UTF-8?q?infinite=20scroll?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Создан модуль feedList.js для клиентской загрузки постов - Посты загружаются напрямую из браузера через API - Медиа URL формируются правильно (как в модале) - Добавлен infinite scroll для автоматической подгрузки - Упрощен view feed_list - только новые сотрудники с сервера - Убрана серверная пагинация, заменена на клиентскую --- backend/feed/views_front.py | 38 +-- backend/static/js/components/feedList.js | 401 +++++++++++++++++++++++ backend/templates/feed/feed_list.html | 47 ++- 3 files changed, 426 insertions(+), 60 deletions(-) create mode 100644 backend/static/js/components/feedList.js diff --git a/backend/feed/views_front.py b/backend/feed/views_front.py index 0f2a9b6d..9eb41004 100644 --- a/backend/feed/views_front.py +++ b/backend/feed/views_front.py @@ -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, }, ) diff --git a/backend/static/js/components/feedList.js b/backend/static/js/components/feedList.js new file mode 100644 index 00000000..8dfbb807 --- /dev/null +++ b/backend/static/js/components/feedList.js @@ -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 = ` +
+ Пока нет новостей. +
+ `; + 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 = ''; + 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, '
'); + } + + 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 = ` +
+ Загрузка... +
+ `; + 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); + } +} diff --git a/backend/templates/feed/feed_list.html b/backend/templates/feed/feed_list.html index fe91e1b7..2aed6b10 100644 --- a/backend/templates/feed/feed_list.html +++ b/backend/templates/feed/feed_list.html @@ -75,33 +75,10 @@
- {% if posts %} - {% include "feed/_feed_cards.html" with items=posts %} - {% else %} -
Пока нет новостей.
- {% endif %} + {# Посты загружаются через JavaScript из feedList.js #}
- {# Пагинация #} - {% if next_url or prev_url %} -
- - Назад - - {% if count %} - Всего: {{ count }} - {% endif %} - - Вперёд - -
- {% endif %} - @@ -119,6 +96,7 @@