nodebb-plugin-recent-cards-2 3.3.19 → 3.3.21

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.
package/library.js CHANGED
@@ -29,6 +29,7 @@ plugin.init = async function (params) {
29
29
 
30
30
  router.get('/plugins/nodebb-plugin-recent-cards/render', renderExternal);
31
31
  router.get('/plugins/nodebb-plugin-recent-cards/render/style.css', renderExternalStyle);
32
+ router.get('/plugins/nodebb-plugin-recent-cards/filter', renderFiltered);
32
33
  router.get('/admin/plugins/nodebb-plugin-recent-cards/tests/external', testRenderExternal);
33
34
 
34
35
  plugin.settings = new settings('recentcards', '1.0.0', defaultSettings);
@@ -97,12 +98,20 @@ plugin.renderWidget = async function (widget) {
97
98
  });
98
99
  const categories = Object.values(categoryMap);
99
100
 
101
+ const widgetConfig = {
102
+ sort: widget.data.sort || 'recent',
103
+ teaserPost: widget.data.teaserPost || 'first',
104
+ teaserParseType: widget.data.teaserParseType || 'default',
105
+ thumbnailStyle: widget.data.thumbnailStyle || 'background',
106
+ };
107
+
100
108
  widget.html = await app.renderAsync('partials/nodebb-plugin-recent-cards/header', {
101
109
  topics: topics,
102
110
  config: widget.templateData.config,
103
111
  title: widget.data.title || '',
104
112
  carouselMode: plugin.settings.get('enableCarousel'),
105
113
  categories: JSON.stringify(categories).replace(/</g, '\\u003c'),
114
+ widgetConfig: JSON.stringify(widgetConfig).replace(/</g, '\\u003c'),
106
115
  });
107
116
  return widget;
108
117
  };
@@ -266,7 +275,47 @@ async function renderExternal(req, res, next) {
266
275
  relative_path: nconf.get('url'),
267
276
  },
268
277
  categories: JSON.stringify(Object.values(categoryMap)).replace(/</g, '\\u003c'),
278
+ widgetConfig: '{}',
279
+ });
280
+ } catch (err) {
281
+ next(err);
282
+ }
283
+ }
284
+
285
+ async function renderFiltered(req, res, next) {
286
+ try {
287
+ const cids = String(req.query.cids || '');
288
+ const sort = String(req.query.sort || 'recent');
289
+ const teaserPost = String(req.query.teaserPost || 'first');
290
+ const teaserParseType = String(req.query.teaserParseType || 'default');
291
+ const thumbnailStyle = String(req.query.thumbnailStyle || 'background');
292
+
293
+ const allowedSorts = ['recent', 'create', 'votes', 'posts'];
294
+ const allowedTeasers = ['first', 'last-post'];
295
+ const allowedParseTypes = ['default', 'plaintext'];
296
+ const allowedThumbs = ['background', 'inline'];
297
+
298
+ const filteredTopics = await getTopics({
299
+ uid: req.uid,
300
+ data: {
301
+ topicsFromCid: cids,
302
+ sort: allowedSorts.includes(sort) ? sort : 'recent',
303
+ teaserPost: allowedTeasers.includes(teaserPost) ? teaserPost : 'first',
304
+ teaserParseType: allowedParseTypes.includes(teaserParseType) ? teaserParseType : 'default',
305
+ thumbnailStyle: allowedThumbs.includes(thumbnailStyle) ? thumbnailStyle : 'background',
306
+ },
307
+ templateData: {},
308
+ });
309
+
310
+ const html = await app.renderAsync('partials/nodebb-plugin-recent-cards/header', {
311
+ topics: filteredTopics,
312
+ config: { relative_path: nconf.get('relative_path') },
313
+ carouselMode: plugin.settings.get('enableCarousel'),
314
+ categories: '[]',
315
+ widgetConfig: '{}',
269
316
  });
317
+
318
+ res.json({ html: html });
270
319
  } catch (err) {
271
320
  next(err);
272
321
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-recent-cards-2",
3
- "version": "3.3.19",
3
+ "version": "3.3.21",
4
4
  "description": "Add lavender-style cards of recent topics to Persona's category homepage",
5
5
  "main": "library.js",
6
6
  "repository": {
@@ -93,18 +93,6 @@ $(document).ready(function () {
93
93
  document.body.style.overflow = '';
94
94
  }
95
95
 
96
- function isLightColor(hex) {
97
- if (!hex) return true;
98
- hex = hex.replace('#', '');
99
- if (hex.length === 3) {
100
- hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];
101
- }
102
- const r = parseInt(hex.substr(0, 2), 16);
103
- const g = parseInt(hex.substr(2, 2), 16);
104
- const b = parseInt(hex.substr(4, 2), 16);
105
- return (r * 299 + g * 587 + b * 114) / 1000 > 128;
106
- }
107
-
108
96
  function getStorageKey(categories) {
109
97
  const sortedCids = categories.map(c => c.cid).sort((a, b) => a - b);
110
98
  return 'rc-filter-' + sortedCids.join('-');
@@ -133,6 +121,12 @@ $(document).ready(function () {
133
121
  const inner = pluginContainer.find('.rc-filter-dropdown-inner');
134
122
  inner.empty();
135
123
 
124
+ // Header with reset link
125
+ const header = $('<div class="rc-filter-header d-flex align-items-center justify-content-between px-3 py-2"></div>');
126
+ header.append($('<span class="text-muted text-xs fw-semibold text-uppercase"></span>').text('Kategoriler'));
127
+ header.append($('<button type="button" class="btn btn-link btn-sm rc-clear-all text-decoration-none p-0 text-xs"></button>').text('Temizle'));
128
+ inner.append(header);
129
+
136
130
  categories.forEach(function (cat) {
137
131
  const isChecked = !selectedCids || selectedCids.includes(cat.cid);
138
132
  const item = $('<label class="rc-filter-option d-flex align-items-center gap-2 px-3 py-2"></label>');
@@ -153,104 +147,120 @@ $(document).ready(function () {
153
147
  });
154
148
  }
155
149
 
156
- function updateChips(pluginContainer, selectedCids, categories) {
157
- const chipsContainer = pluginContainer.find('.rc-filter-chips');
158
- chipsContainer.empty();
159
-
160
- if (!selectedCids || selectedCids.length === 0 || selectedCids.length === categories.length) {
161
- return;
162
- }
163
-
164
- selectedCids.forEach(function (cid) {
165
- const cat = categories.find(c => c.cid === cid);
166
- if (!cat) return;
167
- const chip = $('<span class="rc-filter-chip badge d-flex align-items-center gap-1"></span>')
168
- .attr('data-cid', cid)
169
- .css({ backgroundColor: cat.bgColor || '#6c757d', color: cat.color || '#fff' });
170
- chip.append($('<span></span>').text(cat.name));
171
- const closeBtn = $('<button type="button" class="btn-close rc-chip-remove" aria-label="Remove"></button>');
172
- if (!isLightColor(cat.bgColor)) {
173
- closeBtn.addClass('btn-close-white');
174
- }
175
- chip.append(closeBtn);
176
- chipsContainer.append(chip);
177
- });
178
-
179
- chipsContainer.append(
180
- $('<button type="button" class="btn btn-link btn-sm rc-clear-all text-decoration-none p-0 ms-1"></button>')
181
- .text('Temizle')
182
- );
183
- }
184
-
185
150
  function updateFilterButton(pluginContainer, selectedCids, categories) {
186
151
  const btn = pluginContainer.find('.rc-filter-btn');
187
152
  const isFiltered = selectedCids && selectedCids.length > 0 && selectedCids.length < categories.length;
188
- btn.toggleClass('btn-outline-secondary', !isFiltered);
189
- btn.toggleClass('btn-primary', isFiltered);
153
+ btn.toggleClass('rc-filter-active', isFiltered);
190
154
  const label = btn.find('.rc-filter-label');
191
155
  label.text(isFiltered ? 'Filtrele (' + selectedCids.length + ')' : 'Filtrele');
192
156
  }
193
157
 
158
+ function parseWidgetConfig(pluginContainer) {
159
+ const scriptTag = pluginContainer.find('script.rc-widget-config');
160
+ if (!scriptTag.length) return {};
161
+ try {
162
+ return JSON.parse(scriptTag.text()) || {};
163
+ } catch (e) {
164
+ return {};
165
+ }
166
+ }
167
+
194
168
  function applyFilter(pluginContainer, selectedCids, categories) {
195
169
  const carouselContainer = pluginContainer.find('.recent-cards');
196
- const isSlickInitialized = carouselContainer.hasClass('slick-initialized');
197
170
  const isCarouselMode = config.recentCards && config.recentCards.enableCarousel;
198
171
  const showAll = !selectedCids || selectedCids.length === 0 || selectedCids.length === categories.length;
172
+ const emptyState = pluginContainer.find('.rc-empty-state');
173
+ const widgetCfg = parseWidgetConfig(pluginContainer);
199
174
 
200
- // Step 1: Destroy slick if initialized
201
- if (isSlickInitialized) {
202
- try {
203
- carouselContainer.slick('unslick');
204
- } catch (e) {
205
- // Slick was not properly initialized
206
- }
207
- }
175
+ // Update button immediately
176
+ updateFilterButton(pluginContainer, selectedCids, categories);
208
177
 
209
- // Step 2: Restore any previously detached cards back into the container
210
- const stash = pluginContainer.data('rc-stashed-cards');
211
- if (stash && stash.length) {
212
- carouselContainer.append(stash);
213
- pluginContainer.removeData('rc-stashed-cards');
178
+ // If showing all, and we have the original cards stashed, restore them
179
+ if (showAll && pluginContainer.data('rc-original-cards')) {
180
+ swapCards(carouselContainer, emptyState, pluginContainer.data('rc-original-cards'), isCarouselMode);
181
+ return;
214
182
  }
215
183
 
216
- // Step 3: Detach cards that don't match the filter (not just hide — Slick counts hidden children)
217
- const toStashElements = [];
218
- let visibleCount = 0;
219
- carouselContainer.find('.recent-card-container').each(function () {
220
- const cardCid = parseInt($(this).attr('data-cid'), 10);
221
- if (showAll || selectedCids.includes(cardCid)) {
222
- visibleCount++;
223
- } else {
224
- toStashElements.push(this);
225
- }
226
- });
184
+ if (showAll) {
185
+ return; // nothing to filter, initial load handles it
186
+ }
227
187
 
228
- if (toStashElements.length) {
229
- const toStash = $(toStashElements).detach();
230
- pluginContainer.data('rc-stashed-cards', toStash);
188
+ // Stash original cards on first filter
189
+ if (!pluginContainer.data('rc-original-cards')) {
190
+ pluginContainer.data('rc-original-cards', carouselContainer.html());
231
191
  }
232
192
 
233
- // Step 4: Handle empty state
234
- const emptyState = pluginContainer.find('.rc-empty-state');
235
- if (visibleCount === 0) {
236
- emptyState.removeClass('d-none');
237
- carouselContainer.addClass('d-none');
238
- } else {
239
- emptyState.addClass('d-none');
240
- carouselContainer.removeClass('d-none');
193
+ // Abort any in-flight request (safe to call on completed/null)
194
+ const prevXhr = pluginContainer.data('rc-xhr');
195
+ if (prevXhr) {
196
+ prevXhr.abort();
197
+ pluginContainer.removeData('rc-xhr');
241
198
  }
242
199
 
243
- // Step 5: Reinitialize slick with only the cards still in the DOM
244
- if (isCarouselMode && visibleCount > 0) {
200
+ // Keep old cards visible while loading no blank flash
201
+ const params = $.param({
202
+ cids: selectedCids.join(','),
203
+ sort: widgetCfg.sort || 'recent',
204
+ teaserPost: widgetCfg.teaserPost || 'first',
205
+ teaserParseType: widgetCfg.teaserParseType || 'default',
206
+ thumbnailStyle: widgetCfg.thumbnailStyle || 'background',
207
+ });
208
+
209
+ const xhr = $.getJSON(config.relative_path + '/plugins/nodebb-plugin-recent-cards/filter?' + params)
210
+ .done(function (data) {
211
+ const parsed = $('<div>').html(data.html);
212
+ const newCards = parsed.find('.recent-cards').html();
213
+
214
+ if (!newCards || !newCards.trim()) {
215
+ destroySlick(carouselContainer);
216
+ emptyState.removeClass('d-none');
217
+ carouselContainer.addClass('d-none');
218
+ return;
219
+ }
220
+
221
+ // Translate, then do instant swap (destroy → replace → reinit in one tick)
222
+ require(['translator'], function (translator) {
223
+ translator.Translator.create().translate(newCards).then(function (translated) {
224
+ swapCards(carouselContainer, emptyState, translated, isCarouselMode);
225
+ }).catch(function () {
226
+ swapCards(carouselContainer, emptyState, newCards, isCarouselMode);
227
+ });
228
+ });
229
+ })
230
+ .fail(function (jqXHR, textStatus) {
231
+ if (textStatus === 'abort') return;
232
+ // On error, keep current cards as-is
233
+ })
234
+ .always(function () {
235
+ pluginContainer.removeData('rc-xhr');
236
+ });
237
+
238
+ pluginContainer.data('rc-xhr', xhr);
239
+ }
240
+
241
+ function swapCards(carouselContainer, emptyState, html, isCarouselMode) {
242
+ // Instant swap: destroy → replace → reinit in one synchronous block (no blank flash)
243
+ destroySlick(carouselContainer);
244
+ emptyState.addClass('d-none');
245
+ carouselContainer.removeClass('d-none').html(html);
246
+ carouselContainer.find('.timeago').timeago();
247
+
248
+ if (isCarouselMode) {
245
249
  carouselContainer.addClass('overflow-hidden invisible');
246
250
  initSlick(carouselContainer);
247
- } else if (!isCarouselMode) {
248
- carouselContainer.removeClass('carousel-mode invisible');
251
+ } else {
252
+ carouselContainer.removeClass('carousel-mode invisible overflow-hidden');
249
253
  }
254
+ }
250
255
 
251
- // Step 6: Update UI
252
- updateChips(pluginContainer, selectedCids, categories);
253
- updateFilterButton(pluginContainer, selectedCids, categories);
256
+ function destroySlick(container) {
257
+ if (container.hasClass('slick-initialized')) {
258
+ try {
259
+ container.slick('unslick');
260
+ } catch (e) {
261
+ // Slick was not properly initialized
262
+ }
263
+ }
254
264
  }
255
265
 
256
266
  function parseCategories(pluginContainer) {
@@ -293,26 +303,21 @@ $(document).ready(function () {
293
303
  }
294
304
  });
295
305
 
296
- // Checkbox change
306
+ // Checkbox change (debounced — user may click multiple checkboxes rapidly)
307
+ let filterTimeout;
297
308
  pluginContainer.on('change.rcFilter', '.rc-filter-option input[type="checkbox"]', function () {
298
- const selected = [];
299
- pluginContainer.find('.rc-filter-option input:checked').each(function () {
300
- selected.push(parseInt($(this).attr('data-cid'), 10));
301
- });
302
- saveFilter(storageKey, selected, allCids);
303
- applyFilter(pluginContainer, selected, categories);
304
- });
305
-
306
- // Chip remove
307
- pluginContainer.on('click.rcFilter', '.rc-chip-remove', function (e) {
308
- e.stopPropagation();
309
- const chip = $(this).closest('.rc-filter-chip');
310
- const cidToRemove = parseInt(chip.attr('data-cid'), 10);
311
- pluginContainer.find('.rc-filter-option input[data-cid="' + cidToRemove + '"]')
312
- .prop('checked', false).trigger('change');
309
+ clearTimeout(filterTimeout);
310
+ filterTimeout = setTimeout(function () {
311
+ const selected = [];
312
+ pluginContainer.find('.rc-filter-option input:checked').each(function () {
313
+ selected.push(parseInt($(this).attr('data-cid'), 10));
314
+ });
315
+ saveFilter(storageKey, selected, allCids);
316
+ applyFilter(pluginContainer, selected, categories);
317
+ }, 300);
313
318
  });
314
319
 
315
- // Clear all
320
+ // Clear all (from dropdown header)
316
321
  pluginContainer.on('click.rcFilter', '.rc-clear-all', function (e) {
317
322
  e.stopPropagation();
318
323
  pluginContainer.find('.rc-filter-option input').prop('checked', true);
@@ -320,20 +325,17 @@ $(document).ready(function () {
320
325
  applyFilter(pluginContainer, null, categories);
321
326
  });
322
327
 
323
- // Apply saved filter if exists
328
+ // Always init slick first with whatever cards are on the page
329
+ initSlick(pluginContainer.find('.recent-cards'));
330
+
331
+ // Then apply saved filter if exists (triggers AJAX fetch)
324
332
  if (savedFilter) {
325
333
  applyFilter(pluginContainer, savedFilter, categories);
326
- return true; // filter was applied (slick handled inside applyFilter)
327
334
  }
328
-
329
- return false; // no filter applied, caller should init slick
330
335
  }
331
336
 
332
337
  function setupWidget(pluginContainer) {
333
- const filterApplied = initFilter(pluginContainer);
334
- if (!filterApplied) {
335
- initSlick(pluginContainer.find('.recent-cards'));
336
- }
338
+ initFilter(pluginContainer);
337
339
  }
338
340
 
339
341
  // Close dropdown on outside click & Escape
package/static/style.scss CHANGED
@@ -33,9 +33,24 @@
33
33
  .rc-filter-btn {
34
34
  white-space: nowrap;
35
35
  font-size: 0.85rem;
36
- border-radius: var(--bs-border-radius);
36
+ border-radius: var(--bs-border-radius-pill);
37
+ color: var(--bs-body-color);
38
+ background-color: var(--bs-tertiary-bg);
39
+ border: 1px solid var(--bs-border-color);
40
+ padding: 0.35rem 0.85rem;
41
+ transition: all 0.15s ease;
42
+ &:hover {
43
+ background-color: var(--bs-secondary-bg);
44
+ border-color: var(--bs-secondary-bg);
45
+ }
46
+ &.rc-filter-active {
47
+ background-color: var(--bs-secondary-bg);
48
+ border-color: var(--bs-secondary-bg);
49
+ font-weight: 600;
50
+ }
37
51
  .rc-caret {
38
52
  transition: transform 0.2s;
53
+ opacity: 0.6;
39
54
  }
40
55
  &[aria-expanded="true"] .rc-caret {
41
56
  transform: rotate(180deg);
@@ -44,8 +59,9 @@
44
59
 
45
60
  .rc-filter-dropdown {
46
61
  top: 100%;
47
- left: 0;
48
- min-width: min(14rem, 90vw);
62
+ right: 0;
63
+ left: auto;
64
+ min-width: min(16rem, 90vw);
49
65
  max-height: clamp(10rem, 40vh, 20rem);
50
66
  overflow-y: auto;
51
67
  z-index: 1050;
@@ -54,8 +70,8 @@
54
70
  margin-top: 0.25rem;
55
71
 
56
72
  [data-dir="rtl"] & {
57
- left: auto;
58
- right: 0;
73
+ right: auto;
74
+ left: 0;
59
75
  }
60
76
  }
61
77
 
@@ -83,32 +99,8 @@
83
99
  -webkit-tap-highlight-color: transparent;
84
100
  }
85
101
 
86
- .rc-filter-chips {
87
- overflow-x: auto;
88
- scrollbar-width: thin;
89
- &::-webkit-scrollbar {
90
- height: 3px;
91
- }
92
- }
93
-
94
- .rc-filter-chip {
95
- font-size: 0.75rem;
96
- line-height: 1.5;
97
- padding: 0.25rem 0.5rem;
98
- border-radius: var(--bs-border-radius-pill);
99
- white-space: nowrap;
100
- .rc-chip-remove {
101
- width: 1rem;
102
- height: 1rem;
103
- padding: 0.25rem;
104
- margin-left: 0.125rem;
105
- opacity: 0.7;
106
- background-size: 0.5rem;
107
- box-sizing: content-box;
108
- &:hover {
109
- opacity: 1;
110
- }
111
- }
102
+ .rc-filter-header {
103
+ border-bottom: 1px solid var(--bs-border-color);
112
104
  }
113
105
 
114
106
  .rc-empty-state {
@@ -1,17 +1,20 @@
1
1
  {{{ if topics.length }}}
2
2
  <div class="recent-cards-plugin preventSlideout">
3
3
  <script type="application/json" class="rc-categories-data">{categories}</script>
4
- {{{ if title }}}
5
- <h5>{title}</h5>
6
- {{{ end }}}
4
+ <script type="application/json" class="rc-widget-config">{widgetConfig}</script>
7
5
 
8
- <div class="rc-filter-bar d-flex align-items-center flex-wrap gap-2 mb-2">
9
- <div class="rc-filter-wrapper position-relative">
10
- <button class="rc-filter-btn btn btn-sm btn-outline-secondary d-flex align-items-center gap-1"
6
+ <div class="rc-filter-bar d-flex align-items-center justify-content-between mb-2">
7
+ {{{ if title }}}
8
+ <h5 class="mb-0 flex-grow-1">{title}</h5>
9
+ {{{ else }}}
10
+ <div class="flex-grow-1"></div>
11
+ {{{ end }}}
12
+ <div class="rc-filter-wrapper position-relative flex-shrink-0">
13
+ <button class="rc-filter-btn btn btn-sm d-flex align-items-center gap-1"
11
14
  type="button"
12
15
  aria-haspopup="listbox"
13
16
  aria-expanded="false">
14
- <i class="fa fa-filter fa-xs"></i>
17
+ <i class="fa fa-sliders fa-xs"></i>
15
18
  <span class="rc-filter-label">Filtrele</span>
16
19
  <i class="fa fa-caret-down fa-xs rc-caret"></i>
17
20
  </button>
@@ -19,7 +22,6 @@
19
22
  <div class="rc-filter-dropdown-inner"></div>
20
23
  </div>
21
24
  </div>
22
- <div class="rc-filter-chips d-flex align-items-center flex-wrap gap-1"></div>
23
25
  </div>
24
26
 
25
27
  <div class="{{{ if !carouselMode }}}row{{{ else }}}d-flex gap-3{{{ end }}} recent-cards carousel-mode overflow-hidden invisible" itemscope itemtype="http://www.schema.org/ItemList" {{{ if carouselMode }}}style=""{{{ end }}}>