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

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.20",
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,119 @@ $(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
175
+ // Update button immediately
176
+ updateFilterButton(pluginContainer, selectedCids, categories);
177
+
178
+ // If showing all, and we have the original cards stashed, restore them
179
+ if (showAll && pluginContainer.data('rc-original-cards')) {
180
+ destroySlick(carouselContainer);
181
+ carouselContainer.html(pluginContainer.data('rc-original-cards'));
182
+ emptyState.addClass('d-none');
183
+ carouselContainer.removeClass('d-none');
184
+ if (isCarouselMode) {
185
+ carouselContainer.addClass('overflow-hidden invisible');
186
+ initSlick(carouselContainer);
187
+ } else {
188
+ carouselContainer.removeClass('carousel-mode invisible');
206
189
  }
190
+ return;
207
191
  }
208
192
 
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');
193
+ if (showAll) {
194
+ return; // nothing to filter, initial load handles it
214
195
  }
215
196
 
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
- });
197
+ // Stash original cards on first filter
198
+ if (!pluginContainer.data('rc-original-cards')) {
199
+ pluginContainer.data('rc-original-cards', carouselContainer.html());
200
+ }
201
+
202
+ // Show loading state
203
+ destroySlick(carouselContainer);
204
+ carouselContainer.addClass('overflow-hidden invisible');
227
205
 
228
- if (toStashElements.length) {
229
- const toStash = $(toStashElements).detach();
230
- pluginContainer.data('rc-stashed-cards', toStash);
206
+ // Cancel any in-flight request to prevent race conditions
207
+ const prevXhr = pluginContainer.data('rc-xhr');
208
+ if (prevXhr && prevXhr.readyState !== 4) {
209
+ prevXhr.abort();
231
210
  }
232
211
 
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 {
212
+ // Fetch filtered topics from server
213
+ const params = $.param({
214
+ cids: selectedCids.join(','),
215
+ sort: widgetCfg.sort || 'recent',
216
+ teaserPost: widgetCfg.teaserPost || 'first',
217
+ teaserParseType: widgetCfg.teaserParseType || 'default',
218
+ thumbnailStyle: widgetCfg.thumbnailStyle || 'background',
219
+ });
220
+
221
+ const xhr = $.getJSON(config.relative_path + '/plugins/nodebb-plugin-recent-cards/filter?' + params, function (data) {
222
+ const parsed = $(data.html);
223
+ const newCards = parsed.find('.recent-cards').html();
224
+
225
+ if (!newCards || !newCards.trim()) {
226
+ emptyState.removeClass('d-none');
227
+ carouselContainer.addClass('d-none').removeClass('overflow-hidden invisible');
228
+ return;
229
+ }
230
+
239
231
  emptyState.addClass('d-none');
240
- carouselContainer.removeClass('d-none');
241
- }
232
+ carouselContainer.removeClass('d-none').html(newCards);
242
233
 
243
- // Step 5: Reinitialize slick with only the cards still in the DOM
244
- if (isCarouselMode && visibleCount > 0) {
245
- carouselContainer.addClass('overflow-hidden invisible');
246
- initSlick(carouselContainer);
247
- } else if (!isCarouselMode) {
248
- carouselContainer.removeClass('carousel-mode invisible');
249
- }
234
+ if (isCarouselMode) {
235
+ carouselContainer.addClass('overflow-hidden invisible');
236
+ initSlick(carouselContainer);
237
+ } else {
238
+ carouselContainer.removeClass('carousel-mode invisible overflow-hidden');
239
+ }
240
+ }).fail(function (jqXHR, textStatus) {
241
+ if (textStatus === 'abort') return; // intentional cancel, ignore
242
+ // On error, restore original cards
243
+ if (pluginContainer.data('rc-original-cards')) {
244
+ carouselContainer.html(pluginContainer.data('rc-original-cards'));
245
+ }
246
+ carouselContainer.removeClass('overflow-hidden invisible');
247
+ if (isCarouselMode) {
248
+ initSlick(carouselContainer);
249
+ }
250
+ });
250
251
 
251
- // Step 6: Update UI
252
- updateChips(pluginContainer, selectedCids, categories);
253
- updateFilterButton(pluginContainer, selectedCids, categories);
252
+ pluginContainer.data('rc-xhr', xhr);
253
+ }
254
+
255
+ function destroySlick(container) {
256
+ if (container.hasClass('slick-initialized')) {
257
+ try {
258
+ container.slick('unslick');
259
+ } catch (e) {
260
+ // Slick was not properly initialized
261
+ }
262
+ }
254
263
  }
255
264
 
256
265
  function parseCategories(pluginContainer) {
@@ -303,16 +312,7 @@ $(document).ready(function () {
303
312
  applyFilter(pluginContainer, selected, categories);
304
313
  });
305
314
 
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');
313
- });
314
-
315
- // Clear all
315
+ // Clear all (from dropdown header)
316
316
  pluginContainer.on('click.rcFilter', '.rc-clear-all', function (e) {
317
317
  e.stopPropagation();
318
318
  pluginContainer.find('.rc-filter-option input').prop('checked', true);
@@ -320,20 +320,17 @@ $(document).ready(function () {
320
320
  applyFilter(pluginContainer, null, categories);
321
321
  });
322
322
 
323
- // Apply saved filter if exists
323
+ // Always init slick first with whatever cards are on the page
324
+ initSlick(pluginContainer.find('.recent-cards'));
325
+
326
+ // Then apply saved filter if exists (triggers AJAX fetch)
324
327
  if (savedFilter) {
325
328
  applyFilter(pluginContainer, savedFilter, categories);
326
- return true; // filter was applied (slick handled inside applyFilter)
327
329
  }
328
-
329
- return false; // no filter applied, caller should init slick
330
330
  }
331
331
 
332
332
  function setupWidget(pluginContainer) {
333
- const filterApplied = initFilter(pluginContainer);
334
- if (!filterApplied) {
335
- initSlick(pluginContainer.find('.recent-cards'));
336
- }
333
+ initFilter(pluginContainer);
337
334
  }
338
335
 
339
336
  // 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 }}}>