@okjavis/nodebb-theme-javis 4.0.4 → 5.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@okjavis/nodebb-theme-javis",
3
- "version": "4.0.4",
3
+ "version": "5.0.0",
4
4
  "description": "Modern, premium NodeBB theme for JAVIS Community - Extends Harmony with custom styling",
5
5
  "main": "theme.js",
6
6
  "scripts": {
@@ -0,0 +1,401 @@
1
+ 'use strict';
2
+
3
+
4
+ define('forum/search', [
5
+ 'search',
6
+ 'storage',
7
+ 'hooks',
8
+ 'alerts',
9
+ 'api',
10
+ 'translator',
11
+ 'categoryFilter',
12
+ 'userFilter',
13
+ ], function (searchModule, storage, hooks, alerts, api, translator, categoryFilter, userFilter) {
14
+ const Search = {};
15
+ let selectedUsers = [];
16
+ let selectedTags = [];
17
+ let selectedCids = [];
18
+ let searchFilters = {};
19
+ Search.init = function () {
20
+ const searchIn = $('#search-in');
21
+ searchIn.on('change', function () {
22
+ updateFormItemVisiblity(searchIn.val());
23
+ });
24
+
25
+ const searchQuery = $('#results').attr('data-search-query');
26
+ searchModule.highlightMatches(
27
+ searchQuery,
28
+ $('.search-results .content p, .search-results .topic-title')
29
+ );
30
+
31
+ $('#advanced-search form').off('submit').on('submit', function (e) {
32
+ e.preventDefault();
33
+ searchModule.query(getSearchDataFromDOM());
34
+ return false;
35
+ });
36
+
37
+ handleSavePreferences();
38
+
39
+ categoryFilterDropdown(ajaxify.data.selectedCids);
40
+ userFilterDropdown($('[component="user/filter"]'), ajaxify.data.userFilterSelected);
41
+ tagFilterDropdown($('[component="tag/filter"]'), ajaxify.data.tagFilterSelected);
42
+
43
+ $('[component="search/filters"]').on('hidden.bs.dropdown', '.dropdown', function () {
44
+ const updateFns = {
45
+ replies: updateReplyCountFilter,
46
+ time: updateTimeFilter,
47
+ sort: updateSortFilter,
48
+ tag: updateTagFilter,
49
+ };
50
+
51
+ if (updateFns[$(this).attr('data-filter-name')]) {
52
+ updateFns[$(this).attr('data-filter-name')]();
53
+ }
54
+
55
+ const searchFiltersNew = getSearchDataFromDOM();
56
+ if (JSON.stringify(searchFilters) !== JSON.stringify(searchFiltersNew)) {
57
+ searchFilters = searchFiltersNew;
58
+ searchModule.query(searchFilters);
59
+ }
60
+ });
61
+
62
+ fillOutForm();
63
+ updateTimeFilter();
64
+ updateReplyCountFilter();
65
+ updateSortFilter();
66
+
67
+ searchFilters = getSearchDataFromDOM();
68
+ };
69
+
70
+ function updateTagFilter() {
71
+ const isActive = selectedTags.length > 0;
72
+ let labelText = '[[search:tags]]';
73
+ if (selectedTags.length) {
74
+ labelText = translator.compile(
75
+ 'search:tags-x', selectedTags.map(u => u.value).join(', ')
76
+ );
77
+ }
78
+ $('[component="tag/filter/button"]').toggleClass(
79
+ 'active-filter', isActive
80
+ ).find('.filter-label').translateHtml(labelText);
81
+ }
82
+
83
+ function updateTimeFilter() {
84
+ const isActive = $('#post-time-range').val() > 0;
85
+ $('#post-time-button').toggleClass(
86
+ 'active-filter', isActive
87
+ ).find('.filter-label').translateText(
88
+ isActive ?
89
+ `[[search:time-${$('#post-time-filter').val()}-than-${$('#post-time-range').val()}]]` :
90
+ `[[search:time]]`
91
+ );
92
+ }
93
+
94
+ function updateSortFilter() {
95
+ const isActive = $('#post-sort-by').val() !== 'relevance' || $('#post-sort-direction').val() !== 'desc';
96
+ $('#sort-by-button').toggleClass(
97
+ 'active-filter', isActive
98
+ ).find('.filter-label').translateText(
99
+ isActive ?
100
+ `[[search:sort-by-${$('#post-sort-by').val()}-${$('#post-sort-direction').val()}]]` :
101
+ `[[search:sort]]`
102
+ );
103
+ }
104
+
105
+ function updateReplyCountFilter() {
106
+ const isActive = $('#reply-count').val() > 0;
107
+ $('#reply-count-button').toggleClass(
108
+ 'active-filter', isActive
109
+ ).find('.filter-label').translateText(
110
+ isActive ?
111
+ `[[search:replies-${$('#reply-count-filter').val()}-count, ${$('#reply-count').val()}]]` :
112
+ `[[search:replies]]`
113
+ );
114
+ }
115
+
116
+ function getSearchDataFromDOM() {
117
+ const form = $('#advanced-search');
118
+ const searchData = {
119
+ in: $('#search-in').val(),
120
+ };
121
+ searchData.term = $('#search-input').val();
122
+ if (['posts', 'titlesposts', 'titles', 'bookmarks'].includes(searchData.in)) {
123
+ searchData.matchWords = form.find('#match-words-filter').val();
124
+ searchData.by = selectedUsers.length ? selectedUsers.map(u => u.username) : undefined;
125
+ searchData.categories = selectedCids.length ? selectedCids : undefined;
126
+ searchData.searchChildren = form.find('#search-children').is(':checked');
127
+ searchData.hasTags = selectedTags.length ? selectedTags.map(t => t.value) : undefined;
128
+ searchData.replies = form.find('#reply-count').val();
129
+ searchData.repliesFilter = form.find('#reply-count-filter').val();
130
+ searchData.timeFilter = form.find('#post-time-filter').val();
131
+ searchData.timeRange = form.find('#post-time-range').val();
132
+ searchData.sortBy = form.find('#post-sort-by').val();
133
+ searchData.sortDirection = form.find('#post-sort-direction').val();
134
+ searchData.showAs = form.find('#show-results-as').val();
135
+ }
136
+
137
+ hooks.fire('action:search.getSearchDataFromDOM', {
138
+ form: form,
139
+ data: searchData,
140
+ });
141
+
142
+ return searchData;
143
+ }
144
+
145
+ function updateFormItemVisiblity(searchIn) {
146
+ const hideTitlePostFilters = !['posts', 'titles', 'bookmarks'].some(token => searchIn.includes(token));
147
+ $('.post-search-item').toggleClass('hidden', hideTitlePostFilters);
148
+ }
149
+
150
+ function fillOutForm() {
151
+ const params = utils.params({
152
+ disableToType: true,
153
+ });
154
+
155
+ const searchData = searchModule.getSearchPreferences();
156
+ const formData = utils.merge(searchData, params);
157
+
158
+ if (formData) {
159
+ if (ajaxify.data.term) {
160
+ $('#search-input').val(ajaxify.data.term);
161
+ }
162
+ formData.in = formData.in || ajaxify.data.searchDefaultIn;
163
+ $('#search-in').val(formData.in);
164
+ updateFormItemVisiblity(formData.in);
165
+
166
+ if (formData.matchWords) {
167
+ $('#match-words-filter').val(formData.matchWords);
168
+ }
169
+
170
+ if (formData.showAs) {
171
+ $('#show-results-as').val(formData.showAs);
172
+ }
173
+
174
+ if (formData.by) {
175
+ formData.by = Array.isArray(formData.by) ? formData.by : [formData.by];
176
+ formData.by.forEach(function (by) {
177
+ $('#posted-by-user').tagsinput('add', by);
178
+ });
179
+ }
180
+
181
+ if (formData.categories) {
182
+ $('#posted-in-categories').val(formData.categories);
183
+ }
184
+
185
+ if (formData.searchChildren) {
186
+ $('#search-children').prop('checked', true);
187
+ }
188
+
189
+ if (formData.hasTags) {
190
+ formData.hasTags = Array.isArray(formData.hasTags) ? formData.hasTags : [formData.hasTags];
191
+ formData.hasTags.forEach(function (tag) {
192
+ $('#has-tags').tagsinput('add', tag);
193
+ });
194
+ }
195
+
196
+ if (formData.replies) {
197
+ $('#reply-count').val(formData.replies);
198
+ $('#reply-count-filter').val(formData.repliesFilter);
199
+ }
200
+
201
+ if (formData.timeRange) {
202
+ $('#post-time-range').val(formData.timeRange);
203
+ $('#post-time-filter').val(formData.timeFilter);
204
+ }
205
+
206
+ if (formData.sortBy || ajaxify.data.searchDefaultSortBy) {
207
+ $('#post-sort-by').val(formData.sortBy || ajaxify.data.searchDefaultSortBy);
208
+ }
209
+ $('#post-sort-direction').val(formData.sortDirection || 'desc');
210
+
211
+ hooks.fire('action:search.fillOutForm', {
212
+ form: formData,
213
+ });
214
+ }
215
+ }
216
+
217
+ function handleSavePreferences() {
218
+ $('#save-preferences').on('click', function () {
219
+ const data = getSearchDataFromDOM();
220
+ const fieldsToSave = [
221
+ 'matchWords', 'in', 'showAs',
222
+ 'replies', 'repliesFilter',
223
+ 'timeFilter', 'timeRange',
224
+ 'sortBy', 'sortDirection',
225
+ ];
226
+ const saveData = {};
227
+ fieldsToSave.forEach((key) => {
228
+ saveData[key] = data[key];
229
+ });
230
+ storage.setItem('search-preferences', JSON.stringify(saveData));
231
+ alerts.success('[[search:search-preferences-saved]]');
232
+ return false;
233
+ });
234
+
235
+ $('#clear-preferences').on('click', async function () {
236
+ storage.removeItem('search-preferences');
237
+ const html = await app.parseAndTranslate('partials/search-filters', {});
238
+ $('[component="search/filters"]').replaceWith(html);
239
+ $('#search-in').val(ajaxify.data.searchDefaultIn);
240
+ $('#post-sort-by').val(ajaxify.data.searchDefaultSortBy);
241
+ $('#match-words-filter').val('all');
242
+ $('#show-results-as').val('posts');
243
+ // clearing dom removes all event handlers, reinitialize
244
+ userFilterDropdown($('[component="user/filter"]'), []);
245
+ tagFilterDropdown($('[component="tag/filter"]'), []);
246
+ categoryFilterDropdown([]);
247
+ alerts.success('[[search:search-preferences-cleared]]');
248
+ return false;
249
+ });
250
+ }
251
+
252
+
253
+ function categoryFilterDropdown(_selectedCids) {
254
+ ajaxify.data.allCategoriesUrl = '';
255
+ selectedCids = _selectedCids || [];
256
+ const dropdownEl = $('[component="category/filter"]');
257
+ categoryFilter.init(dropdownEl, {
258
+ selectedCids: _selectedCids,
259
+ updateButton: false, // prevent categoryFilter module from updating the button
260
+ onSelect: function (data) {
261
+ // Update selectedCids immediately when a category is clicked
262
+ ajaxify.data.selectedCids = data.selectedCids;
263
+ selectedCids = data.selectedCids;
264
+
265
+ // Trigger search immediately
266
+ const searchFiltersNew = getSearchDataFromDOM();
267
+ if (JSON.stringify(searchFilters) !== JSON.stringify(searchFiltersNew)) {
268
+ searchFilters = searchFiltersNew;
269
+ searchModule.query(searchFilters);
270
+ }
271
+ },
272
+ onHidden: async function (data) {
273
+ const isActive = data.selectedCids.length > 0 && data.selectedCids[0] !== 'all';
274
+ let labelText = '[[search:categories]]';
275
+ ajaxify.data.selectedCids = data.selectedCids;
276
+ selectedCids = data.selectedCids;
277
+ if (data.selectedCids.length === 1 && data.selectedCids[0] === 'all') {
278
+ ajaxify.data.selectedCategory = null;
279
+ } else if (data.selectedCids.length > 0) {
280
+ const categoryData = await api.get(`/categories/${data.selectedCids[0]}`);
281
+ ajaxify.data.selectedCategory = categoryData;
282
+ labelText = `[[search:categories-x, ${categoryData.name}]]`;
283
+ }
284
+ if (data.selectedCids.length > 1) {
285
+ labelText = `[[search:categories-x, ${data.selectedCids.length}]]`;
286
+ }
287
+
288
+ $('[component="category/filter/button"]').toggleClass(
289
+ 'active-filter', isActive
290
+ ).find('.filter-label').translateText(labelText);
291
+ },
292
+ localCategories: [],
293
+ });
294
+ }
295
+
296
+ function userFilterDropdown(el, _selectedUsers) {
297
+ selectedUsers = _selectedUsers || [];
298
+ userFilter.init(el, {
299
+ selectedUsers: _selectedUsers,
300
+ template: 'partials/search-filters',
301
+ onSelect: function (_selectedUsers) {
302
+ selectedUsers = _selectedUsers;
303
+ },
304
+ onHidden: function (_selectedUsers) {
305
+ const isActive = _selectedUsers.length > 0;
306
+ let labelText = '[[search:posted-by]]';
307
+ if (isActive) {
308
+ labelText = translator.compile(
309
+ 'search:posted-by-usernames', selectedUsers.map(u => u.username).join(', ')
310
+ );
311
+ }
312
+ el.find('[component="user/filter/button"]').toggleClass(
313
+ 'active-filter', isActive
314
+ ).find('.filter-label').translateText(labelText);
315
+ },
316
+ });
317
+ }
318
+
319
+ function tagFilterDropdown(el, _selectedTags) {
320
+ selectedTags = _selectedTags;
321
+ async function renderSelectedTags() {
322
+ const html = await app.parseAndTranslate('partials/search-filters', 'tagFilterSelected', {
323
+ tagFilterSelected: selectedTags,
324
+ });
325
+ el.find('[component="tag/filter/selected"]').html(html);
326
+ }
327
+ function tagValueToObject(value) {
328
+ const escapedTag = utils.escapeHTML(value);
329
+ return {
330
+ value: value,
331
+ valueEscaped: escapedTag,
332
+ valueEncoded: encodeURIComponent(value),
333
+ class: escapedTag.replace(/\s/g, '-'),
334
+ };
335
+ }
336
+
337
+ async function doSearch() {
338
+ let result = { tags: [] };
339
+ const query = el.find('[component="tag/filter/search"]').val();
340
+ if (query && query.length > 1) {
341
+ if (app.user.privileges['search:tags']) {
342
+ result = await socket.emit('topics.searchAndLoadTags', { query: query });
343
+ } else {
344
+ result = {
345
+ tags: [tagValueToObject(query)],
346
+ };
347
+ }
348
+ }
349
+
350
+ if (!result.tags.length) {
351
+ el.find('[component="tag/filter/results"]').translateHtml(
352
+ '[[tags:no-tags-found]]'
353
+ );
354
+ return;
355
+ }
356
+ result.tags = result.tags.slice(0, 20);
357
+ const tagMap = {};
358
+ result.tags.forEach((tag) => {
359
+ tagMap[tag.value] = tag;
360
+ });
361
+
362
+ const html = await app.parseAndTranslate('partials/search-filters', 'tagFilterResults', {
363
+ tagFilterResults: result.tags,
364
+ });
365
+ el.find('[component="tag/filter/results"]').html(html);
366
+ el.find('[component="tag/filter/results"] [data-tag]').on('click', async function () {
367
+ selectedTags.push(tagMap[$(this).attr('data-tag')]);
368
+ renderSelectedTags();
369
+ });
370
+ }
371
+
372
+ el.find('[component="tag/filter/search"]').on('keyup', utils.debounce(function () {
373
+ if (app.user.privileges['search:tags']) {
374
+ doSearch();
375
+ }
376
+ }, 1000));
377
+
378
+ el.on('click', '[component="tag/filter/delete"]', function () {
379
+ const deleteTag = $(this).attr('data-tag');
380
+ selectedTags = selectedTags.filter(tag => tag.value !== deleteTag);
381
+ renderSelectedTags();
382
+ });
383
+
384
+ el.find('[component="tag/filter/search"]').on('keyup', (e) => {
385
+ if (e.key === 'Enter' && !app.user.privileges['search:tags']) {
386
+ const value = el.find('[component="tag/filter/search"]').val();
387
+ if (value && selectedTags.every(tag => tag.value !== value)) {
388
+ selectedTags.push(tagValueToObject(value));
389
+ renderSelectedTags();
390
+ }
391
+ el.find('[component="tag/filter/search"]').val('');
392
+ }
393
+ });
394
+
395
+ el.on('shown.bs.dropdown', function () {
396
+ el.find('[component="tag/filter/search"]').trigger('focus');
397
+ });
398
+ }
399
+
400
+ return Search;
401
+ });
package/scss/_base.scss CHANGED
@@ -79,7 +79,8 @@ body {
79
79
  font-size: $jv-font-size-base;
80
80
  line-height: $jv-line-height-base;
81
81
  color: $jv-text-main;
82
- background-color: $jv-bg;
82
+ background: linear-gradient(180deg, #ffffff 0%, #e0f0ff 100%);
83
+ background-attachment: fixed;
83
84
  -webkit-font-smoothing: antialiased;
84
85
  -moz-osx-font-smoothing: grayscale;
85
86
  }
@@ -378,3 +378,55 @@
378
378
  text-align: left;
379
379
  justify-content: flex-start;
380
380
  }
381
+
382
+ // ===========================================================
383
+ // LINKEDIN SSO BUTTON
384
+ // ===========================================================
385
+ .linkedin-sso-btn {
386
+ background-color: #0077B5 !important;
387
+ border-color: #0077B5 !important;
388
+ color: #fff !important;
389
+ font-size: 16px !important;
390
+ font-weight: 600 !important;
391
+ border-radius: $jv-radius-md !important;
392
+ box-shadow: 0 4px 14px rgba(0, 119, 181, 0.35) !important;
393
+ transition: all 0.2s ease !important;
394
+
395
+ &:hover {
396
+ background-color: #005885 !important;
397
+ border-color: #005885 !important;
398
+ box-shadow: 0 6px 20px rgba(0, 119, 181, 0.45) !important;
399
+ transform: translateY(-1px);
400
+ color: #fff !important;
401
+ }
402
+
403
+ &:active {
404
+ transform: translateY(0);
405
+ box-shadow: 0 2px 8px rgba(0, 119, 181, 0.3) !important;
406
+ }
407
+
408
+ i {
409
+ font-size: 20px !important;
410
+ }
411
+ }
412
+
413
+ // LinkedIn login block styling
414
+ .linkedin-login-block {
415
+ max-width: 400px;
416
+ margin: 0 auto;
417
+ padding: $jv-space-6;
418
+ background: $jv-surface;
419
+ border-radius: $jv-radius-lg;
420
+ border: 1px solid $jv-border-subtle;
421
+ box-shadow: $jv-shadow-md;
422
+ }
423
+
424
+ // Hide admin login section by default (shown via JS when ?admin=true)
425
+ .admin-login-section {
426
+ display: none !important;
427
+ }
428
+
429
+ // When admin login is shown
430
+ .admin-login-section[style*="display: block"] {
431
+ display: block !important;
432
+ }
@@ -0,0 +1,44 @@
1
+ <button type="button" class="btn btn-ghost btn-sm d-flex align-items-center ff-secondary d-flex gap-2 dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
2
+ {{{ if selectedCategory }}}
3
+ <span class="category-item d-inline-flex align-items-center gap-1">
4
+ {buildCategoryIcon(selectedCategory, "18px", "rounded-circle")}
5
+ <span class="d-none d-md-inline fw-semibold">{selectedCategory.name}</span>
6
+ </span>
7
+ {{{ else }}}
8
+ <i class="fa fa-fw fa-list text-primary"></i>
9
+ <span class="d-none d-md-inline fw-semibold">[[unread:all-categories]]</span>{{{ end }}}
10
+ </button>
11
+
12
+ <div class="dropdown-menu p-1">
13
+ <div component="category-selector-search" class="p-1 hidden">
14
+ <input type="text" class="form-control form-control-sm" placeholder="[[search:type-to-search]]" autocomplete="off">
15
+ <hr class="mt-2 mb-0"/>
16
+ </div>
17
+
18
+ <ul component="category/list" class="list-unstyled mb-0 text-sm category-dropdown-menu ghost-scrollbar" role="menu">
19
+ <li role="presentation" class="category" data-cid="all">
20
+ <a class="dropdown-item rounded-1 d-flex align-items-center gap-2" role="menuitem" href="{{{ if allCategoriesUrl }}}{config.relative_path}/{allCategoriesUrl}{{{ else }}}#{{{ end }}}">
21
+ <div class="flex-grow-1">[[unread:all-categories]]</div>
22
+ <i component="category/select/icon" class="flex-shrink-0 fa fa-fw fa-check {{{if selectedCategory}}}invisible{{{end}}}"></i>
23
+ </a>
24
+ </li>
25
+ {{{each categoryItems}}}
26
+ <li role="presentation" class="category {{{ if ./disabledClass }}}disabled{{{ end }}}" data-cid="{./cid}" data-parent-cid="{./parentCid}" data-name="{./name}">
27
+ <a class="dropdown-item rounded-1 d-flex align-items-center gap-2 {{{ if ./disabledClass }}}disabled{{{ end }}}" role="menuitem" href="#">
28
+ {./level}
29
+ <span component="category-markup" class="flex-grow-1" style="{{{ if ./match }}}font-weight: bold;{{{end}}}">
30
+ {{{ if ./icon }}}
31
+ <div class="category-item d-inline-flex align-items-center gap-1">
32
+ {buildCategoryIcon(@value, "24px", "rounded-circle")}
33
+ {./name}
34
+ </div>
35
+ {{{ else }}}
36
+ {./name}
37
+ {{{ end }}}
38
+ </span>
39
+ <i component="category/select/icon" class="flex-shrink-0 fa fa-fw fa-check {{{ if !./selected }}}invisible{{{ end }}}"></i>
40
+ </a>
41
+ </li>
42
+ {{{end}}}
43
+ </ul>
44
+ </div>