PageSourceSearch

https://predanie.ru/js/media-favorites.js?v=v5.3.147

js predanie.ru collected 2026-10-01 10:24:20 UTC 27,481 bytes, 650 lines download raw bytes

1/**
2 * ============================================================================
3 * МЕДИАТЕКА: Избранное и Посмотрю позже
4 * ============================================================================
5 *
6 * Модуль для работы с work-level списками материалов.
7 * Данные хранятся в localStorage браузера в ключах:
8 * - `media_favorites`
9 * - `media_watch_later`
10 *
11 * Важно не путать с file-level списками из `media-player-core.js`:
12 * - `predanie_favorites`
13 * - `predanie_watch_later`
14 *
15 * Здесь живут именно произведения целиком (works), а не отдельные треки/файлы.
16 *
17 * API (window.MediaFavorites):
18 * ----------------------------
19 * getFavorites() / getWatchLater()           → Array
20 * addToFavorites(item) / addToWatchLater()   → Array
21 * removeFromFavorites(id) / removeFromWatchLater() → Array
22 * isInFavorites(id) / isInWatchLater()       → Boolean
23 * toggleFavorite(item) / toggleWatchLater()  → Boolean
24 * getFavoritesCount() / getWatchLaterCount() → Number
25 */
26
27(function () {
28  'use strict';
29
30  // ═══════════════════════════════════════════════════════════════════════════
31  // КОНСТАНТЫ
32  // ═══════════════════════════════════════════════════════════════════════════
33
34  const STORAGE_KEYS = {
35    favorites: 'media_favorites',
36    watchLater: 'media_watch_later',
37  };
38  
39  // Ключи плеера для треков/файлов
40  const PLAYER_STORAGE_KEYS = {
41    favorites: 'predanie_favorites',
42    watchLater: 'predanie_watch_later',
43  };
44
45  /**
46   * Нормализует входной объект произведения к компактному storage-формату.
47   *
48   * Подходит и для:
49   * - новых объектов из UI
50   * - старых жирных объектов, оставшихся в localStorage
51   */
52  function toCompactWorkItem(item) {
53    if (!item || typeof item !== 'object') return null;
54    const itemId = item.id || item.workId;
55    if (!itemId) return null;
56
57    return {
58      id: itemId,
59      title: item.title || item.workTitle || '',
60      href: item.href || '',
61      thumbUrl: item.thumbUrl || item.coverUrl || '',
62      addedAt: item.addedAt || null,
63    };
64  }
65
66  /** Безопасно считает длину file-level списка из localStorage. */
67  function getPlayerListCount(key) {
68    try {
69      const parsed = JSON.parse(localStorage.getItem(key)) || [];
70      return Array.isArray(parsed) ? parsed.length : 0;
71    } catch (e) {
72      return 0;
73    }
74  }
75
76  // ═══════════════════════════════════════════════════════════════════════════
77  // РАБОТА С LOCALSTORAGE
78  // ═══════════════════════════════════════════════════════════════════════════
79
80  /** Получить список из localStorage */
81  function getItems(key) {
82    try {
83      const raw = JSON.parse(localStorage.getItem(key)) || [];
84      if (!Array.isArray(raw)) return [];
85      return raw
86        .map(normalizeStoredItem)
87        .filter(Boolean);
88    } catch (e) {
89      return [];
90    }
91  }
92
93  /**
94   * Нормализует и ужимает сохранённый объект произведения.
95   * Храним только поля, реально нужные для UI и синхронизации.
96   */
97  function normalizeStoredItem(item) {
98    return toCompactWorkItem(item);
99  }
100
101  /** Сохранить список в localStorage */
102  function saveItems(key, items) {
103    try {
104      const compactItems = (Array.isArray(items) ? items : [])
105        .map(normalizeStoredItem)
106        .filter(Boolean);
107      localStorage.setItem(key, JSON.stringify(compactItems));
108    } catch (e) {
109      console.error('[MediaFavorites] Storage error:', e);
110    }
111  }
112
113  /** Проверить наличие элемента */
114  function isInList(key, itemId) {
115    return getItems(key).some(item => item.id === itemId);
116  }
117
118  /** Добавить элемент (в начало) */
119  function addToList(key, itemData) {
120    const items = getItems(key);
121    if (!items.some(item => item.id === itemData.id)) {
122      items.unshift({
123        ...toCompactWorkItem(itemData),
124        addedAt: new Date().toISOString(),
125      });
126      saveItems(key, items);
127      updateSidebarCounters();
128    }
129    return items;
130  }
131
132  /** Удалить элемент */
133  function removeFromList(key, itemId) {
134    const items = getItems(key).filter(item => item.id !== itemId);
135    saveItems(key, items);
136    updateSidebarCounters();
137    return items;
138  }
139
140  /** Переключить (добавить/удалить) */
141  function toggleInList(key, itemData) {
142    if (isInList(key, itemData.id)) {
143      removeFromList(key, itemData.id);
144      return false;
145    }
146    addToList(key, itemData);
147    return true;
148  }
149
150  /** Обновить счётчики в сайдбаре (произведения + файлы/треки) */
151  function updateSidebarCounters() {
152    // Произведения (MediaFavorites)
153    const worksInFav = getItems(STORAGE_KEYS.favorites).length;
154    const worksInWL = getItems(STORAGE_KEYS.watchLater).length;
155    
156    // Файлы/треки (плеер) — отдельные file-level списки из media-player-core.js.
157    const tracksInFav = getPlayerListCount(PLAYER_STORAGE_KEYS.favorites);
158    const tracksInWL = getPlayerListCount(PLAYER_STORAGE_KEYS.watchLater);
159    
160    // Суммируем
161    const favCount = worksInFav + tracksInFav;
162    const wlCount = worksInWL + tracksInWL;
163    
164    const favEl = document.getElementById('sidebarFavoritesCount');
165    const wlEl = document.getElementById('sidebarWatchLaterCount');
166    
167    if (favEl) {
168      favEl.textContent = favCount > 0 ? favCount : '';
169      favEl.classList.toggle('has-items', favCount > 0);
170    }
171    
172    if (wlEl) {
173      wlEl.textContent = wlCount > 0 ? wlCount : '';
174      wlEl.classList.toggle('has-items', wlCount > 0);
175    }
176  }
177
178  // ═══════════════════════════════════════════════════════════════════════════
179  // ПУБЛИЧНОЕ API
180  // ═══════════════════════════════════════════════════════════════════════════
181
182  window.MediaFavorites = {
183    // Избранное
184    getFavorites: () => getItems(STORAGE_KEYS.favorites),
185    addToFavorites: item => addToList(STORAGE_KEYS.favorites, item),
186    removeFromFavorites: id => removeFromList(STORAGE_KEYS.favorites, id),
187    isInFavorites: id => isInList(STORAGE_KEYS.favorites, id),
188    toggleFavorite: item => toggleInList(STORAGE_KEYS.favorites, item),
189    getFavoritesCount: () => getItems(STORAGE_KEYS.favorites).length,
190
191    // Посмотрю позже
192    getWatchLater: () => getItems(STORAGE_KEYS.watchLater),
193    addToWatchLater: item => addToList(STORAGE_KEYS.watchLater, item),
194    removeFromWatchLater: id => removeFromList(STORAGE_KEYS.watchLater, id),
195    isInWatchLater: id => isInList(STORAGE_KEYS.watchLater, id),
196    toggleWatchLater: item => toggleInList(STORAGE_KEYS.watchLater, item),
197    getWatchLaterCount: () => getItems(STORAGE_KEYS.watchLater).length,
198    
199    // Обновить счётчики в сайдбаре
200    updateSidebarCounters: updateSidebarCounters,
201  };
202
203  // ═══════════════════════════════════════════════════════════════════════════
204  // РАБОТА С DOM
205  // ═══════════════════════════════════════════════════════════════════════════
206
207  /** Извлечь данные из data-атрибутов карточки */
208  function getItemDataFromCard(card) {
209    return {
210      id: card.dataset.itemId,
211      title: card.dataset.itemTitle || 'Без названия',
212      href: card.dataset.itemHref || '#',
213      thumbUrl: card.dataset.itemThumb || '',
214      author: card.dataset.itemAuthor || '',
215      type: card.dataset.itemType || 'lectures',
216      description: card.dataset.itemDescription || '',
217    };
218  }
219
220  /** Обновить состояние кнопок на карточке */
221  function updateCardState(card) {
222    const itemId = card.dataset.itemId;
223    if (!itemId) return;
224
225    const inFav = window.MediaFavorites.isInFavorites(itemId);
226    const inWatch = window.MediaFavorites.isInWatchLater(itemId);
227
228    // Кнопка избранного
229    const favBtn = card.querySelector('.stroki-card-icon-btn--favorite');
230    if (favBtn) {
231      favBtn.classList.toggle('active', inFav);
232      const icon = favBtn.querySelector('i');
233      if (icon)
234        icon.className = inFav ? 'fa-solid fa-heart' : 'fa-regular fa-heart';
235      favBtn.title = inFav ? 'Удалить из избранного' : 'В избранное';
236    }
237
238    // Кнопка "Посмотрю позже" (иконка)
239    const watchBtn = card.querySelector('.stroki-card-icon-btn--watch-later');
240    if (watchBtn) {
241      watchBtn.classList.toggle('active', inWatch);
242      const icon = watchBtn.querySelector('i');
243      if (icon)
244        icon.className = inWatch
245          ? 'fa-solid fa-bookmark'
246          : 'fa-regular fa-bookmark';
247      watchBtn.title = inWatch
248        ? 'Удалить из "Посмотрю позже"'
249        : 'Посмотрю позже';
250    }
251
252    // Кнопка "Позже" в оверлее
253    const watchBtnOverlay = card.querySelector('.stroki-card-btn--watch-later');
254    if (watchBtnOverlay) {
255      watchBtnOverlay.classList.toggle('active', inWatch);
256      const icon = watchBtnOverlay.querySelector('i');
257      if (icon)
258        icon.className = inWatch
259          ? 'fa-solid fa-bookmark'
260          : 'fa-regular fa-bookmark';
261      const text = watchBtnOverlay.lastChild;
262      if (text?.nodeType === Node.TEXT_NODE) {
263        text.textContent = inWatch ? ' Отложено' : ' Позже';
264      }
265    }
266  }
267
268  /** Обновить все карточки */
269  function updateAllCards() {
270    document
271      .querySelectorAll('.stroki-card[data-item-id]')
272      .forEach(updateCardState);
273  }
274
275  /** Обновить счётчики в навигации */
276  function updateNavCounters() {
277    const fav = window.MediaFavorites.getFavoritesCount();
278    const watch = window.MediaFavorites.getWatchLaterCount();
279
280    document.querySelectorAll('.media-favorites-count').forEach(el => {
281      el.textContent = fav;
282      el.style.display = fav > 0 ? '' : 'none';
283    });
284
285    document.querySelectorAll('.media-watch-later-count').forEach(el => {
286      el.textContent = watch;
287      el.style.display = watch > 0 ? '' : 'none';
288    });
289  }
290
291  // ═══════════════════════════════════════════════════════════════════════════
292  // TOAST-УВЕДОМЛЕНИЯ
293  // ═══════════════════════════════════════════════════════════════════════════
294
295  function showToast(message) {
296    const existing = document.querySelector('.media-toast');
297    if (existing) existing.remove();
298
299    const toast = document.createElement('div');
300    toast.className = 'media-toast';
301    toast.textContent = message;
302    document.body.appendChild(toast);
303
304    requestAnimationFrame(() => toast.classList.add('show'));
305    setTimeout(() => {
306      toast.classList.remove('show');
307      setTimeout(() => toast.remove(), 300);
308    }, 2000);
309  }
310
311  // ═══════════════════════════════════════════════════════════════════════════
312  // ОБРАБОТЧИКИ СОБЫТИЙ
313  // ═══════════════════════════════════════════════════════════════════════════
314
315  function handleFavoriteClick(e) {
316    e.preventDefault();
317    e.stopPropagation();
318
319    const card = e.currentTarget.closest('.stroki-card');
320    if (!card) return;
321
322    const itemData = getItemDataFromCard(card);
323    const added = window.MediaFavorites.toggleFavorite(itemData);
324
325    updateCardState(card);
326    updateNavCounters();
327    showToast(added ? 'Добавлено в избранное' : 'Удалено из избранного');
328  }
329
330  function handleWatchLaterClick(e) {
331    e.preventDefault();
332    e.stopPropagation();
333
334    const card = e.currentTarget.closest('.stroki-card');
335    if (!card) return;
336
337    const itemData = getItemDataFromCard(card);
338    const added = window.MediaFavorites.toggleWatchLater(itemData);
339
340    updateCardState(card);
341    updateNavCounters();
342    showToast(
343      added ? 'Добавлено в "Посмотрю позже"' : 'Удалено из "Посмотрю позже"',
344    );
345  }
346
347  function handleRemoveFromList(e) {
348    e.preventDefault();
349    e.stopPropagation();
350
351    const btn = e.currentTarget;
352    const card = btn.closest('.stroki-card');
353    const listType = btn.dataset.listType;
354    const itemId = card?.dataset.itemId;
355
356    if (!card || !itemId) return;
357
358    // Удаляем из списка
359    if (listType === 'favorites') {
360      window.MediaFavorites.removeFromFavorites(itemId);
361      showToast('Удалено из избранного');
362    } else if (listType === 'watchLater') {
363      window.MediaFavorites.removeFromWatchLater(itemId);
364      showToast('Удалено из "Посмотрю позже"');
365    }
366
367    // Анимация удаления
368    card.style.transition = 'opacity 0.3s, transform 0.3s';
369    card.style.opacity = '0';
370    card.style.transform = 'scale(0.9)';
371
372    setTimeout(() => {
373      card.remove();
374      updateNavCounters();
375
376      // Проверяем пустой список
377      const grid = document.querySelector('.stroki-grid');
378      const empty = document.querySelector('.media-list-empty');
379      if (grid && empty && grid.querySelectorAll('.stroki-card').length === 0) {
380        grid.style.display = 'none';
381        empty.style.display = '';
382      }
383    }, 300);
384  }
385
386  // ═══════════════════════════════════════════════════════════════════════════
387  // МОБИЛЬНЫЙ КЛИК ПО КАРТОЧКЕ
388  // На мобильных устройствах и планшетах клик по карточке сразу переходит на страницу
389  // ═══════════════════════════════════════════════════════════════════════════
390
391  /**
392   * Определяет, нужно ли обрабатывать клик по карточке через JS.
393   * true для устройств без hover (мобильные, планшеты) — на них
394   * CSS-оверлей невидим и некликабелен, поэтому нужен JS-переход.
395   */
396  function shouldNavigateOnCardClick() {
397    // Устройство без hover-поддержки (touch-планшеты, телефоны)
398    if (window.matchMedia && !window.matchMedia('(hover: hover)').matches) return true;
399    // Фолбэк: маленький экран
400    return window.innerWidth <= 767;
401  }
402
403  function handleMobileCardClick(e) {
404    if (!shouldNavigateOnCardClick()) return;
405
406    // Не перехватываем клики по кнопкам
407    if (e.target.closest('.stroki-card-icon-btn, .stroki-card-btn, button')) return;
408
409    const card = e.currentTarget;
410    const href = card.dataset.itemHref;
411    if (href) {
412      window.location.href = href;
413    }
414  }
415
416  // ═══════════════════════════════════════════════════════════════════════════
417  // СТРАНИЦА РАБОТЫ: ИКОНКИ ДЕЙСТВИЙ
418  // ═══════════════════════════════════════════════════════════════════════════
419
420  /** Получить данные работы со страницы */
421  function getWorkDataFromPage() {
422    const container = document.querySelector('.container[data-work-id]');
423    if (!container) return null;
424
425    // Собираем данные из страницы
426    const workId = container.dataset.workId;
427    const workTitle = container.dataset.workTitle || document.querySelector('.work-title')?.textContent || 'Без названия';
428    const author = document.querySelector('.work-author a')?.textContent || '';
429    const thumbUrl = document.querySelector('.work-cover-image img')?.src || '';
430    
431    return {
432      id: workId,
433      title: workTitle,
434      href: window.location.pathname,
435      thumbUrl: thumbUrl,
436      author: author,
437      type: 'work',
438      description: '',
439    };
440  }
441
442  /** Обновить состояние иконок на странице работы */
443  function updateWorkPageIcons() {
444    const container = document.querySelector('.container[data-work-id]');
445    if (!container) return;
446
447    const workId = container.dataset.workId;
448    if (!workId) return;
449
450    const inFav = window.MediaFavorites.isInFavorites(workId);
451    const inWatch = window.MediaFavorites.isInWatchLater(workId);
452
453    // Кнопка избранного (поддерживаем оба класса: старый и новый)
454    const favBtn = document.querySelector('.work-action-btn--favorite, .work-action-icon--favorite');
455    if (favBtn) {
456      favBtn.classList.toggle('active', inFav);
457      const icon = favBtn.querySelector('i');
458      if (icon) icon.className = inFav ? 'fa-solid fa-heart' : 'fa-regular fa-heart';
459      favBtn.title = inFav ? 'Удалить из избранного' : 'В избранное';
460      // Обновляем текст если есть span
461      const textSpan = favBtn.querySelector('span');
462      if (textSpan) textSpan.textContent = inFav ? 'В избранном' : 'В избранное';
463    }
464
465    // Кнопка "Позже" (поддерживаем оба класса)
466    const watchBtn = document.querySelector('.work-action-btn--later, .work-action-icon--later');
467    if (watchBtn) {
468      watchBtn.classList.toggle('active', inWatch);
469      const icon = watchBtn.querySelector('i');
470      if (icon) icon.className = inWatch ? 'fa-solid fa-clock' : 'fa-regular fa-clock';
471      watchBtn.title = inWatch ? 'Удалить из "Позже"' : 'Позже';
472      // Обновляем текст если есть span
473      const textSpan = watchBtn.querySelector('span');
474      if (textSpan) textSpan.textContent = inWatch ? 'Добавлено' : 'Позже';
475    }
476  }
477
478  function handleWorkFavoriteClick(e) {
479    e.preventDefault();
480    e.stopPropagation();
481
482    const itemData = getWorkDataFromPage();
483    if (!itemData) return;
484
485    const added = window.MediaFavorites.toggleFavorite(itemData);
486    updateWorkPageIcons();
487    updateNavCounters();
488    showToast(added ? 'Добавлено в избранное' : 'Удалено из избранного');
489  }
490
491  function handleWorkWatchLaterClick(e) {
492    e.preventDefault();
493    e.stopPropagation();
494
495    const itemData = getWorkDataFromPage();
496    if (!itemData) return;
497
498    const added = window.MediaFavorites.toggleWatchLater(itemData);
499    updateWorkPageIcons();
500    updateNavCounters();
501    showToast(added ? 'Добавлено в "Позже"' : 'Удалено из "Позже"');
502  }
503
504  // ═══════════════════════════════════════════════════════════════════════════
505  // ГЛАВНАЯ СТРАНИЦА: КАРТОЧКИ МЕДИАТЕКИ
506  // ═══════════════════════════════════════════════════════════════════════════
507
508  /** Получить данные из карточки медиатеки на главной */
509  function getItemDataFromFeedCard(container) {
510    return {
511      id: container.da
511taset.workId,
512      title: container.dataset.workTitle || 'Без названия',
513      href: container.dataset.workHref || '#',
514      thumbUrl: container.dataset.workThumb || '',
515      author: container.dataset.workAuthor || '',
516      type: 'work',
517      description: '',
518    };
519  }
520
521  /** Обновить состояние кнопок на карточке главной страницы */
522  function updateFeedCardState(container) {
523    const workId = container.dataset.workId;
524    if (!workId) return;
525
526    const inFav = window.MediaFavorites.isInFavorites(workId);
527    const inWatch = window.MediaFavorites.isInWatchLater(workId);
528
529    const favBtn = container.querySelector('.feed-card__media-btn--favorite');
530    if (favBtn) {
531      favBtn.classList.toggle('active', inFav);
532      const icon = favBtn.querySelector('i');
533      if (icon) icon.className = inFav ? 'fa-solid fa-heart' : 'fa-regular fa-heart';
534      favBtn.title = inFav ? 'Удалить из избранного' : 'В избранное';
535    }
536
537    const watchBtn = container.querySelector('.feed-card__media-btn--later');
538    if (watchBtn) {
539      watchBtn.classList.toggle('active', inWatch);
540      const icon = watchBtn.querySelector('i');
541      if (icon) icon.className = inWatch ? 'fa-solid fa-clock' : 'fa-regular fa-clock';
542      watchBtn.title = inWatch ? 'Удалить из "Позже"' : 'Позже';
543    }
544  }
545
546  /** Обновить все карточки на главной */
547  function updateAllFeedCards() {
548    document.querySelectorAll('.feed-card__media-actions[data-work-id]').forEach(updateFeedCardState);
549  }
550
551  function handleFeedFavoriteClick(e) {
552    e.preventDefault();
553    e.stopPropagation();
554
555    const container = e.currentTarget.closest('.feed-card__media-actions');
556    if (!container) return;
557
558    const itemData = getItemDataFromFeedCard(container);
559    const added = window.MediaFavorites.toggleFavorite(itemData);
560
561    updateFeedCardState(container);
562    updateNavCounters();
563    showToast(added ? 'Добавлено в избранное' : 'Удалено из избранного');
564  }
565
566  function handleFeedWatchLaterClick(e) {
567    e.preventDefault();
568    e.stopPropagation();
569
570    const container = e.currentTarget.closest('.feed-card__media-actions');
571    if (!container) return;
572
573    const itemData = getItemDataFromFeedCard(container);
574    const added = window.MediaFavorites.toggleWatchLater(itemData);
575
576    updateFeedCardState(container);
577    updateNavCounters();
578    showToast(added ? 'Добавлено в "Позже"' : 'Удалено из "Позже"');
579  }
580
581  // ═══════════════════════════════════════════════════════════════════════════
582  // ИНИЦИАЛИЗАЦИЯ
583  // ═══════════════════════════════════════════════════════════════════════════
584
585  function init() {
586    updateAllCards();
587    updateAllFeedCards();
588    updateWorkPageIcons();
589    updateNavCounters();
590    updateSidebarCounters();
591
592    // Кнопки избранного
593    document
594      .querySelectorAll('.stroki-card-icon-btn--favorite')
595      .forEach(btn => {
596        btn.addEventListener('click', handleFavoriteClick);
597      });
598
599    // Кнопки "Посмотрю позже"
600    document
601      .querySelectorAll('.stroki-card-icon-btn--watch-later')
602      .forEach(btn => {
603        btn.addEventListener('click', handleWatchLaterClick);
604      });
605
606    document.querySelectorAll('.stroki-card-btn--watch-later').forEach(btn => {
607      btn.addEventListener('click', handleWatchLaterClick);
608    });
609
610    // Кнопки удаления
611    document.querySelectorAll('.stroki-card-remove-btn').forEach(btn => {
612      btn.addEventListener('click', handleRemoveFromList);
613    });
614
615    // Мобильный / планшетный клик по карточке — сразу переходим на страницу материала.
616    // Используем event delegation чтобы ловить клики и по динамически подгруженным карточкам.
617    document.addEventListener('click', function(e) {
618      const card = e.target.closest('.stroki-card');
619      if (!card) return;
620      handleMobileCardClick({ target: e.target, currentTarget: card, preventDefault: e.preventDefault.bind(e) });
621    });
622
623    // Кнопки на странице работы (поддерживаем оба класса)
624    const workFavBtn = document.querySelector('.work-action-btn--favorite, .work-action-icon--favorite');
625    if (workFavBtn) {
626      workFavBtn.addEventListener('click', handleWorkFavoriteClick);
627    }
628
629    const workWatchBtn = document.querySelector('.work-action-btn--later, .work-action-icon--later');
630    if (workWatchBtn) {
631      workWatchBtn.addEventListener('click', handleWorkWatchLaterClick);
632    }
633
634    // Кнопки на главной странице (карточки медиатеки)
635    document.querySelectorAll('.feed-card__media-btn--favorite').forEach(btn => {
636      btn.addEventListener('click', handleFeedFavoriteClick);
637    });
638
639    document.querySelectorAll('.feed-card__media-btn--later').forEach(btn => {
640      btn.addEventListener('click', handleFeedWatchLaterClick);
641    });
642  }
643
644  // Запуск
645  if (document.readyState === 'loading') {
646    document.addEventListener('DOMContentLoaded', init);
647  } else {
648    init();
649  }
650})();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.