nodebb-plugin-recent-cards-2 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,340 @@
1
+ 'use strict';
2
+
3
+ $(document).ready(function () {
4
+ const rtl = $('html').attr('data-dir') === 'rtl';
5
+ const arrowClasses = 'link-secondary position-absolute top-50 translate-middle-y p-1 z-1';
6
+ const nextIcon = rtl ? 'fa-chevron-left' : 'fa-chevron-right';
7
+ const prevIcon = rtl ? 'fa-chevron-right' : 'fa-chevron-left';
8
+ const nextArrow = `<a href="#" class="${arrowClasses} slick-next" title="">
9
+ <i class="fa-solid fa-fw ${nextIcon} fa-lg"></i>
10
+ </a>`;
11
+ const prevArrow = `<a href="#" class="${arrowClasses} slick-prev" title="">
12
+ <i class="fa-solid fa-fw ${prevIcon} fa-lg"></i>
13
+ </a>`;
14
+
15
+ async function initSlick(container) {
16
+ if (!container.length || container.hasClass('slick-initialized')) {
17
+ return;
18
+ }
19
+
20
+ if (!config.recentCards || !config.recentCards.enableCarousel) {
21
+ container.removeClass('carousel-mode invisible');
22
+ return;
23
+ }
24
+
25
+ const slideCount = parseInt(config.recentCards.maxSlides, 10) || 4;
26
+ const slideMargin = 16;
27
+ const env = utils.findBootstrapEnvironment();
28
+ if (['xxl', 'xl', 'lg'].includes(env)) {
29
+ const cards = container.find('.recent-card-container .recent-card');
30
+ const cardCount = Math.min(cards.length, slideCount);
31
+ if (cardCount > 0) {
32
+ cards.css({
33
+ width: (container.width() - ((cardCount - 1) * slideMargin)) / cardCount,
34
+ });
35
+ }
36
+ }
37
+ container.slick({
38
+ infinite: false,
39
+ slidesToShow: slideCount,
40
+ slidesToScroll: slideCount,
41
+ rtl: rtl,
42
+ variableWidth: true,
43
+ dots: !!config.recentCards.enableCarouselPagination,
44
+ nextArrow: nextArrow,
45
+ prevArrow: prevArrow,
46
+ responsive: [{
47
+ breakpoint: 992, // md
48
+ settings: {
49
+ slidesToShow: 3,
50
+ slidesToScroll: 2,
51
+ infinite: false,
52
+ },
53
+ }, {
54
+ breakpoint: 768, // sm/xs
55
+ settings: {
56
+ slidesToShow: 2,
57
+ slidesToScroll: 1,
58
+ infinite: false,
59
+ },
60
+ }],
61
+ });
62
+
63
+ container.removeClass('overflow-hidden invisible');
64
+ container.find('.slick-prev').translateAttr('title', '[[global:pagination.previouspage]]');
65
+ container.find('.slick-next').translateAttr('title', '[[global:pagination.nextpage]]');
66
+ }
67
+
68
+ // --- Category Filter ---
69
+
70
+ const isMobile = () => window.innerWidth < 576;
71
+
72
+ function openDropdown(pluginContainer) {
73
+ const dropdown = pluginContainer.find('.rc-filter-dropdown');
74
+ const btn = pluginContainer.find('.rc-filter-btn');
75
+ dropdown.removeClass('d-none');
76
+ btn.attr('aria-expanded', 'true');
77
+
78
+ // On mobile: add backdrop and lock body scroll
79
+ if (isMobile()) {
80
+ const backdrop = $('<div class="rc-backdrop"></div>');
81
+ backdrop.on('click', function () {
82
+ closeAllDropdowns();
83
+ });
84
+ pluginContainer.find('.rc-filter-wrapper').append(backdrop);
85
+ document.body.style.overflow = 'hidden';
86
+ }
87
+ }
88
+
89
+ function closeAllDropdowns() {
90
+ $('.rc-filter-dropdown').addClass('d-none');
91
+ $('.rc-filter-btn').attr('aria-expanded', 'false');
92
+ $('.rc-backdrop').remove();
93
+ document.body.style.overflow = '';
94
+ }
95
+
96
+ // No localStorage — filter resets on page reload (F5)
97
+
98
+ function buildDropdown(pluginContainer, categories, selectedCids) {
99
+ const inner = pluginContainer.find('.rc-filter-dropdown-inner');
100
+ inner.empty();
101
+
102
+ // Header with reset link
103
+ const header = $('<div class="rc-filter-header d-flex align-items-center justify-content-between px-3 py-2"></div>');
104
+ header.append($('<span class="text-muted text-xs fw-semibold text-uppercase"></span>').text('Kategoriler'));
105
+ header.append($('<button type="button" class="btn btn-link btn-sm rc-clear-all text-decoration-none p-0 text-xs"></button>').text('Temizle'));
106
+ inner.append(header);
107
+
108
+ categories.forEach(function (cat) {
109
+ const isChecked = selectedCids ? selectedCids.includes(cat.cid) : false;
110
+ const item = $('<label class="rc-filter-option d-flex align-items-center gap-2 px-3 py-2"></label>');
111
+ const checkbox = $('<input type="checkbox" class="form-check-input mt-0">')
112
+ .attr('data-cid', cat.cid)
113
+ .prop('checked', isChecked);
114
+
115
+ const nameSpan = $('<span class="badge"></span>')
116
+ .css({ backgroundColor: cat.bgColor || '#6c757d', color: cat.color || '#fff' })
117
+ .text(cat.name);
118
+
119
+ item.append(checkbox);
120
+ if (cat.icon) {
121
+ item.append($('<i></i>').addClass('fa').addClass(cat.icon).css('color', cat.bgColor || '#6c757d'));
122
+ }
123
+ item.append(nameSpan);
124
+ inner.append(item);
125
+ });
126
+ }
127
+
128
+ function updateFilterButton(pluginContainer, selectedCids, categories) {
129
+ const btn = pluginContainer.find('.rc-filter-btn');
130
+ const activeCount = selectedCids ? selectedCids.length : 0;
131
+ const isFiltered = activeCount > 0 && activeCount < categories.length;
132
+ btn.toggleClass('rc-filter-active', isFiltered);
133
+ const label = btn.find('.rc-filter-label');
134
+ label.text(isFiltered ? 'Filtrele (' + activeCount + ')' : 'Filtrele');
135
+ }
136
+
137
+ function parseWidgetConfig(pluginContainer) {
138
+ const scriptTag = pluginContainer.find('script.rc-widget-config');
139
+ if (!scriptTag.length) return {};
140
+ try {
141
+ return JSON.parse(scriptTag.text()) || {};
142
+ } catch (e) {
143
+ return {};
144
+ }
145
+ }
146
+
147
+ function applyFilter(pluginContainer, selectedCids, categories) {
148
+ const carouselContainer = pluginContainer.find('.recent-cards');
149
+ const isCarouselMode = config.recentCards && config.recentCards.enableCarousel;
150
+ const showAll = !selectedCids || selectedCids.length === 0 || selectedCids.length === categories.length;
151
+ const emptyState = pluginContainer.find('.rc-empty-state');
152
+ const widgetCfg = parseWidgetConfig(pluginContainer);
153
+
154
+ // Update button immediately
155
+ updateFilterButton(pluginContainer, selectedCids, categories);
156
+
157
+ // If showing all, and we have the original cards stashed, restore them
158
+ if (showAll && pluginContainer.data('rc-original-cards')) {
159
+ swapCards(carouselContainer, emptyState, pluginContainer.data('rc-original-cards'), isCarouselMode);
160
+ return;
161
+ }
162
+
163
+ if (showAll) {
164
+ return; // nothing to filter, initial load handles it
165
+ }
166
+
167
+ // Stash original cards on first filter
168
+ if (!pluginContainer.data('rc-original-cards')) {
169
+ pluginContainer.data('rc-original-cards', carouselContainer.html());
170
+ }
171
+
172
+ // Abort any in-flight request (safe to call on completed/null)
173
+ const prevXhr = pluginContainer.data('rc-xhr');
174
+ if (prevXhr) {
175
+ prevXhr.abort();
176
+ pluginContainer.removeData('rc-xhr');
177
+ }
178
+
179
+ // Keep old cards visible while loading — no blank flash
180
+ const params = $.param({
181
+ cids: selectedCids.join(','),
182
+ sort: widgetCfg.sort || 'recent',
183
+ teaserPost: widgetCfg.teaserPost || 'first',
184
+ teaserParseType: widgetCfg.teaserParseType || 'default',
185
+ thumbnailStyle: widgetCfg.thumbnailStyle || 'background',
186
+ });
187
+
188
+ const xhr = $.getJSON(config.relative_path + '/plugins/nodebb-plugin-recent-cards/filter?' + params)
189
+ .done(function (data) {
190
+ const parsed = $('<div>').html(data.html);
191
+ const newCards = parsed.find('.recent-cards').html();
192
+
193
+ if (!newCards || !newCards.trim()) {
194
+ destroySlick(carouselContainer);
195
+ emptyState.removeClass('d-none');
196
+ carouselContainer.addClass('d-none');
197
+ return;
198
+ }
199
+
200
+ // Translate, then do instant swap (destroy → replace → reinit in one tick)
201
+ require(['translator'], function (translator) {
202
+ translator.Translator.create().translate(newCards).then(function (translated) {
203
+ swapCards(carouselContainer, emptyState, translated, isCarouselMode);
204
+ }).catch(function () {
205
+ swapCards(carouselContainer, emptyState, newCards, isCarouselMode);
206
+ });
207
+ });
208
+ })
209
+ .fail(function (jqXHR, textStatus) {
210
+ if (textStatus === 'abort') return;
211
+ // On error, keep current cards as-is
212
+ })
213
+ .always(function () {
214
+ pluginContainer.removeData('rc-xhr');
215
+ });
216
+
217
+ pluginContainer.data('rc-xhr', xhr);
218
+ }
219
+
220
+ function swapCards(carouselContainer, emptyState, html, isCarouselMode) {
221
+ // Instant swap: destroy → replace → reinit in one synchronous block (no blank flash)
222
+ destroySlick(carouselContainer);
223
+ emptyState.addClass('d-none');
224
+ carouselContainer.removeClass('d-none').html(html);
225
+ carouselContainer.find('.timeago').timeago();
226
+
227
+ if (isCarouselMode) {
228
+ carouselContainer.addClass('overflow-hidden invisible');
229
+ initSlick(carouselContainer);
230
+ } else {
231
+ carouselContainer.removeClass('carousel-mode invisible overflow-hidden');
232
+ }
233
+ }
234
+
235
+ function destroySlick(container) {
236
+ if (container.hasClass('slick-initialized')) {
237
+ try {
238
+ container.slick('unslick');
239
+ } catch (e) {
240
+ // Slick was not properly initialized
241
+ }
242
+ }
243
+ }
244
+
245
+ function parseCategories(pluginContainer) {
246
+ const scriptTag = pluginContainer.find('script.rc-categories-data');
247
+ if (!scriptTag.length) return [];
248
+ try {
249
+ const parsed = JSON.parse(scriptTag.text());
250
+ return Array.isArray(parsed) ? parsed : [];
251
+ } catch (e) {
252
+ return [];
253
+ }
254
+ }
255
+
256
+ function initFilter(pluginContainer) {
257
+ const categories = parseCategories(pluginContainer);
258
+ const filterBar = pluginContainer.find('.rc-filter-bar');
259
+
260
+ // Hide filter if fewer than 2 categories
261
+ if (categories.length < 2) {
262
+ filterBar.addClass('d-none');
263
+ return false;
264
+ }
265
+
266
+ const allCids = categories.map(c => c.cid);
267
+
268
+ buildDropdown(pluginContainer, categories, null);
269
+
270
+ // Bind events (use .off first to prevent duplicates from ajaxify)
271
+ pluginContainer.off('.rcFilter');
272
+
273
+ // Toggle dropdown
274
+ pluginContainer.on('click.rcFilter', '.rc-filter-btn', function (e) {
275
+ e.stopPropagation();
276
+ const isOpen = !pluginContainer.find('.rc-filter-dropdown').hasClass('d-none');
277
+ closeAllDropdowns();
278
+ if (!isOpen) {
279
+ openDropdown(pluginContainer);
280
+ }
281
+ });
282
+
283
+ // Checkbox change (debounced — user may click multiple checkboxes rapidly)
284
+ let filterTimeout;
285
+ pluginContainer.on('change.rcFilter', '.rc-filter-option input[type="checkbox"]', function () {
286
+ clearTimeout(filterTimeout);
287
+ filterTimeout = setTimeout(function () {
288
+ const selected = [];
289
+ pluginContainer.find('.rc-filter-option input:checked').each(function () {
290
+ selected.push(parseInt($(this).attr('data-cid'), 10));
291
+ });
292
+ applyFilter(pluginContainer, selected, categories);
293
+ }, 300);
294
+ });
295
+
296
+ // Clear all (from dropdown header) — uncheck everything = show all (last 20)
297
+ pluginContainer.on('click.rcFilter', '.rc-clear-all', function (e) {
298
+ e.stopPropagation();
299
+ pluginContainer.find('.rc-filter-option input').prop('checked', false);
300
+ applyFilter(pluginContainer, [], categories);
301
+ });
302
+
303
+ // Init slick with default cards (all categories, last 20)
304
+ initSlick(pluginContainer.find('.recent-cards'));
305
+ }
306
+
307
+ function setupWidget(pluginContainer) {
308
+ initFilter(pluginContainer);
309
+ }
310
+
311
+ // Close dropdown on outside click & Escape
312
+ function bindGlobalHandlers() {
313
+ $(document).off('.rcFilter');
314
+
315
+ $(document).on('click.rcFilter', function (e) {
316
+ if (!$(e.target).closest('.rc-filter-wrapper').length) {
317
+ closeAllDropdowns();
318
+ }
319
+ });
320
+
321
+ $(document).on('keydown.rcFilter', function (e) {
322
+ if (e.key === 'Escape') {
323
+ closeAllDropdowns();
324
+ }
325
+ });
326
+ }
327
+
328
+ // Initialize
329
+ bindGlobalHandlers();
330
+ $('.recent-cards-plugin').each(function () {
331
+ setupWidget($(this));
332
+ });
333
+
334
+ $(window).on('action:ajaxify.contentLoaded', function () {
335
+ bindGlobalHandlers();
336
+ $('.recent-cards-plugin').each(function () {
337
+ setupWidget($(this));
338
+ });
339
+ });
340
+ });
@@ -0,0 +1,113 @@
1
+
2
+ /* modified from https://gist.github.com/vivekkumawat/c8351e28412a194aac977bf996c4a6d8 */
3
+
4
+ /* CSS For Slick Slider */
5
+ /* Note: Don't use slick-theme.css file */
6
+
7
+ /* Adding margin between slides */
8
+ .slick-slide {
9
+ margin: 0 8px;
10
+ }
11
+ .slick-list {
12
+ margin: 0;
13
+ }
14
+
15
+ /* Arrows */
16
+ .slick-prev.slick-disabled,
17
+ .slick-next.slick-disabled
18
+ {
19
+ opacity: .25;
20
+ }
21
+
22
+ /* rtlcss converts these to right/left:0px on RTL */
23
+ .slick-prev { left: 0px; }
24
+ .slick-next { right: 0px; }
25
+
26
+ /* Dots */
27
+ .slick-dotted.slick-slider
28
+ {
29
+ margin-bottom: 30px;
30
+ }
31
+
32
+ .slick-dots
33
+ {
34
+ position: absolute;
35
+ bottom: -25px;
36
+
37
+ display: block;
38
+
39
+ width: 100%;
40
+ padding: 0;
41
+ margin: 0;
42
+
43
+ list-style: none;
44
+
45
+ text-align: center;
46
+ }
47
+ .slick-dots li
48
+ {
49
+ position: relative;
50
+
51
+ display: inline-block;
52
+
53
+ width: 20px;
54
+ height: 20px;
55
+ margin: 0 5px;
56
+ padding: 0;
57
+
58
+ cursor: pointer;
59
+ }
60
+ .slick-dots li button
61
+ {
62
+ font-size: 0;
63
+ line-height: 0;
64
+
65
+ display: block;
66
+
67
+ width: 20px;
68
+ height: 20px;
69
+ padding: 5px;
70
+
71
+ cursor: pointer;
72
+
73
+ color: transparent;
74
+ border: 0;
75
+ outline: none;
76
+ background: transparent;
77
+ }
78
+ .slick-dots li button:hover,
79
+ .slick-dots li button:focus
80
+ {
81
+ outline: none;
82
+ }
83
+ .slick-dots li button:hover:before,
84
+ .slick-dots li button:focus:before
85
+ {
86
+ opacity: 1;
87
+ }
88
+ .slick-dots li button:before
89
+ {
90
+ font-size: 8px;
91
+ line-height: 20px;
92
+
93
+ position: absolute;
94
+ top: 0;
95
+ left: 0;
96
+
97
+ width: 20px;
98
+ height: 20px;
99
+
100
+ content: '•';
101
+ text-align: center;
102
+
103
+ opacity: .25;
104
+ color: black;
105
+
106
+ -webkit-font-smoothing: antialiased;
107
+ -moz-osx-font-smoothing: grayscale;
108
+ }
109
+ .slick-dots li.slick-active button:before
110
+ {
111
+ opacity: .75;
112
+ color: black;
113
+ }
@@ -0,0 +1,118 @@
1
+ /* Slider */
2
+ .slick-slider
3
+ {
4
+ position: relative;
5
+
6
+ display: block;
7
+ box-sizing: border-box;
8
+
9
+ -webkit-user-select: none;
10
+ -moz-user-select: none;
11
+ -ms-user-select: none;
12
+ user-select: none;
13
+
14
+ -webkit-touch-callout: none;
15
+ -khtml-user-select: none;
16
+ -ms-touch-action: pan-y;
17
+ touch-action: pan-y;
18
+ -webkit-tap-highlight-color: transparent;
19
+ }
20
+
21
+ .slick-list
22
+ {
23
+ position: relative;
24
+
25
+ display: block;
26
+ overflow: hidden;
27
+
28
+ margin: 0;
29
+ padding: 0;
30
+ }
31
+ .slick-list:focus
32
+ {
33
+ outline: none;
34
+ }
35
+ .slick-list.dragging
36
+ {
37
+ cursor: pointer;
38
+ cursor: hand;
39
+ }
40
+
41
+ .slick-slider .slick-track,
42
+ .slick-slider .slick-list
43
+ {
44
+ -webkit-transform: translate3d(0, 0, 0);
45
+ -moz-transform: translate3d(0, 0, 0);
46
+ -ms-transform: translate3d(0, 0, 0);
47
+ -o-transform: translate3d(0, 0, 0);
48
+ transform: translate3d(0, 0, 0);
49
+ }
50
+
51
+ .slick-track
52
+ {
53
+ position: relative;
54
+ top: 0;
55
+ left: 0;
56
+
57
+ display: block;
58
+ margin-left: auto;
59
+ margin-right: auto;
60
+ }
61
+ .slick-track:before,
62
+ .slick-track:after
63
+ {
64
+ display: table;
65
+
66
+ content: '';
67
+ }
68
+ .slick-track:after
69
+ {
70
+ clear: both;
71
+ }
72
+ .slick-loading .slick-track
73
+ {
74
+ visibility: hidden;
75
+ }
76
+
77
+ .slick-slide
78
+ {
79
+ display: none;
80
+ float: left;
81
+
82
+ height: 100%;
83
+ min-height: 1px;
84
+ }
85
+ [dir='rtl'] .slick-slide
86
+ {
87
+ float: right;
88
+ }
89
+ .slick-slide.slick-loading img
90
+ {
91
+ display: none;
92
+ }
93
+ .slick-slide .topic-title img {
94
+ display: inline-block
95
+ }
96
+ .slick-slide.dragging img
97
+ {
98
+ pointer-events: none;
99
+ }
100
+ .slick-initialized .slick-slide
101
+ {
102
+ display: block;
103
+ }
104
+ .slick-loading .slick-slide
105
+ {
106
+ visibility: hidden;
107
+ }
108
+ .slick-vertical .slick-slide
109
+ {
110
+ display: block;
111
+
112
+ height: auto;
113
+
114
+ border: 1px solid transparent;
115
+ }
116
+ .slick-arrow.slick-hidden {
117
+ display: none;
118
+ }