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 @@