PageSourceSearch

https://igroprom.online/wp-content/plugins/igroprom-stream-schedul…/js/stream-schedule.js?ver=1791160965

js igroprom.online collected 2026-10-05 00:45:39 UTC 30,006 bytes, 682 lines download raw bytes

1// Глобальная функция для добавления в календарь
2function issAddToCal(title, date, time, url) {
3    if (!date || typeof date !== 'string') return;
4    var startTime = time || '12:00';
5    var startDate = date.replace(/-/g, '');
6    var startHour = startTime.replace(':', '');
7
8    // Выпадающее меню
9    var menu = document.createElement('div');
10    menu.className = 'iss-cal-menu';
11    menu.innerHTML =
12        '<div class="iss-cal-menu-title">Добавить в календарь</div>' +
13        '<a href="https://calendar.google.com/calendar/render?action=TEMPLATE' +
14            '&text=' + encodeURIComponent('Стрим: ' + title) +
15            '&dates=' + startDate + 'T' + startHour + '00/' + startDate + 'T' + startHour + '00' +
16            '&details=' + encodeURIComponent('Ссылка на трансляцию: ' + url) +
17        '" target="_blank" class="iss-cal-menu-item">Google Calendar</a>' +
18        '<a href="https://calendar.yandex.ru/new?title=' + encodeURIComponent('Стрим: ' + title) +
19            '&from=' + startDate + 'T' + startHour + '00' +
20            '&to=' + startDate + 'T' + startHour + '00' +
21            '&description=' + encodeURIComponent('Ссылка на трансляцию: ' + url) +
22        '" target="_blank" class="iss-cal-menu-item">Яндекс Календарь</a>' +
23        '<a href="#" class="iss-cal-menu-item iss-cal-ics">Apple / Outlook</a>' +
24        '<button class="iss-cal-menu-close">&times;</button>';
25
26    document.body.appendChild(menu);
27
28    // .ics файл
29    menu.querySelector('.iss-cal-ics').addEventListener('click', function(e) {
30        e.preventDefault();
31        var ics = 'BEGIN:VCALENDAR\nVERSION:2.0\nBEGIN:VEVENT\n' +
32            'DTSTART:' + startDate + 'T' + startHour + '00\n' +
33            'SUMMARY:Стрим: ' + title + '\n' +
34            'DESCRIPTION:' + url + '\n' +
35            'END:VEVENT\nEND:VCALENDAR';
36        var blob = new Blob([ics], {type: 'text/calendar'});
37        var a = document.createElement('a');
38        a.href = URL.createObjectURL(blob);
39        a.download = 'stream.ics';
40        a.click();
41        menu.remove();
42    });
43
44    menu.querySelector('.iss-cal-menu-close').addEventListener('click', function() {
45        menu.remove();
46    });
47
48    setTimeout(function() {
49        document.addEventListener('click', function handler(e) {
50            if (!menu.contains(e.target)) { menu.remove(); document.removeEventListener('click', handler); }
51        });
52    }, 100);
53}
54
55(function ($) {
56    'use strict';
57
58    var MONTHS_RU = [
59        'Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь',
60        'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь'
61    ];
62
63    // SVG-иконки, используемые в карточках стрима. Объявлены в самом начале,
64    // чтобы быть доступными во всех обработчиках (включая submit формы).
65    var REMIND_SVG = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:4px"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>';
66    var PLAY_SVG = '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" style="vertical-align:-2px;margin-right:4px"><polygon points="5 3 19 12 5 21 5 3"/></svg>';
67    var TRASH_SVG = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>';
68
69    function escapeHtml(text) {
70        var div = document.createElement('div');
71        div.textContent = text;
72        return div.innerHTML;
73    }
74
75    // Возвращает URL только если он http(s) — иначе пустую строку.
76    // Защищает от javascript:/data:/vbscript: при вставке в href/src.
77    function safeUrl(url) {
78        if (typeof url !== 'string' || !url) return '';
79        var s = url.trim().toLowerCase();
80        if (s.indexOf('http://') === 0 || s.indexOf('https://') === 0) return url;
81        return '';
82    }
83
84    // ========================================
85    // Модальное окно «Буду стримить»
86    // ========================================
87
88    $(document).on('click', '.iss-btn-stream', function () {
89        var gameId = $(this).data('game-id');
90        $('#iss-modal-' + gameId).fadeIn(200);
91    });
92
93    $(document).on('click', '.iss-modal-close, .iss-modal-overlay', function () {
94        $(this).closest('.iss-modal').fadeOut(200);
95    });
96
97    $(document).on('submit', '.iss-stream-form', function (e) {
98        e.preventDefault();
99        var $form = $(this);
100        var $btn = $form.find('.iss-btn-submit');
101
102        var dateVal = $form.find('[name="stream_date"]').val();
103        var timeVal = $form.find('[name="stream_time"]').val();
104        if (!dateVal) {
105            alert('Укажите дату стрима');
106            $form.find('[name="stream_date"]').focus();
107            return;
108        }
109        if (!timeVal) {
110            alert('Укажите время стрима');
111            $form.find('[name="stream_time"]').focus();
112            return;
113        }
114
115        $btn.prop('disabled', true).text('Сохранение...');
116
117        $.post(issAjax.url, {
118            action: 'stream_create',
119            nonce: issAjax.nonce,
120            game_id: $form.data('game-id'),
121            festival: $form.data('festival'),
122            stream_date: $form.find('[name="stream_date"]').val(),
123            stream_time: $form.find('[name="stream_time"]').val(),
124            stream_url: $form.find('[name="stream_url"]').val()
125        }, function (res) {
126            if (res.success) {
127                $form.closest('.iss-modal').fadeOut(200);
128                $form[0].reset();
129
130                // Рендерим карточку через общую функцию — структура совпадает
131                // с серверным шорткодом (та же дата/время, тот же DOM).
132                var d = res.data;
133                var cardData = d.card || null;
134                var $container = $('.iss-game-streams');
135                if (!$container.length) {
136                    var $modal = $form.closest('.iss-modal');
137                    $container = $('<div class="iss-game-streams gray-section"><h3 class="iss-game-streams-title">Запланированные стримы</h3></div>');
138                    $modal.after($container);
139                }
140
141                if (cardData) {
142                    // На странице игры обложку игры не показываем (это ведь её же страница).
143                    var $newCard = $(renderStreamCard(cardData, false)).hide();
144                    insertCardSorted($container, $newCard, cardData);
145                    $newCard.slideDown(300);
146                }
147
148                // Обновить календарь, если на странице
149                if (window.issCalendar) {
150                    window.issCalendar.load();
151                }
152            } else {
153                alert(res.data || 'Ошибка');
154            }
155            $btn.prop('disabled', false).text('Запланировать');
156        }).fail(function () {
157            alert('Ошибка сети');
158            $btn.prop('disabled', false).text('Запланировать');
159        });
160    });
161
162    // ========================================
163    // Вкладки «Календарь» / «Мои стримы»
164    // ========================================
165
166    $(document).on('click', '.iss-tab', function () {
167        var tab = $(this).data('tab');
168        $('.iss-tab').removeClass('active');
169        $(this).addClass('active');
170        $('.iss-tab-content').hide();
171        $('#iss-tab-' + tab).show();
172
173        if (tab === 'my-streams') {
174            loadMyStreams();
175        }
176    });
177
178    function loadMyStreams() {
179        var $list = $('.iss-my-streams-list');
180        var $empty = $('.iss-my-streams-empty');
181        $list.html('<div style="text-align:center;padding:20px;color:#999;">Загрузка...</div>');
182        $empty.hide();
183
184        $.post(issAjax.url, {
185            action: 'stream_my_streams',
186            nonce: issAjax.nonce
187        }, function (res) {
188            $list.empty();
189            var items = (res && res.success && Array.isArray(res.data)) ? res.data : [];
190            if (items.length > 0) {
191                items.forEach(function (s) {
192                    $list.append(renderStreamCard(s, true));
193                });
194            } else {
195                $list.empty();
196                $empty.show();
197            }
198        });
199    }
200
201    // ========================================
202    // Кнопка «Напомнить» — добавить в календарь
203    // ========================================
204
205    $(document).on('click', '.iss-stream-plan-btn', function () {
206        var $btn = $(this);
207        issAddToCal(
208            $btn.data('game-title') || '',
209            $btn.data('date') || '',
210            $btn.data('time') || '',
211            $btn.data('url') || ''
212        );
213    });
214
215    // ========================================
216    // Отмена стрима
217    // ========================================
218
219    $(document).on('click', '.iss-stream-cancel', function () {
220        if (!confirm('Отменить стрим?')) return;
221        var streamId = $(this).data('stream-id');
222        var $card = $(this).closest('.iss-stream-card');
223
224        $.post(issAjax.url, {
225            action: 'stream_cancel',
226            nonce: issAjax.nonce,
227            stream_id: streamId
228        }, function (res) {
229            if (!res.success) {
230                alert(res.data || 'Ошибка');
231                return;
232            }
233            // Сервер мог физически удалить стрим (отмена в окне iss_delete_hours)
234            // — в этом случае убираем карточку целиком.
235            if (res.data && res.data.deleted) {
236                $card.slideUp(300, function () { $(this).remove(); });
237                if (window.issCalendar) window.issCalendar.load();
238                return;
239            }
240            $card.addClass('iss-stream-status-cancelled');
241            $card.find('.iss-stream-cancel').remove();
242            $card.find('.iss-stream-plan-btn').remove();
243            $card.find('.iss-stream-platform-link').remove();
244            // Лейбл «Отменён» добавляем в начало actions — соответствует PHP-шаблону.
245            $card.find('.iss-stream-card-actions')
246                .prepend($('<span class="iss-stream-cancelled-label"></span>').text('Отменён'));
247        });
248    });
249
250    // ========================================
251    // Удаление стрима (в течение 1 часа)
252    // ========================================
253
254    $(document).on('click', '.iss-stream-delete', function () {
255        if (!confirm('Удалить стрим полностью? Это действие нельзя отменить.')) return;
256        var streamId = $(this).data('stream-id');
257        var $card = $(this).closest('.iss-stream-card');
258
259        $.post(issAjax.url, {
260            action: 'stream_delete',
261            nonce: issAjax.nonce,
262            stream_id: streamId
263        }, function (res) {
264            if (res.success) {
265                $card.slideUp(300, function () { $(this).remove(); });
266            } else {
267                alert(res.data || 'Ошибка');
268            }
269        });
270    });
271
272    // ========================================
273    // Календарь
274    // ========================================
275
276    var $wrap = $('.iss-calendar-wrap');
277    if (!$wrap.length) return;
278
279    var now = new Date();
280    var currentYear = now.getFullYear();
281    var currentMonth = now.getMonth(); // 0-based
282    var festival = $wrap.data('festival') || '';
283    var allStreams = [];
284    var filterGame = '';
285    var filterStreamer = '';
286
287    window.issCalendar = { load: loadMonth };
288
289    // Навигация
290    $wrap.on('click', '.iss-prev-month', function () {
291        currentMonth--;
292        if (currentMonth < 0) { currentMonth = 11; currentYear--; }
293        loadMonth();
294    });
295    $wrap.on('click', '.iss-next-month', function () {
296        currentMonth++;
297        if (currentMonth > 11) { currentMonth = 0; currentYear++; }
298        loadMonth();
299    });
300
301    // Фильтры — показываем/скрываем кнопку «Найти»
302    var $btnSearch = $('#iss-btn-search');
303    var $searchResults = $wrap.find('.iss-search-results');
304
305    $wrap.on('input', '.iss-filter-input', function () {
306        var gameVal = $('#iss-filter-game').val().trim();
307        var streamerVal = $('#iss-filter-streamer').val().trim();
308
309        if (gameVal || streamerVal) {
310            $btnSearch.show();
311        } else {
312            $btnSearch.hide();
313            $searchResults.hide();
314            // Восстанавливаем фильтр календаря
315            filterGame = '';
316            filterStreamer = '';
317            renderCalendar();
318        }
319
320        // Локальный фильтр календаря текущего месяца
321        filterGame = gameVal.toLowerCase();
322        filterStreamer = streamerVal.toLowerCase();
323        renderCalendar();
324    });
325
326    // Кнопка «Найти» — глобальный поиск по всем месяцам
327    $btnSearch.on('click', function () {
328        var gameVal = $('#iss-filter-game').val().trim();
329        var streamerVal = $('#iss-filter-streamer').val().trim();
330        if (!gameVal && !streamerVal) return;
331
332        $btnSearch.prop('disabled', true).text('Поиск...');
333
334        $.post(issAjax.url, {
335            action: 'stream_search',
336            nonce: issAjax.nonce,
337            game: gameVal,
338            streamer: streamerVal,
339            festival: festival
340        }, function (res) {
341            $btnSearch.prop('disabled', false).text('Найти');
342            var items = (res && res.success && Array.isArray(res.data)) ? res.data : [];
343            if (items.length > 0) {
344                var $list = $searchResults.find('.iss-search-results-list').empty();
345                items.forEach(function (s) {
346                    $list.append(renderStreamCard(s));
347                });
348                $searchResults.slideDown(200);
349            } else {
350                $searchResults.find('.iss-search-results-list').html(
351                    '<div class="iss-no-results">Стримы не найдены</div>'
352                );
353                $searchResults.slideDown(200);
354            }
355        }).fail(function () {
356            $btnSearch.prop('disabled', false).text('Найти');
357        });
358    });
359
360    function loadMonth() {
361        $wrap.find('.iss-current-month').text(MONTHS_RU[currentMonth] + ' ' + currentYear);
362        $wrap.find('.iss-day-details').hide();
363
364        $.post(issAjax.url, {
365            action: 'stream_get_by_month',
366            nonce: issAjax.nonce,
367            year: currentYear,
368            month: currentMonth + 1,
369            festival: festival
370        }, function (res) {
371            if (res.success) {
372                allStreams = res.data || [];
373                renderCalendar();
374            } else {
375                console.error('ISS: ошибка загрузки стримов', res.data);
376                allStreams = [];
377                renderCalendar();
378            }
379        }).fail(function (xhr, status, err) {
380            console.error('ISS: сетевая ошибка', status, err);
381            allStreams = [];
382            renderCalendar();
383        });
384    }
385
386    function getFilteredStreams() {
387        return allStreams.filter(function (s) {
388            if (filterGame && (s.game_title || '').toLowerCase().indexOf(filterGame) === -1) return false;
389            if (filterStreamer && (s.streamer_name || '').toLowerCase().indexOf(filterStreamer) === -1) return false;
390            return true;
391        });
392    }
393
394    function renderCalendar() {
395        var streams = getFilteredStreams();
396        var $days = $wrap.find('.iss-calendar-days');
397        $days.empty();
398
399        var firstDay = new Date(currentYear, currentMonth, 1);
400        var lastDay = new Date(currentYear, currentMonth + 1, 0);
401        var startDow = (firstDay.getDay() + 6) % 7; // Пн = 0
402        var daysInMonth = lastDay.getDate();
403
404        var today = new Date();
405        var todayStr = today.getFullYear() + '-' +
406            String(today.getMonth() + 1).padStart(2, '0') + '-' +
407            String(today.getDate()).padStart(2, '0');
408
409        // Группируем стримы по дате (стримы без даты не показываем в расписании)
410        var streamsByDate = {};
411        streams.forEach(function (s) {
412            if (!s.date) return;
413            if (!streamsByDate[s.date]) streamsByDate[s.date] = [];
414            streamsByDate[s.date].push(s);
415        });
416
417        // Пустые ячейки до 1 числа
418        for (var i = 0; i < startDow; i++) {
419            $days.append('<div class="iss-day iss-other-month"></div>');
420        }
421
422        // Дни месяца
423        for (var d = 1; d <= daysInMonth; d++) {
424            var dateStr = currentYear + '-' +
425                String(currentMonth + 1).padStart(2, '0') + '-' +
426                String(d).padStart(2, '0');
427
428            var dayStreams = streamsByDate[dateStr] || [];
429            var isToday = dateStr === todayStr;
430
431            var html = '<div class="iss-day' + (isToday ? ' iss-today' : '') + '" data-date="' + dateStr + '">';
432            html += '<div class="iss-day-number">' + d + '</div>';
433            if (dayStreams.length > 0) {
434                for (var j = 0; j < Math.min(dayStreams.length, 3); j++) {
435                    html += '<span class="iss-day-dot"></span>';
436                }
437                html += '<div class="iss-day-count">' + dayStreams.length + ' стрим' + pluralize(dayStreams.length) + '</div>';
438            }
439            html += '</div>';
440            $days.append(html);
441        }
442
443        // Автопоказ стримов за сегодня — только если открыт текущий месяц.
444        // Важно: .iss-today также навешивается на ячейку сегодняшнего дня в сетке;
445        // здесь нужен именно автоблок, поэтому исключаем ячейки календаря через :not(.iss-day).
446        var $today = $wrap.find('.iss-today').not('.iss-day');
447        var isCurrentMonth = (currentMonth === today.getMonth() && currentYear === today.getFullYear());
448        if (isCurrentMonth) {
449            var todayStreams = streams.filter(function (s) { return s.date === todayStr; });
450            $today.find('.iss-today-title').text('Стримы сегодня');
451            var $todayList = $today.find('.iss-today-list').empty();
452            if (todayStreams.length > 0) {
453                todayStreams.forEach(function (s) { $todayList.append(renderStreamCard(s)); });
454                $today.find('.iss-today-empty').hide();
455                $todayList.show();
456            } else {
457                $todayList.hide();
458                $today.find('.iss-today-empty').show();
459            }
460            $today.show();
461        } else {
462            $today.hide();
463        }
464    }
465
466    // Клик на день
467    $wrap.on('click', '.iss-day:not(.iss-other-month)', function () {
468        var $clicked = $(this);
469        var date = $clicked.data('date');
470        if (!date) return;
471
472        // Помечаем кликнутую ячейку активной
473        $wrap.find('.iss-day.iss-day-selected').removeClass('iss-day-selected');
474        $clicked.addClass('iss-day-selected');
475
476        // Клик по сегодняшнему дню — скроллим к автоблоку, а не дублируем детали.
477        // Прячем .iss-day-details, чтобы не осталось старого содержимого от другого дня.
478        var now = new Date();
479        var nowStr = now.getFullYear() + '-' +
480            String(now.getMonth() + 1).padStart(2, '0') + '-' +
481            String(now.getDate()).padStart(2, '0');
482        if (date === nowStr) {
483            var $todayBlock = $wrap.find('.iss-today').not('.iss-day');
484            $wrap.find('.iss-day-details').hide();
485            if ($todayBlock.is(':visible')) {
486                $('html, body').animate({
487                    scrollTop: $todayBlock.offset().top - 100
488                }, 300);
489                return;
490            }
491        }
492
493        var streams = getFilteredStreams().filter(function (s) { return s.date === date; });
494        var $details = $wrap.find('.iss-day-details');
495
496        if (streams.length === 0) {
497            $details.hide();
498            return;
499        }
500
501        var parts = date.split('-');
502        $details.find('.iss-details-title').text(
503            parseInt(parts[2]) + ' ' + MONTHS_RU[parseInt(parts[1]) - 1].toLowerCase() + ' ' + parts[0]
504        );
505
506        var $list = $details.find('.iss-details-list').empty();
507        streams.forEach(function (s) {
508            $list.append(renderStreamCard(s));
509        });
510
511        $details.slideDown(200, function () {
512            $('html, body').animate({
513                scrollTop: $details.offset().top - 100
514            }, 300);
515        });
516    });
517
518    // Сортировочный ключ карточки: "YYYY-MM-DD HH:MM" из data-атрибутов
519    // или из объекта данных. Без даты/времени — '9999-12-31 23:59' (в конец).
520    function streamSortKey(s) {
521        var d = (s && s.date) ? String(s.date) : '9999-12-31';
522        var t = (s && s.time) ? String(s.time) : '23:59';
523        return d + ' ' + t;
524    }
525
526    // Извлекает ключ сортировки из существующей карточки в DOM —
527    // и серверный шорткод, и renderStreamCard ставят data-stream-key на корне карточки.
528    function streamSortKeyFromCard($card) {
529        return $card.attr('data-stream-key') || '9999-12-31 23:59';
530    }
531
532    // Вставляет $newCard в $container в правильное место по возрастанию даты/времени.
533    function insertCardSorted($container, $newCard, newData) {
534        var newKey = streamSortKey(newData);
535        var inserted = false;
536        $container.find('.iss-stream-card').each(function () {
537            var $existing = $(this);
538            var existingKey = streamSortKeyFromCard($existing);
539            if (newKey < existingKey) {
540                $newCard.insertBefore($existing);
541                inserted = true;
542                return false; // break
543            }
544        });
545        if (!inserted) $container.append($newCard);
546    }
547
548    // Рендер карточки стрима. Если showCover=false — формат страницы игры
549    // (соответствует серверному шаблону class-stream-shortcodes.php), иначе —
550    // формат календаря (с обложкой игры и названием).
551    function renderStreamCard(s, showCover) {
552        if (showCover === undefined) showCover = true;
553        var statusClass = s.status === 'completed' ? 'iss-stream-status-completed' : (s.status === 'cancelled' ? 'iss-stream-status-cancelled' : '');
554
555        var $card = $('<div class="iss-stream-card"></div>');
556        if (statusClass) $card.addClass(statusClass);
557        $card.attr('data-stream-key', streamSortKey(s));
558
559        var gameUrl = safeUrl(s.game_url);
560        var gameCover = safeUrl(s.game_cover);
561        var streamUrl = safeUrl(s.url);
562        var streamerUrl = safeUrl(s.streamer_url);
563
564        // Превью игры — только в формате календаря.
565        if (showCover && gameCover) {
566            var $cover = $('<a class="iss-stream-card-cover"></a>').attr('href', gameUrl);
567            $('<img />').attr('src', gameCover).attr('alt', s.game_title || '').appendTo($cover);
568            $cover.appendTo($card);
569        }
570
571        var $body = $('<div class="iss-stream-card-body"></div>').appendTo($card);
572
573        // На странице игры (showCover=false) порядок: clock → info → actions.
574        // В календаре (showCover=true): info → clock → actions.
575        function renderClock() {
576            if (s.date || s.time) {
577                var $clock = $('<div class="iss-stream-card-clock"></div>');
578                if (s.date) {
579                    var parts = String(s.date).split('-');
580                    // Формат "DD.MM" с ведущим нулём — совпадает с PHP date('d.m', ...)
581                    $('<div class="iss-clock-date"></div>').text(parts[2] + '.' + parts[1]).appendTo($clock);
582                }
583                if (s.time) {
584                    $('<div class="iss-clock-time"></div>').text(s.time).appendTo($clock);
585                }
586                $clock.appendTo($body);
587            } else {
588                // Текст "TBD" совпадает с PHP-шаблоном (class-stream-shortcodes.php).
589                var $tbd = $('<div class="iss-stream-card-clock iss-clock-tbd"></div>');
590                $('<div class="iss-clock-date"></div>').text('TBD').appendTo($tbd);
591                $tbd.appendTo($body);
592            }
593        }
594
595        function renderInfo() {
596            var $info = $('<div class="iss-stream-card-info"></div>').appendTo($body);
597            // Название игры показываем только в формате календаря —
598            // на странице игры это и так текущая страница.
599            if (showCover) {
600                var $gameTitle = $('<div class="iss-stream-card-game"></div>');
601                $('<a></a>').attr('href', gameUrl).text(s.game_title || '').appendTo($gameTitle);
602                $gameTitle.appendTo($info);
603            }
604            // Имя стримера — ссылка на профиль, если url передан, иначе просто текст.
605            var $streamer = $('<div class="iss-stream-card-streamer"></div>');
606            if (streamerUrl) {
607                $('<a></a>').attr('href', streamerUrl).text(s.streamer_name || '').appendTo($streamer);
608            } else {
609                $streamer.text(s.streamer_name || '');
610            }
611            $streamer.appendTo($info);
612        }
613
614        if (showCover) {
615            renderInfo();
616            renderClock();
617        } else {
618            renderClock();
619            renderInfo();
620        }
621
622        // На странице игры actions центрируются.
623        var $actions = $('<div class="iss-stream-card-actions"></div>');
624        if (!showCover) $actions.addClass('iss-actions-center');
625        $actions.appendTo($body);
626
627        if (s.status === 'cancelled') {
628            $('<span class="iss-stream-cancelled-label"></span>').text('Отменён').appendTo($actions);
629        }
630
631        // Кнопка «Напомнить» — параметры в data-* атрибутах, обработчик через делегирование.
632        if (s.date && s.status === 'planned') {
633            $('<button class="iss-stream-link iss-stream-plan-btn" type="button"></button>')
634                .attr('data-game-title', s.game_title || '')
635                .attr('data-date', s.date || '')
636                .attr('data-time', s.time || '')
637                .attr('data-url', streamUrl)
638                .html(REMIND_SVG + 'Напомнить')
639                .appendTo($actions);
640        }
641
642        // Ссылка на трансляцию
643        if (streamUrl && s.status !== 'cancelled') {
644            $('<a class="iss-stream-platform-link" target="_blank"></a>')
645                .attr('href', streamUrl)
646                .html(PLAY_SVG + 'Трансляция')
647                .appendTo($actions);
648        }
649
650        if (s.is_owner && s.status === 'planned') {
651            $('<button class="iss-stream-cancel" type="button">Отменить</button>')
652                .attr('data-stream-id', s.id)
653                .appendTo($actions);
654        }
655        // Кнопка «Удалить» — только в формате календаря (на странице игры её не было в шаблоне).
656        if (showCover && s.can_delete) {
657            $('<button class="iss-stream-delete" type="button" title="Удалить стрим"></button>')
658                .attr('data-stream-id', s.id)
659                .html(TRASH_SVG)
660                .appendTo($actions);
661        }
662
663        // Возвращаем outerHTML — карточка вставляется в DOM через .append/.appendTo,
664        // и внутри нет небезопасных конкатенаций пользовательских строк.
665        return $card[0].outerHTML;
666    }
667
668    function formatDate(dateStr) {
669        var parts = dateStr.split('-');
670        return parseInt(parts[2]) + '.' + parts[1] + '.' + parts[0];
671    }
672
673    function pluralize(n) {
674        if (n % 10 === 1 && n % 100 !== 11) return '';
675        if (n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 10 || n % 100 >= 20)) return 'а';
676        return 'ов';
677    }
678
679    // Запуск
680    loadMonth();
681
682})(jQuery);

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.