diff --git a/.github/workflows/f24_nodebb-yerim-test.yml b/.github/workflows/f24_nodebb-yerim-test.yml
new file mode 100644
index 0000000000..be411ebcf0
--- /dev/null
+++ b/.github/workflows/f24_nodebb-yerim-test.yml
@@ -0,0 +1,62 @@
+# Docs for the Azure Web Apps Deploy action: https://github.com/Azure/webapps-deploy
+# More GitHub Actions for Azure: https://github.com/Azure/actions
+
+name: Build and deploy Node.js app to Azure Web App - nodebb-yerim-test
+
+on:
+ push:
+ branches:
+ - f24
+ workflow_dispatch:
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Set up Node.js version
+ uses: actions/setup-node@v3
+ with:
+ node-version: '20.x'
+
+ - name: npm install, build, and test
+ run: |
+ npm install
+ npm run build --if-present
+ npm run test --if-present
+
+ - name: Zip artifact for deployment
+ run: zip release.zip ./* -r
+
+ - name: Upload artifact for deployment job
+ uses: actions/upload-artifact@v3
+ with:
+ name: node-app
+ path: release.zip
+
+ deploy:
+ runs-on: ubuntu-latest
+ needs: build
+ environment:
+ name: 'Production'
+ url: ${{ steps.deploy-to-webapp.outputs.webapp-url }}
+
+ steps:
+ - name: Download artifact from build job
+ uses: actions/download-artifact@v3
+ with:
+ name: node-app
+
+ - name: Unzip artifact for deployment
+ run: unzip release.zip
+
+ - name: 'Deploy to Azure Web App'
+ id: deploy-to-webapp
+ uses: azure/webapps-deploy@v2
+ with:
+ app-name: 'nodebb-yerim-test'
+ slot-name: 'Production'
+ publish-profile: ${{ secrets.AZUREAPPSERVICE_PUBLISHPROFILE_7987CBBA808B4A44A7974B909CC34E48 }}
+ package: .
diff --git a/README.md b/README.md
index 6ef180f625..a9b0e10bff 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,4 @@
+[](https://classroom.github.com/a/ithVU1OO)
# 
[](https://github.com/CMU-313/NodeBB/actions/workflows/test.yaml)
@@ -81,3 +82,31 @@ Interested in a sublicense agreement for use of NodeBB in a non-free/restrictive
* Unofficial IRC community – channel `#nodebb` on Libera.chat
* [Follow us on Twitter](http://www.twitter.com/NodeBB/ "NodeBB Twitter")
* [Like us on Facebook](http://www.facebook.com/NodeBB/ "NodeBB Facebook")
+
+=======
+## Team Members
+Kester Tan
+Anuja Uppuluri
+Phillip Araujo
+Camille Day
+
+## Using nodebb-theme-slackers
+We have a custom theme called `nodebb-theme-slackers` to incorporate new front-end UI features into our project.
+
+Here is how to set it up:
+#### Add it to node_modules
+1. Make sure the directory `nodebb-theme-slackers` is located in the root directory (along with `public`, `src` for example)
+2. Navigate to `nodebb-theme-slackers` (run `cd nodebb-theme-slackers/` from the root directory)
+3. Run `npm link`
+4. Navigate back to the root directory (run `cd ..`)
+5. Run `npm link nodebb-theme-slackers`
+
+#### Use the new custom theme
+1. After making sure NodeBB is not running (run `./nodebb stop`), run `./nodebb reset -t nodebb-theme-slackers`. Now our new custom theme will be used when NodeBB is relaunched.
+2. Make sure `redis-server` is running so that we can launch our project.
+3. Launch NodeBB in your preferred way:
+```
+ ./nodebb stop && ./nodebb reset -t nodebb-theme-slackers && ./nodebb build && ./nodebb start
+```
+4. Go to http://localhost:4567/ to see the new theme in action!
+
diff --git a/UserGuide.md b/UserGuide.md
new file mode 100644
index 0000000000..d2f86430ac
--- /dev/null
+++ b/UserGuide.md
@@ -0,0 +1,49 @@
+# User Guide
+In this project, we added two new features:
+
+1. A search bar that can search for topics within a category.
+2. A system to tag questions as answered/closed and tag answers as verified by a teacher (similar to Piazza)
+
+The front-end components of these new features are implemented using our custom theme: `nodebb-theme-slackers`.
+
+## Using our custom theme
+Here is how to set up and use our custom theme.
+
+#### Add it to node_modules
+1. Make sure the directory `nodebb-theme-slackers` is located in the root directory (along with `public`, `src` for example)
+2. Navigate to `nodebb-theme-slackers` (run `cd nodebb-theme-slackers/` from the root directory)
+3. Run `npm link`
+4. Navigate back to the root directory (run `cd ..`)
+5. Run `npm link nodebb-theme-slackers`
+
+#### Use the new custom theme
+1. After making sure NodeBB is not running (run `./nodebb stop`), run `./nodebb reset -t nodebb-theme-slackers`. Now our new custom theme will be used when NodeBB is relaunched.
+2. Make sure `redis-server` is running so that we can launch our project.
+3. Launch NodeBB in your preferred way:
+```
+./nodebb reset -t nodebb-theme-slackers && ./nodebb build && ./nodebb start
+```
+4. Go to http://localhost:4567/ to see the new theme in action!
+
+## Search bar
+### How to use
+Our search bar component is found under any category. After launching the local NodeBB app, **navigate to a category** (such as General Discussion or Announcements).
+
+If you set up the theme properly, you should see a search bar in between the title and the tags/filters. Simply click on this search bar and type a search query. If there are existing topics within this category that contain that search query, they will be displayed. You can still click on any of these topics after being filtered and it will properly navigate you to within the topic.
+
+### Automated testing
+TODO (include file location of automated tests)
+
+## Tags
+### How to use
+TODO
+### Automated testing
+TODO (include file location of automated tests)
+
+
+## Verify Message
+### How to use
+Our verify message component is found under the dropdown beside a post. Notice how there is a cross beside the post/comment. This indicates that the post/comment is not yet verified. If you are an administrator or moderator, you can click verify message to verify the post/comment. Refresh the page to notice that the cross has now been changed to a tick.
+
+### Automated testing
+Automated tests for permissions and for backend APIs for verify/unverify message can be found in `tests/posts.js`.
diff --git a/nodebb-theme-slackers/languages/harmony.json b/nodebb-theme-slackers/languages/harmony.json
new file mode 100644
index 0000000000..9e26dfeeb6
--- /dev/null
+++ b/nodebb-theme-slackers/languages/harmony.json
@@ -0,0 +1 @@
+{}
\ No newline at end of file
diff --git a/nodebb-theme-slackers/lib/controllers.js b/nodebb-theme-slackers/lib/controllers.js
new file mode 100644
index 0000000000..379ec06509
--- /dev/null
+++ b/nodebb-theme-slackers/lib/controllers.js
@@ -0,0 +1,29 @@
+'use strict';
+
+const Controllers = module.exports;
+
+const accountHelpers = require.main.require('./src/controllers/accounts/helpers');
+const helpers = require.main.require('./src/controllers/helpers');
+
+Controllers.renderAdminPage = (req, res) => {
+ res.render('admin/plugins/harmony', {
+ title: '[[themes/harmony:theme-name]]',
+ });
+};
+
+Controllers.renderThemeSettings = async (req, res, next) => {
+ const userData = await accountHelpers.getUserDataByUserSlug(req.params.userslug, req.uid, req.query);
+ if (!userData) {
+ return next();
+ }
+ const lib = require('../library');
+ userData.theme = await lib.loadThemeConfig(userData.uid);
+
+ userData.title = '[[themes/harmony:settings.title]]';
+ userData.breadcrumbs = helpers.buildBreadcrumbs([
+ { text: userData.username, url: `/user/${userData.userslug}` },
+ { text: '[[themes/harmony:settings.title]]' },
+ ]);
+
+ res.render('account/theme', userData);
+};
diff --git a/nodebb-theme-slackers/library.js b/nodebb-theme-slackers/library.js
new file mode 100644
index 0000000000..6ab72cf4ce
--- /dev/null
+++ b/nodebb-theme-slackers/library.js
@@ -0,0 +1,190 @@
+'use strict';
+
+const nconf = require.main.require('nconf');
+const meta = require.main.require('./src/meta');
+const _ = require.main.require('lodash');
+const user = require.main.require('./src/user');
+
+const controllers = require('./lib/controllers');
+
+const library = module.exports;
+
+const defaults = {
+ enableQuickReply: 'on',
+ enableBreadcrumbs: 'on',
+ centerHeaderElements: 'off',
+ mobileTopicTeasers: 'off',
+ stickyToolbar: 'on',
+ autohideBottombar: 'on',
+ openSidebars: 'off',
+ chatModals: 'off',
+};
+
+library.init = async function (params) {
+ const { router, middleware } = params;
+ const routeHelpers = require.main.require('./src/routes/helpers');
+
+ routeHelpers.setupAdminPageRoute(router, '/admin/plugins/harmony', [], controllers.renderAdminPage);
+
+ routeHelpers.setupPageRoute(router, '/user/:userslug/theme', [
+ middleware.exposeUid,
+ middleware.ensureLoggedIn,
+ middleware.canViewUsers,
+ middleware.checkAccountPermissions,
+ ], controllers.renderThemeSettings);
+
+ if (nconf.get('isPrimary') && process.env.NODE_ENV === 'production') {
+ setTimeout(buildSkins, 0);
+ }
+};
+
+async function buildSkins() {
+ try {
+ const plugins = require.main.require('./src/plugins');
+ await plugins.prepareForBuild(['client side styles']);
+ for (const skin of meta.css.supportedSkins) {
+ // eslint-disable-next-line no-await-in-loop
+ await meta.css.buildBundle(`client-${skin}`, true);
+ }
+ require.main.require('./src/meta/minifier').killAll();
+ } catch (err) {
+ console.error(err.stack);
+ }
+}
+
+library.addAdminNavigation = async function (header) {
+ header.plugins.push({
+ route: '/plugins/harmony',
+ icon: 'fa-paint-brush',
+ name: '[[themes/harmony:theme-name]]',
+ });
+ return header;
+};
+
+library.addProfileItem = async (data) => {
+ data.links.push({
+ id: 'theme',
+ route: 'theme',
+ icon: 'fa-paint-brush',
+ name: '[[themes/harmony:settings.title]]',
+ visibility: {
+ self: true,
+ other: false,
+ moderator: false,
+ globalMod: false,
+ admin: false,
+ },
+ });
+
+ return data;
+};
+
+library.defineWidgetAreas = async function (areas) {
+ const locations = ['header', 'sidebar', 'footer'];
+ const templates = [
+ 'categories.tpl', 'category.tpl', 'topic.tpl', 'users.tpl',
+ 'unread.tpl', 'recent.tpl', 'popular.tpl', 'top.tpl', 'tags.tpl', 'tag.tpl',
+ 'login.tpl', 'register.tpl',
+ ];
+ function capitalizeFirst(str) {
+ return str.charAt(0).toUpperCase() + str.slice(1);
+ }
+ templates.forEach((template) => {
+ locations.forEach((location) => {
+ areas.push({
+ name: `${capitalizeFirst(template.split('.')[0])} ${capitalizeFirst(location)}`,
+ template: template,
+ location: location,
+ });
+ });
+ });
+
+ areas = areas.concat([
+ {
+ name: 'Main post header',
+ template: 'topic.tpl',
+ location: 'mainpost-header',
+ },
+ {
+ name: 'Main post footer',
+ template: 'topic.tpl',
+ location: 'mainpost-footer',
+ },
+ {
+ name: 'Sidebar Footer',
+ template: 'global',
+ location: 'sidebar-footer',
+ },
+ {
+ name: 'Brand Header',
+ template: 'global',
+ location: 'brand-header',
+ },
+ {
+ name: 'About me (before)',
+ template: 'account/profile.tpl',
+ location: 'profile-aboutme-before',
+ },
+ {
+ name: 'About me (after)',
+ template: 'account/profile.tpl',
+ location: 'profile-aboutme-after',
+ },
+ ]);
+
+ return areas;
+};
+
+library.loadThemeConfig = async function (uid) {
+ const [themeConfig, userConfig] = await Promise.all([
+ meta.settings.get('harmony'),
+ user.getSettings(uid),
+ ]);
+
+ const config = { ...defaults, ...themeConfig, ...(_.pick(userConfig, Object.keys(defaults))) };
+ config.enableQuickReply = config.enableQuickReply === 'on';
+ config.enableBreadcrumbs = config.enableBreadcrumbs === 'on';
+ config.centerHeaderElements = config.centerHeaderElements === 'on';
+ config.mobileTopicTeasers = config.mobileTopicTeasers === 'on';
+ config.stickyToolbar = config.stickyToolbar === 'on';
+ config.autohideBottombar = config.autohideBottombar === 'on';
+ config.openSidebars = config.openSidebars === 'on';
+ config.chatModals = config.chatModals === 'on';
+ return config;
+};
+
+library.getThemeConfig = async function (config) {
+ config.theme = await library.loadThemeConfig(config.uid);
+ config.openDraftsOnPageLoad = false;
+ return config;
+};
+
+library.getAdminSettings = async function (hookData) {
+ if (hookData.plugin === 'harmony') {
+ hookData.values = {
+ ...defaults,
+ ...hookData.values,
+ };
+ }
+ return hookData;
+};
+
+library.saveUserSettings = async function (hookData) {
+ Object.keys(defaults).forEach((key) => {
+ if (hookData.data.hasOwnProperty(key)) {
+ hookData.settings[key] = hookData.data[key] || undefined;
+ }
+ });
+ return hookData;
+};
+
+library.filterMiddlewareRenderHeader = async function (hookData) {
+ hookData.templateData.bootswatchSkinOptions = await meta.css.getSkinSwitcherOptions(hookData.req.uid);
+ return hookData;
+};
+
+library.filterTeasersConfigureStripTags = function (hookData) {
+ // teasers have a stretched-link to go to last post, the anchors in them are not clickable
+ hookData.tags.push('a');
+ return hookData;
+};
diff --git a/nodebb-theme-slackers/package.json b/nodebb-theme-slackers/package.json
new file mode 100644
index 0000000000..33811d2891
--- /dev/null
+++ b/nodebb-theme-slackers/package.json
@@ -0,0 +1,29 @@
+{
+ "name": "nodebb-theme-slackers",
+ "version": "1.2.63",
+ "nbbpm": {
+ "compatibility": "^3.7.0"
+ },
+ "description": "Our Slackers custom theme for NodeBB",
+ "main": "library.js",
+ "scripts": {
+ "lint": "eslint ."
+ },
+ "keywords": [
+ "nodebb",
+ "theme",
+ "forum",
+ "bootstrap",
+ "responsive"
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@fontsource/inter": "5.0.15",
+ "@fontsource/poppins": "5.0.8"
+ },
+ "devDependencies": {
+ "eslint": "^9.0.0",
+ "eslint-config-nodebb": "^0.2.0",
+ "eslint-plugin-import": "^2.24.2"
+ }
+}
diff --git a/nodebb-theme-slackers/plugin.json b/nodebb-theme-slackers/plugin.json
new file mode 100644
index 0000000000..10c822b5d9
--- /dev/null
+++ b/nodebb-theme-slackers/plugin.json
@@ -0,0 +1,26 @@
+{
+ "id": "nodebb-theme-slackers",
+ "hooks": [
+ { "hook": "static:app.load", "method": "init" },
+ { "hook": "filter:admin.header.build", "method": "addAdminNavigation" },
+ { "hook": "filter:widgets.getAreas", "method": "defineWidgetAreas" },
+ { "hook": "filter:config.get", "method": "getThemeConfig" },
+ { "hook": "filter:settings.get", "method": "getAdminSettings"},
+ { "hook": "filter:user.saveSettings", "method": "saveUserSettings" },
+ { "hook": "filter:user.profileMenu", "method": "addProfileItem" },
+ { "hook": "filter:middleware.renderHeader", "method": "filterMiddlewareRenderHeader" },
+ { "hook": "filter:teasers.configureStripTags", "method": "filterTeasersConfigureStripTags"}
+ ],
+ "scripts": [
+ "public/harmony.js"
+ ],
+ "modules": {
+ "../admin/plugins/harmony.js": "public/admin.js",
+ "../client/account/theme.js": "public/settings.js"
+ },
+ "staticDirs": {
+ "inter": "node_modules/@fontsource/inter/files",
+ "poppins": "node_modules/@fontsource/poppins/files"
+ },
+ "languages": "languages"
+}
\ No newline at end of file
diff --git a/nodebb-theme-slackers/public/.eslintrc b/nodebb-theme-slackers/public/.eslintrc
new file mode 100644
index 0000000000..a3ce8297a6
--- /dev/null
+++ b/nodebb-theme-slackers/public/.eslintrc
@@ -0,0 +1,3 @@
+{
+ "extends": "nodebb/public"
+}
diff --git a/nodebb-theme-slackers/public/admin.js b/nodebb-theme-slackers/public/admin.js
new file mode 100644
index 0000000000..6df37a7156
--- /dev/null
+++ b/nodebb-theme-slackers/public/admin.js
@@ -0,0 +1,15 @@
+'use strict';
+
+define('admin/plugins/harmony', ['settings'], function (Settings) {
+ var ACP = {};
+
+ ACP.init = function () {
+ Settings.load('harmony', $('.harmony-settings'));
+
+ $('#save').on('click', function () {
+ Settings.save('harmony', $('.harmony-settings'));
+ });
+ };
+
+ return ACP;
+});
diff --git a/nodebb-theme-slackers/public/harmony.js b/nodebb-theme-slackers/public/harmony.js
new file mode 100644
index 0000000000..63870554f3
--- /dev/null
+++ b/nodebb-theme-slackers/public/harmony.js
@@ -0,0 +1,287 @@
+'use strict';
+
+$(document).ready(function () {
+ setupSkinSwitcher();
+ setupNProgress();
+ setupMobileMenu();
+ setupSearch();
+ setupDrafts();
+ handleMobileNavigator();
+ setupNavTooltips();
+ fixPlaceholders();
+ fixSidebarOverflow();
+
+ function setupSkinSwitcher() {
+ $('[component="skinSwitcher"]').on('click', '.dropdown-item', function () {
+ const skin = $(this).attr('data-value');
+ $('[component="skinSwitcher"] .dropdown-item .fa-check').addClass('invisible');
+ $(this).find('.fa-check').removeClass('invisible');
+ require(['forum/account/settings', 'hooks'], function (accountSettings, hooks) {
+ hooks.one('action:skin.change', function () {
+ $('[component="skinSwitcher"] [component="skinSwitcher/icon"]').removeClass('fa-fade');
+ });
+ $('[component="skinSwitcher"] [component="skinSwitcher/icon"]').addClass('fa-fade');
+ accountSettings.changeSkin(skin);
+ });
+ });
+ }
+
+ require(['hooks'], function (hooks) {
+ $(window).on('action:composer.resize action:sidebar.toggle', function () {
+ const isRtl = $('html').attr('data-dir') === 'rtl';
+ const css = {
+ width: $('#panel').width(),
+ };
+ const sidebarEl = $('.sidebar-left');
+ css[isRtl ? 'right' : 'left'] = sidebarEl.is(':visible') ? sidebarEl.outerWidth(true) : 0;
+ $('[component="composer"]').css(css);
+ });
+
+ hooks.on('filter:chat.openChat', function (hookData) {
+ // disables chat modals & goes straight to chat page based on user setting
+ hookData.modal = config.theme.chatModals && !utils.isMobile();
+ return hookData;
+ });
+ });
+
+ function setupMobileMenu() {
+ require(['hooks', 'api', 'navigator'], function (hooks, api, navigator) {
+ $('[component="sidebar/toggle"]').on('click', async function () {
+ const sidebarEl = $('.sidebar');
+ sidebarEl.toggleClass('open');
+ if (app.user.uid) {
+ await api.put(`/users/${app.user.uid}/settings`, {
+ settings: {
+ openSidebars: sidebarEl.hasClass('open') ? 'on' : 'off',
+ },
+ });
+ }
+ $(window).trigger('action:sidebar.toggle');
+ if (ajaxify.data.template.topic) {
+ hooks.fire('action:navigator.update', { newIndex: navigator.getIndex() });
+ }
+ });
+
+ const bottomBar = $('[component="bottombar"]');
+ const $body = $('body');
+ const $window = $(window);
+ $body.on('shown.bs.dropdown hidden.bs.dropdown', '.sticky-tools', function () {
+ bottomBar.toggleClass('hidden', $(this).find('.dropdown-menu.show').length);
+ });
+ function isSearchVisible() {
+ return !!$('[component="bottombar"] [component="sidebar/search"] .search-dropdown.show').length;
+ }
+
+ let lastScrollTop = 0;
+ let newPostsLoaded = false;
+
+ function onWindowScroll() {
+ const st = $window.scrollTop();
+ if (newPostsLoaded) {
+ newPostsLoaded = false;
+ lastScrollTop = st;
+ return;
+ }
+ if (st !== lastScrollTop && !navigator.scrollActive && !isSearchVisible()) {
+ const diff = Math.abs(st - lastScrollTop);
+ const scrolledDown = st > lastScrollTop;
+ const scrolledUp = st < lastScrollTop;
+ if (diff > 5) {
+ bottomBar.css({
+ bottom: !scrolledUp && scrolledDown ?
+ -bottomBar.find('.bottombar-nav').outerHeight(true) :
+ 0,
+ });
+ }
+ }
+ lastScrollTop = st;
+ }
+
+ const delayedScroll = utils.throttle(onWindowScroll, 250);
+ function enableAutohide() {
+ $window.off('scroll', delayedScroll);
+ if (config.theme.autohideBottombar) {
+ lastScrollTop = $window.scrollTop();
+ $window.on('scroll', delayedScroll);
+ }
+ }
+
+ hooks.on('action:posts.loading', function () {
+ $window.off('scroll', delayedScroll);
+ });
+ hooks.on('action:posts.loaded', function () {
+ newPostsLoaded = true;
+ setTimeout(enableAutohide, 250);
+ });
+ hooks.on('action:ajaxify.end', function () {
+ $window.off('scroll', delayedScroll);
+ bottomBar.css({ bottom: 0 });
+ setTimeout(enableAutohide, 250);
+ });
+ });
+ }
+
+ function setupSearch() {
+ $('[component="sidebar/search"]').on('shown.bs.dropdown', function () {
+ $(this).find('[component="search/fields"] input[name="query"]').trigger('focus');
+ });
+ }
+
+ function setupDrafts() {
+ require(['composer/drafts', 'bootbox'], function (drafts, bootbox) {
+ const draftsEl = $('[component="sidebar/drafts"]');
+
+ function updateBadgeCount() {
+ const count = drafts.getAvailableCount();
+ if (count > 0) {
+ draftsEl.removeClass('hidden');
+ }
+ $('[component="drafts/count"]').toggleClass('hidden', count <= 0).text(count);
+ }
+
+ async function renderDraftList() {
+ const draftListEl = $('[component="drafts/list"]');
+ const draftItems = drafts.listAvailable();
+ if (!draftItems.length) {
+ draftListEl.find('.no-drafts').removeClass('hidden');
+ draftListEl.find('.placeholder-wave').addClass('hidden');
+ draftListEl.find('.draft-item-container').html('');
+ return;
+ }
+ draftItems.reverse().forEach((draft) => {
+ if (draft) {
+ if (draft.title) {
+ draft.title = utils.escapeHTML(String(draft.title));
+ }
+ draft.text = utils.escapeHTML(
+ draft.text
+ ).replace(/(?:\r\n|\r|\n)/g, '
');
+ }
+ });
+
+ const html = await app.parseAndTranslate('partials/sidebar/drafts', 'drafts', { drafts: draftItems });
+ draftListEl.find('.no-drafts').addClass('hidden');
+ draftListEl.find('.placeholder-wave').addClass('hidden');
+ draftListEl.find('.draft-item-container').html(html).find('.timeago').timeago();
+ }
+
+
+ draftsEl.on('shown.bs.dropdown', renderDraftList);
+
+ draftsEl.on('click', '[component="drafts/open"]', function () {
+ drafts.open($(this).attr('data-save-id'));
+ });
+
+ draftsEl.on('click', '[component="drafts/delete"]', function () {
+ const save_id = $(this).attr('data-save-id');
+ bootbox.confirm('[[modules:composer.discard-draft-confirm]]', function (ok) {
+ if (ok) {
+ drafts.removeDraft(save_id);
+ renderDraftList();
+ }
+ });
+ return false;
+ });
+
+ $(window).on('action:composer.drafts.save', updateBadgeCount);
+ $(window).on('action:composer.drafts.remove', updateBadgeCount);
+ updateBadgeCount();
+ });
+ }
+
+ function setupNProgress() {
+ require(['nprogress'], function (NProgress) {
+ window.nprogress = NProgress;
+ if (NProgress) {
+ $(window).on('action:ajaxify.start', function () {
+ NProgress.set(0.7);
+ });
+
+ $(window).on('action:ajaxify.end', function () {
+ NProgress.done(true);
+ });
+ }
+ });
+ }
+
+ function handleMobileNavigator() {
+ const paginationBlockEl = $('.pagination-block');
+ require(['hooks'], function (hooks) {
+ hooks.on('action:ajaxify.end', function () {
+ paginationBlockEl.find('.dropdown-menu.show').removeClass('show');
+ });
+ hooks.on('filter:navigator.scroll', function (hookData) {
+ paginationBlockEl.find('.dropdown-menu.show').removeClass('show');
+ return hookData;
+ });
+ });
+ }
+
+ function setupNavTooltips() {
+ // remove title from user icon in sidebar to prevent double tooltip
+ $('.sidebar [component="header/avatar"] .avatar').removeAttr('title');
+ const tooltipEls = $('.sidebar [title]');
+ const lefttooltipEls = $('.sidebar-left [title]');
+ const rightooltipEls = $('.sidebar-right [title]');
+ const isRtl = $('html').attr('data-dir') === 'rtl';
+ lefttooltipEls.tooltip({
+ trigger: 'manual',
+ animation: false,
+ placement: isRtl ? 'left' : 'right',
+ });
+ rightooltipEls.tooltip({
+ trigger: 'manual',
+ animation: false,
+ placement: isRtl ? 'right' : 'left',
+ });
+
+ tooltipEls.on('mouseenter', function (ev) {
+ const target = $(ev.target);
+ const isDropdown = target.hasClass('dropdown-menu') || !!target.parents('.dropdown-menu').length;
+ if (!$('.sidebar').hasClass('open') && !isDropdown) {
+ $(this).tooltip('show');
+ }
+ });
+ tooltipEls.on('click mouseleave', function () {
+ $(this).tooltip('hide');
+ });
+ }
+
+ function fixPlaceholders() {
+ if (!config.loggedIn) {
+ return;
+ }
+ ['notifications', 'chat'].forEach((type) => {
+ const countEl = document.querySelector(`[component="${type}/count"]`);
+ if (!countEl) {
+ return;
+ }
+ const count = parseInt(countEl.innerText, 10);
+ if (count > 1) {
+ const listEls = document.querySelectorAll(`[component="${type}/list"]`);
+ listEls.forEach((listEl) => {
+ const placeholder = listEl.querySelector('*');
+ if (placeholder) {
+ for (let x = 0; x < count - 1; x++) {
+ const cloneEl = placeholder.cloneNode(true);
+ listEl.insertBefore(cloneEl, placeholder);
+ }
+ }
+ });
+ }
+ });
+ }
+
+ function fixSidebarOverflow() {
+ // overflow-y-auto needs to be removed on main-nav when dropdowns are opened
+ const mainNavEl = $('#main-nav');
+ function toggleOverflow() {
+ mainNavEl.toggleClass(
+ 'overflow-y-auto',
+ !mainNavEl.find('.dropdown-menu.show').length
+ );
+ }
+ mainNavEl.on('shown.bs.dropdown', toggleOverflow)
+ .on('hidden.bs.dropdown', toggleOverflow);
+ }
+});
diff --git a/nodebb-theme-slackers/public/settings.js b/nodebb-theme-slackers/public/settings.js
new file mode 100644
index 0000000000..e677db7fe0
--- /dev/null
+++ b/nodebb-theme-slackers/public/settings.js
@@ -0,0 +1,31 @@
+'use strict';
+
+define('forum/account/theme', ['forum/account/header', 'api', 'settings', 'alerts'], function (header, api, settings, alerts) {
+ const Theme = {};
+
+ Theme.init = () => {
+ header.init();
+ Theme.setupForm();
+ };
+
+ Theme.setupForm = () => {
+ const saveEl = document.getElementById('save');
+ if (saveEl) {
+ const formEl = document.getElementById('theme-settings');
+ saveEl.addEventListener('click', async () => {
+ const themeSettings = settings.helper.serializeForm($(formEl));
+ await api.put(`/users/${ajaxify.data.uid}/settings`, {
+ settings: {
+ ...themeSettings,
+ },
+ });
+ if (ajaxify.data.isSelf) {
+ config.theme = (await api.get('/api/config')).theme;
+ }
+ alerts.success('[[success:settings-saved]]');
+ });
+ }
+ };
+
+ return Theme;
+});
diff --git a/nodebb-theme-slackers/screenshots/categories.png b/nodebb-theme-slackers/screenshots/categories.png
new file mode 100644
index 0000000000..7cae6309a1
Binary files /dev/null and b/nodebb-theme-slackers/screenshots/categories.png differ
diff --git a/nodebb-theme-slackers/screenshots/recent.png b/nodebb-theme-slackers/screenshots/recent.png
new file mode 100644
index 0000000000..9551a27575
Binary files /dev/null and b/nodebb-theme-slackers/screenshots/recent.png differ
diff --git a/nodebb-theme-slackers/screenshots/topic.png b/nodebb-theme-slackers/screenshots/topic.png
new file mode 100644
index 0000000000..3e5c92cf55
Binary files /dev/null and b/nodebb-theme-slackers/screenshots/topic.png differ
diff --git a/nodebb-theme-slackers/scss/account.scss b/nodebb-theme-slackers/scss/account.scss
new file mode 100644
index 0000000000..0a318b3e1e
--- /dev/null
+++ b/nodebb-theme-slackers/scss/account.scss
@@ -0,0 +1,27 @@
+.page-user.page-status-200 #panel {
+ margin-top: 0px!important;
+}
+.account {
+ margin-top: 200px;
+
+ @include media-breakpoint-up(md) {
+ margin-top: 300px;
+ }
+
+ .categories {
+ [component="categories/category"] {
+ $category-pad: 50;
+ @for $i from 1 through 6 {
+ .depth-#{$i} {
+ padding-left: #{$category-pad * $i}px;
+ }
+ }
+ }
+ }
+}
+
+[component="group/badge/item"]:first-child [component="group/order/up"],
+[component="group/badge/item"]:last-child [component="group/order/down"] {
+ opacity: 0.65;
+ pointer-events: none;
+}
diff --git a/nodebb-theme-slackers/scss/category.scss b/nodebb-theme-slackers/scss/category.scss
new file mode 100644
index 0000000000..41bff4d4eb
--- /dev/null
+++ b/nodebb-theme-slackers/scss/category.scss
@@ -0,0 +1,4 @@
+.category-header .description p { margin: 0; }
+.page-category .breadcrumb .breadcrumb-item:last-child {
+ display: none;
+}
\ No newline at end of file
diff --git a/nodebb-theme-slackers/scss/chats.scss b/nodebb-theme-slackers/scss/chats.scss
new file mode 100644
index 0000000000..e0c5f49a96
--- /dev/null
+++ b/nodebb-theme-slackers/scss/chats.scss
@@ -0,0 +1,12 @@
+// themes have a different layout so each one needs this block to set height to 100%
+body.page-user-chats {
+ > .layout-container {
+ height: 100%;
+ > #panel {
+ height: 100%;
+ > .container {
+ height: 100%;
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/nodebb-theme-slackers/scss/common.scss b/nodebb-theme-slackers/scss/common.scss
new file mode 100644
index 0000000000..b78ceea634
--- /dev/null
+++ b/nodebb-theme-slackers/scss/common.scss
@@ -0,0 +1,130 @@
+
+html {
+ height: 100%;
+}
+
+body {
+ overflow-y: scroll;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ text-rendering: optimizeLegibility;
+}
+
+// fixes chrome font boosting :/ https://stackoverflow.com/questions/13430897/how-to-override-font-boosting-in-mobile-chrome
+body * {
+ max-height:1000000px;
+ text-size-adjust: none;
+ -webkit-text-size-adjust: none;
+ -moz-text-size-adjust: none;
+}
+
+hr {
+ border-top-color: var(--bs-border-color);
+ opacity: 1;
+}
+
+.ff-base { font-family: $font-family-base !important; }
+.ff-sans { font-family: $font-family-sans-serif !important; }
+.ff-secondary { font-family: $font-family-secondary; }
+.tracking-tight { letter-spacing: -0.02em; }
+
+.caret {
+ &::after {
+ border: none;
+ font-family: "FontAwesome";
+ content: "\f078";
+ }
+}
+
+.placeholder-wave {
+ opacity: 0.5;
+}
+
+.bg-card-cap {
+ --bs-bg-opacity: 1;
+ background-color: $card-cap-bg!important;
+}
+
+blockquote {
+ $bq-border-color: mix($light, $dark, 75%);
+ @extend .text-bg-light;
+ font-style: normal;
+ border-left: 2px solid $bq-border-color;
+ padding: 1rem;
+ p:last-child {
+ margin-bottom: 0;
+ }
+ .toggle {
+ border-color: $bq-border-color!important;
+ }
+}
+
+body:not(.page-user) {
+ #content {
+ transition: opacity 150ms linear;
+ &.ajaxifying {
+ -moz-opacity: 0;
+ opacity: 0;
+ }
+ }
+}
+.page-user {
+ #content {
+ transition: opacity 150ms linear;
+ &.ajaxifying .account-content {
+ transition: opacity 150ms linear;
+ -moz-opacity: 0;
+ opacity: 0;
+ }
+ }
+}
+
+.sticky-tools {
+ position: sticky;
+ z-index: 3;
+ top: 0;
+ padding: 0.25rem 0;
+}
+// quartz doesn't need body-bg for tool background
+.skin-quartz .sticky-tools {
+ background-color: initial;
+}
+
+.btn-link {
+ &:hover, &.active {
+ background-color: var(--btn-ghost-hover-color);
+ text-decoration: none;
+ }
+}
+
+.flex-basis-md-200 {
+ @include media-breakpoint-up(md) {
+ flex-basis: 200px!important;
+ }
+}
+
+.markdown-highlight {
+ @extend .shadow-sm;
+ @extend .border;
+}
+
+[component="chat/message/body"], [component="post/content"] {
+ .img-fluid {
+ @extend .shadow-sm;
+ padding: $spacer * 0.5;
+ margin: $spacer * 0.5 0;
+ border: 1px solid $border-color;
+ background-color: $light;
+ border-radius: $border-radius-sm;
+ max-height: 500px;
+ width: auto;
+ }
+}
+
+[component="chat/message/body"],
+[component="post/content"],
+[component="topic/teaser"] .post-content,
+[component="category/posts"] .post-content,
+.post-queue.posts-list .post-content {
+ a { text-decoration: underline;}
+}
\ No newline at end of file
diff --git a/nodebb-theme-slackers/scss/fonts.scss b/nodebb-theme-slackers/scss/fonts.scss
new file mode 100644
index 0000000000..5d3ae65fa9
--- /dev/null
+++ b/nodebb-theme-slackers/scss/fonts.scss
@@ -0,0 +1,19 @@
+@use "@fontsource/inter/scss/mixins" as Inter;
+@use "@fontsource/poppins/scss/mixins" as Poppins;
+
+$weights: $font-weight-light, $font-weight-normal, $font-weight-semibold, $font-weight-bold;
+$subsets: (latin, latin-ext);
+$font-path: "./plugins/nodebb-theme-harmony" !default;
+
+@include Inter.faces(
+ $weights: $weights,
+ $subsets: $subsets,
+ $display: fallback,
+ $directory: "#{$font-path}/inter"
+);
+@include Poppins.faces(
+ $weights: $weights,
+ $subsets: $subsets,
+ $display: fallback,
+ $directory: "#{$font-path}/poppins"
+);
diff --git a/nodebb-theme-slackers/scss/groups.scss b/nodebb-theme-slackers/scss/groups.scss
new file mode 100644
index 0000000000..914e3fce90
--- /dev/null
+++ b/nodebb-theme-slackers/scss/groups.scss
@@ -0,0 +1,22 @@
+.template-groups-details #panel {
+ margin-top: 0px!important;
+}
+
+.group-hover-bg {
+ $hover-color: mix($light, $dark, 97%);
+ $border-color: mix($light, $dark, 90%);
+ .card-body {
+ border-color: $border-color!important;
+ }
+ &:hover {
+ background-color: $hover-color;
+ }
+}
+
+.groups.details {
+ margin-top: 200px;
+
+ @include media-breakpoint-up(md) {
+ margin-top: 300px;
+ }
+}
diff --git a/nodebb-theme-slackers/scss/harmony.scss b/nodebb-theme-slackers/scss/harmony.scss
new file mode 100644
index 0000000000..f209a54f5e
--- /dev/null
+++ b/nodebb-theme-slackers/scss/harmony.scss
@@ -0,0 +1,26 @@
+@import "fonts";
+@import "mixins";
+@import "common";
+
+@import "header";
+@import "topic";
+@import "category";
+@import "chats";
+@import "sidebar";
+@import "status";
+@import "account";
+@import "groups";
+@import "modals";
+
+@import "modules/breadcrumbs";
+@import "modules/tags";
+@import "modules/user-menu";
+@import "modules/bottom-sheet";
+@import "modules/topic-navigator";
+@import "modules/topics-list";
+@import "modules/cover";
+@import "modules/nprogress";
+@import "modules/paginator";
+@import "modules/filters";
+
+@import "skins";
\ No newline at end of file
diff --git a/nodebb-theme-slackers/scss/header.scss b/nodebb-theme-slackers/scss/header.scss
new file mode 100644
index 0000000000..a64d632c11
--- /dev/null
+++ b/nodebb-theme-slackers/scss/header.scss
@@ -0,0 +1,16 @@
+// hide brand/title on user and group details pages so it doesnt break covers
+body[class*="template-account-"], .template-chats, .template-groups-details {
+ .brand-container {
+ display: none;
+ }
+}
+[component="brand/wrapper"] {
+ &:hover {
+ background-color: $card-cap-bg;
+ }
+}
+
+[component="brand/logo"] {
+ max-height: 48px;
+ width: auto;
+}
\ No newline at end of file
diff --git a/nodebb-theme-slackers/scss/mixins.scss b/nodebb-theme-slackers/scss/mixins.scss
new file mode 100644
index 0000000000..641b38bb7b
--- /dev/null
+++ b/nodebb-theme-slackers/scss/mixins.scss
@@ -0,0 +1,177 @@
+@mixin topic-avatars() {
+ .icon .avatar, .timeline-badge {
+ z-index: 1;
+
+ line-height: calc(var(--avatar-size) - 4px);
+ }
+
+ [component="user/status"] {
+ top: 20px;
+ left: 12px;
+
+ z-index: 2;
+ }
+
+ @include media-breakpoint-up(sm) {
+ [component="user/status"] {
+ padding: 5px;
+ top: 36px;
+ left: 36px;
+ }
+ }
+}
+
+@mixin timeline-style() {
+ > [component="post"], .timeline-event, > [component="post/placeholder"] {
+ position: relative; // for absolutely positioned pseudo-element, below
+ border: 0;
+ margin-left: 1.5rem;
+ transition: border-color 1s ease-out;
+
+ &:first-child {
+ &:before {
+ content: '';
+ position: absolute;
+ top: 0;
+ height: 16px;
+ width: 16px;
+ background-color: $border-color;
+ border-radius: 100%;
+ transform: translate(calc(-50% - .5px), -100%);
+ transition: background-color 1s ease-out;
+ }
+
+ &.highlight:before {
+ background-color: $primary;
+ }
+ }
+
+ &:last-child {
+ padding-bottom: 2rem;
+
+ &:after {
+ content: '';
+ position: absolute;
+ bottom: 0;
+ height: 16px;
+ width: 16px;
+ background-color: $border-color;
+ border-radius: 100%;
+ transform: translate(calc(-50% - .5px), 100%);
+ transition: background-color 1s ease-out;
+ }
+
+ &.highlight:after {
+ background-color: $primary;
+ }
+ }
+
+ > div:first-of-type {
+ margin-left: -1.5rem;
+ }
+
+ &.highlight {
+ .bookmarked {
+ opacity: 1 !important;
+ }
+ }
+
+ @include topic-avatars();
+ }
+
+ [component="topic/event"], [component="topic/necro-post"] {
+ &.timeline-event {
+ text-align: left;
+ justify-content: left;
+ font-size: 1em;
+
+ .timeline-badge {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+
+ width: 20px;
+ height: 20px;
+ padding: 0;
+ margin-right: 1rem;
+ color: $gray-500;
+ background-color: $body-bg;
+ }
+
+ .timeline-text {
+ line-height: 32px;
+ text-transform: initial;
+ }
+ }
+ }
+
+ @include media-breakpoint-down(sm) {
+ > [component="post"], .timeline-event {
+ &:first-child:before, &:last-child:after {
+ display: none;
+ }
+ }
+
+ [component="post"] {
+ margin-left: initial;
+ > div:not(.content) {
+ margin-left: 0;
+ }
+ }
+
+ [component="post"]:last-child:after {
+ display: none;
+ }
+
+
+ [component="topic/event"], [component="topic/necro-post"] {
+ &.timeline-event {
+ .timeline-text {
+ line-height: 16px;
+ font-size: 0.75rem;
+ }
+ }
+ }
+ }
+
+ @include media-breakpoint-up(sm) {
+ > [component="post"], .timeline-event, > [component="post/placeholder"] {
+ border-left: 2px solid $border-color;
+
+ &.highlight {
+ border-left: 2px solid $primary;
+ }
+ }
+
+ .timeline-event {
+ margin-left: 1.5rem;
+
+ [component="topic/event/delete"] {
+ visibility: hidden;
+ }
+
+ &:hover {
+ [component="topic/event/delete"] {
+ visibility: visible;
+
+ &:hover {
+ color: $danger;
+ }
+ }
+ }
+ }
+
+ [component="topic/event"], [component="topic/necro-post"] {
+ &.timeline-event .timeline-badge {
+ width: 24px;
+ height: 24px;
+ padding: 0;
+ margin-left: -0.75rem;
+ margin-right: 1.25rem;
+ border: 2px solid $border-color;
+ border-radius: 50%;
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/nodebb-theme-slackers/scss/modals.scss b/nodebb-theme-slackers/scss/modals.scss
new file mode 100644
index 0000000000..201cfb5229
--- /dev/null
+++ b/nodebb-theme-slackers/scss/modals.scss
@@ -0,0 +1,6 @@
+.tool-modal {
+ @include media-breakpoint-up(md) {
+ bottom: $spacer * 3;
+ right: $spacer * 4;
+ }
+}
diff --git a/nodebb-theme-slackers/scss/modules/bottom-sheet.scss b/nodebb-theme-slackers/scss/modules/bottom-sheet.scss
new file mode 100644
index 0000000000..339e000a8b
--- /dev/null
+++ b/nodebb-theme-slackers/scss/modules/bottom-sheet.scss
@@ -0,0 +1,52 @@
+.bottom-sheet {
+ @include media-breakpoint-down(md) {
+ .dropdown-menu {
+ display: block;
+ visibility: hidden;
+
+ position: fixed!important;
+ inset: auto 0 0 0!important;
+
+ margin: 0 -1px -1px -1px;
+ padding: $spacer * 0.25 !important;
+ max-height: 60%;
+
+ box-shadow: 0 2px 6px rgba(0,0,0,0.35);
+ overflow: auto;
+ -webkit-overflow-scrolling: touch;
+ transform: translate3d(0, 350px, 0);
+ transition: transform 0.3s, visibility 0s 0.3s;
+ z-index: $zindex-popover;
+ padding: 5px 0 10px;
+
+ border-radius: 0;
+ border: 0px;
+ border-top: 1px solid $border-color;
+
+ > li {
+ > a, .dropdown-item {
+ padding: 10px 20px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
+ &.divider {
+ padding: 0;
+ }
+ }
+ }
+
+ .dropdown-menu.show {
+ transform: none!important;
+ visibility: visible;
+ transition-delay: 0s;
+ top: auto;
+ width: auto;
+ }
+
+ .dropdown-backdrop {
+ background-color: rgba(0, 0, 0, .3);
+ }
+ }
+}
diff --git a/nodebb-theme-slackers/scss/modules/breadcrumbs.scss b/nodebb-theme-slackers/scss/modules/breadcrumbs.scss
new file mode 100644
index 0000000000..6f7d855138
--- /dev/null
+++ b/nodebb-theme-slackers/scss/modules/breadcrumbs.scss
@@ -0,0 +1,16 @@
+.breadcrumb .breadcrumb-item {
+ font-family: $font-family-secondary;
+
+ &::before {
+ font-family: $font-family-sans-serif;
+ font-weight: $font-weight-semibold;
+ font-size: $small-font-size;
+ line-height: $h4-font-size;
+ }
+
+ a, span {
+ color: $body-color;
+ font-size: $small-font-size;
+ line-height: 16px;
+ }
+}
\ No newline at end of file
diff --git a/nodebb-theme-slackers/scss/modules/cover.scss b/nodebb-theme-slackers/scss/modules/cover.scss
new file mode 100644
index 0000000000..acdd601c03
--- /dev/null
+++ b/nodebb-theme-slackers/scss/modules/cover.scss
@@ -0,0 +1,105 @@
+// used in group and account pages
+.cover {
+ background-size: cover;
+ background-repeat: no-repeat;
+ height: 200px;
+ position: absolute;
+ background-origin: content-box;
+ width: 100%;
+ top: var(--panel-offset);
+ left: auto;
+ right: 0px;
+
+ &:hover {
+ .controls {
+ opacity: 1;
+ }
+ }
+
+ .controls {
+ height: 200px;
+ line-height: 200px;
+ opacity: 0;
+ @include transition(opacity .15s linear);
+ cursor: pointer;
+ pointer-events: none;
+
+ > * {
+ pointer-events: all;
+ }
+ }
+
+ &.active {
+ &:hover {
+ cursor: move;
+ }
+
+ .controls {
+ > * {
+ display: none;
+ }
+ }
+
+ .save {
+ display: inline-block;
+ }
+ }
+
+ &.saving {
+ .save {
+ display: none;
+ }
+
+ .indicator {
+ display: inline-block;
+ }
+ }
+
+ .save, .indicator {
+ display: inline-block;
+ position: absolute;
+ top: 1em;
+ right: 2em;
+ opacity: 1;
+ padding: 0.5em;
+ font-weight: bold;
+
+ &:hover {
+ cursor: pointer;
+ }
+ }
+
+ .save {
+ display: none;
+ }
+
+ .indicator {
+ display: none;
+ }
+}
+
+.cover > .container {
+ height: 200px;
+ position: relative;
+ pointer-events: none;
+ .save {
+ pointer-events: all;
+ }
+ .controls {
+ pointer-events: none;
+ > * {
+ pointer-events: all;
+ }
+ }
+}
+
+@include media-breakpoint-up(md) {
+ .cover, .cover > .container {
+ height: 300px;
+
+ .controls {
+ height: 300px;
+ line-height: 300px;
+ }
+ }
+}
\ No newline at end of file
diff --git a/nodebb-theme-slackers/scss/modules/filters.scss b/nodebb-theme-slackers/scss/modules/filters.scss
new file mode 100644
index 0000000000..1f70620d48
--- /dev/null
+++ b/nodebb-theme-slackers/scss/modules/filters.scss
@@ -0,0 +1,8 @@
+[component="search/filters"], [component="flags/filters"] {
+ .filter-btn {
+ border-color: $gray-300!important;
+ &.active-filter {
+ border-color: $primary!important;
+ }
+ }
+}
\ No newline at end of file
diff --git a/nodebb-theme-slackers/scss/modules/nprogress.scss b/nodebb-theme-slackers/scss/modules/nprogress.scss
new file mode 100644
index 0000000000..a576b32d44
--- /dev/null
+++ b/nodebb-theme-slackers/scss/modules/nprogress.scss
@@ -0,0 +1,80 @@
+#nprogress {
+ pointer-events: none;
+}
+
+$nprogress-color: $primary;
+
+#nprogress .bar {
+ background: $nprogress-color;
+ position: fixed;
+ z-index: 1031;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 2px;
+}
+
+#nprogress .peg {
+ display: block;
+ position: absolute;
+ right: 0px;
+ width: 100px;
+ height: 100%;
+ box-shadow: 0 0 10px $nprogress-color, 0 0 5px $nprogress-color;
+ opacity: 1.0;
+
+ -webkit-transform: rotate(3deg) translate(0px, -4px);
+ -ms-transform: rotate(3deg) translate(0px, -4px);
+ transform: rotate(3deg) translate(0px, -4px);
+}
+
+#nprogress .spinner {
+ display: none;
+ position: fixed;
+ z-index: 1031;
+ top: 15px;
+ right: 15px;
+}
+
+@include media-breakpoint-down(sm) {
+ #nprogress .spinner {
+ bottom: 15px;
+ right: 15px;
+ top: initial;
+ }
+}
+
+
+#nprogress .spinner-icon {
+ width: 18px;
+ height: 18px;
+ box-sizing: border-box;
+
+ border: solid 2px transparent;
+ border-top-color: $nprogress-color;
+ border-left-color: $nprogress-color;
+ border-radius: 50%;
+
+ -webkit-animation: nprogress-spinner 400ms linear infinite;
+ animation: nprogress-spinner 400ms linear infinite;
+}
+
+.nprogress-custom-parent {
+ overflow: hidden;
+ position: relative;
+}
+
+.nprogress-custom-parent #nprogress .spinner,
+.nprogress-custom-parent #nprogress .bar {
+ position: absolute;
+}
+
+@-webkit-keyframes nprogress-spinner {
+ 0% { -webkit-transform: rotate(0deg); }
+ 100% { -webkit-transform: rotate(360deg); }
+}
+@keyframes nprogress-spinner {
+ 0% { transform: rotate(0deg); }
+ 100% { transform: rotate(360deg); }
+}
+
diff --git a/nodebb-theme-slackers/scss/modules/paginator.scss b/nodebb-theme-slackers/scss/modules/paginator.scss
new file mode 100644
index 0000000000..1689c8e49b
--- /dev/null
+++ b/nodebb-theme-slackers/scss/modules/paginator.scss
@@ -0,0 +1,24 @@
+.skin-noskin [component="pagination"] {
+ .page-item.active:not(.disabled) .page-link {
+ color: $body-color;
+ background-color: $gray-300;
+ border-color: $gray-300;
+ }
+
+ .page-item:not(.disabled):hover .page-link {
+ color: $body-color;
+ }
+}
+
+[component="pagination"] {
+ .page-item.active:not(.disabled) .page-link {
+ color: $pagination-active-color;
+ }
+ .page-item:not(.disabled):hover .page-link {
+ color: $pagination-hover-color;
+ background-color: $pagination-hover-bg;
+ }
+ .page-item:not(.disabled) .page-link {
+ color: $body-color;
+ }
+}
\ No newline at end of file
diff --git a/nodebb-theme-slackers/scss/modules/tags.scss b/nodebb-theme-slackers/scss/modules/tags.scss
new file mode 100644
index 0000000000..f1217aa621
--- /dev/null
+++ b/nodebb-theme-slackers/scss/modules/tags.scss
@@ -0,0 +1,6 @@
+.tag-list {
+ .tag {
+ background-color: $gray-200!important;
+ color: $gray-700!important;
+ }
+}
\ No newline at end of file
diff --git a/nodebb-theme-slackers/scss/modules/topic-navigator.scss b/nodebb-theme-slackers/scss/modules/topic-navigator.scss
new file mode 100644
index 0000000000..35d6be6c1a
--- /dev/null
+++ b/nodebb-theme-slackers/scss/modules/topic-navigator.scss
@@ -0,0 +1,53 @@
+.pagination-block { display: none; }
+
+body.template-topic {
+ // used for both sidebar and bottom bar pagination-block
+ .pagination-block {
+ display: block;
+ transition: opacity 250ms ease-in;
+ opacity: 0;
+ &.ready {
+ opacity: 1;
+ }
+ &.noreplies {
+ pointer-events: none;
+ cursor: none;
+ }
+ }
+}
+
+.topic .pagination-block {
+ .scroller-content {
+ min-width: 170px;
+ }
+ .scroller-container {
+ left: 10px;
+ height: 300px;
+ border-left: 2px solid $border-color;
+
+ .scroller-thumb {
+ left: -5px;
+ &:not(.active) {
+ transition: top 100ms linear;
+ }
+ cursor: grab;
+ &.active {
+ cursor: grabbing;
+ }
+ }
+
+ .unread {
+ width: 1px;
+ height: 0; // initial
+ bottom: 0;
+ background: $primary;
+ transition: $transition-base;
+ left: -1px;
+
+ .meta {
+ left: 5px;
+ font-size: 13px;
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/nodebb-theme-slackers/scss/modules/topics-list.scss b/nodebb-theme-slackers/scss/modules/topics-list.scss
new file mode 100644
index 0000000000..6ce124bead
--- /dev/null
+++ b/nodebb-theme-slackers/scss/modules/topics-list.scss
@@ -0,0 +1,40 @@
+ul.topics-list, ul.categories-list {
+ li {
+ &.deleted {
+ .meta, .topic-thumbs { display: none!important; }
+ opacity: 0.65;
+ }
+
+ &.selected {
+ background-color: mix($body-bg, $body-color, 90%);
+ [component="topic/select"] {
+ color: $success!important;
+ visibility: visible;
+ }
+ }
+ p {
+ margin: 0 !important;
+ }
+
+ // all other skins use link-color for unread titles
+ &.unread .title {
+ color: $link-color;
+ }
+
+ .ui-sortable-handle {
+ cursor: move;
+ }
+
+ // if only one thumb don't display
+ [data-numthumbs="1"] { display: none; }
+ }
+}
+
+// on default skin use primary color for unread titles
+.skin-noskin {
+ ul.topics-list, ul.categories-list {
+ li.unread .title {
+ color: $primary;
+ }
+ }
+}
diff --git a/nodebb-theme-slackers/scss/modules/user-menu.scss b/nodebb-theme-slackers/scss/modules/user-menu.scss
new file mode 100644
index 0000000000..cda2ac064b
--- /dev/null
+++ b/nodebb-theme-slackers/scss/modules/user-menu.scss
@@ -0,0 +1,11 @@
+[component="header/usercontrol"] {
+ [component="header/profilelink"] > div, .user-status > div {
+ min-width: 1.25em; // match fontawesome fixed width
+ }
+ .user-status i.fa-check {
+ display: none;
+ }
+ .user-status.selected i.fa-check {
+ display: block;
+ }
+}
\ No newline at end of file
diff --git a/nodebb-theme-slackers/scss/overrides.scss b/nodebb-theme-slackers/scss/overrides.scss
new file mode 100644
index 0000000000..49de2660b7
--- /dev/null
+++ b/nodebb-theme-slackers/scss/overrides.scss
@@ -0,0 +1,64 @@
+// only overrides to bs5 variables here
+
+// Harmony colours
+$white: #fff !default;
+$gray-100: #f8f9fa !default;
+$gray-200: #e9ecef !default;
+$gray-300: #dee2e6 !default;
+$gray-400: #ced4da !default;
+$gray-500: #adb5bd !default;
+$gray-600: #6c757d !default;
+$gray-700: #495057 !default;
+$gray-800: #343a40 !default;
+$gray-900: #212529 !default;
+$black: #000 !default;
+
+$blue: #0d6efd !default;
+$red: #dc3545 !default;
+$yellow: #ffc107 !default;
+$green: #198754 !default;
+$cyan: #0dcaf0 !default;
+
+$primary: $blue !default;
+$secondary: $gray-600 !default;
+$success: $green !default;
+$info: $cyan !default;
+$warning: $yellow !default;
+$danger: $red !default;
+$light: $gray-100 !default;
+$dark: $gray-900 !default;
+
+$body-color: $gray-800 !default;
+$body-bg: $white !default;
+$body-tertiary-bg: $gray-200 !default;
+$text-muted: $gray-600 !default;
+$border-color: $gray-200 !default;
+$link-color: #0951be !default;
+
+$form-check-input-border: var(--bs-border-width) solid $gray-500 !default;
+
+// no caret on dropdown-toggle
+$enable-caret: false;
+
+// disable smooth scroll, this makes window.scrollTo(0,0) in ajaxify.js take x milliseconds
+$enable-smooth-scroll: false;
+
+$enable-shadows: true;
+
+$link-decoration: none;
+$link-hover-decoration: underline;
+
+// Custom fonts
+$font-family-sans-serif: "Inter", system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", "Liberation Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
+$font-family-secondary: "Poppins", system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", "Liberation Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji" !default;
+$font-weight-semibold: 500 !default;
+$small-font-size: 0.875rem !default;
+
+$breadcrumb-divider: quote("→");
+$breadcrumb-divider-color: $gray-500 !default;
+$breadcrumb-active-color: $body-color !default;
+$breadcrumb-item-padding-x: 12px !default;
+
+.form-control::placeholder, .bootstrap-tagsinput::placeholder {
+ color: $gray-500 !important;
+}
\ No newline at end of file
diff --git a/nodebb-theme-slackers/scss/sidebar.scss b/nodebb-theme-slackers/scss/sidebar.scss
new file mode 100644
index 0000000000..ca8df90d0c
--- /dev/null
+++ b/nodebb-theme-slackers/scss/sidebar.scss
@@ -0,0 +1,189 @@
+.skin-noskin {
+ // only using colors when there is no bootswatch skin applied
+ nav.sidebar, .bottombar-nav {
+ color: $secondary !important;
+ background-color: $light !important;
+ }
+ .bottombar-nav {
+ .dropdown-menu {
+ color: $secondary !important;
+ background-color: $light !important;
+ }
+ }
+}
+
+.sidebar {
+ $hover-color: mix($light, $dark, 90%);
+
+ @include media-breakpoint-up(lg) {
+ &.open {
+ min-width: 200px;
+ max-width: 200px;
+ width: 200px;
+
+ .sidebar-toggle {
+ .fa-angles-right { display: none; }
+ .fa-angles-left { display: inline-block; }
+ }
+ .visible-open { display: initial; }
+ .visible-closed { display: none; }
+ hr.visible-open { display: block; }
+ .truncate-open {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+ .sidebar-toggle-container {
+ width: 100%
+ }
+ }
+ }
+ .visible-open { display: none; }
+ .visible-closed {display: initial; }
+
+ .truncate-open {
+ overflow: initial;
+ text-overflow: initial;
+ white-space: initial;
+ }
+
+ .nav-link {
+ @extend .ff-secondary;
+ padding: ($spacer * 0.25) ($spacer * 0.5);
+ border-radius: $border-radius-sm;
+ cursor: pointer;
+ &.active {
+ background-color: $hover-color;
+ }
+ &:hover {
+ background-color: $hover-color;
+ }
+ }
+
+ .nav-item {
+ .dropdown-menu .dropdown-item {
+ @extend .rounded-1;
+ }
+ }
+
+ #user_dropdown .avatar {
+ margin: 2px 0; // fixes the avatar so its height is same as the icons on right sidebar
+ }
+
+ .sidebar-toggle {
+ justify-content: start;
+ .fa-angles-right { display: inline-block; }
+ .fa-angles-left { display: none; }
+ }
+
+ .search-dropdown {
+ width: 300px;
+ }
+
+ .chats-dropdown, .notifications-dropdown, .drafts-dropdown {
+ min-width: 300px;
+ width: 300px;
+ .list-container {
+ max-height: 400px;
+ overflow-y: auto;
+ }
+ }
+
+ .badge {
+ font-size: 9px;
+ line-height: 12px;
+ &.visible-open {
+ font-size:12px;
+ line-height: 12px;
+ font-weight: normal;
+ }
+ }
+
+ [data-widget-area="sidebar-footer"] {
+ font-size: $font-size-base * 0.75;
+ }
+}
+
+ /*rtl:begin:ignore*/
+ html[data-dir="rtl"] {
+ .sidebar {
+ &.open {
+ .sidebar-toggle {
+ .fa-angles-right { display: none; }
+ .fa-angles-left { display: inline-block; }
+ }
+ }
+ .sidebar-toggle {
+ .fa-angles-right { display: inline-block; }
+ .fa-angles-left { display: none; }
+ }
+ }
+}
+ /*rtl:end:ignore*/
+
+.bottombar {
+ transition: bottom 150ms linear;
+
+ .pagination-block {
+ .scroller-container {
+ border-right: 3px solid;
+ margin-right: 5.5px;
+ .scroller-thumb {
+ right: -6px;
+ padding-right: 15px;
+ margin-right: -15px;
+ }
+ }
+ }
+}
+
+.bottombar-nav {
+ .nav-text {
+ font-size: 1rem;
+ color: $body-color;
+ }
+ .nav-link {
+ padding: 8px;
+ border-radius: $border-radius-sm;
+ }
+ .usermenu, .chats, .notifications, .drafts, .search, .logged-out-menu {
+ .visible-open { display: none; }
+ }
+ .badge {
+ font-size: 9px;
+ line-height: 12px;
+ }
+
+ .navigation-dropdown, .user-dropdown {
+ > li {
+ > a, .dropdown-item {
+ padding: 10px 20px!important;
+ }
+ }
+ left: 0!important;
+ right: 0!important;
+ bottom: $spacer*0.5!important;
+ box-shadow: none!important;
+ max-height: 60vh!important;
+ overflow: auto!important;
+ }
+ .search-dropdown .quick-search-results {
+ max-height: 225px!important;
+ overflow-y: auto!important;
+ }
+ .search-dropdown, .chats-dropdown, .notifications-dropdown, .drafts-dropdown {
+ left: 0 !important;
+ right: 0 !important;
+ box-shadow: none!important;
+
+ border-left: 0;
+ border-right: 0;
+ border-bottom: 0;
+ border-radius: 0;
+
+ .list-container {
+ max-height: 60vh!important;
+ overflow-y: auto!important;
+ }
+ }
+}
diff --git a/nodebb-theme-slackers/scss/skins.scss b/nodebb-theme-slackers/scss/skins.scss
new file mode 100644
index 0000000000..322e3cb2f5
--- /dev/null
+++ b/nodebb-theme-slackers/scss/skins.scss
@@ -0,0 +1,50 @@
+.skin-quartz {
+ // $body-bg-image is gradient in quartz
+ [component="post"] .icon {
+ background-color: transparent !important;
+ }
+}
+
+.skin-quartz, .skin-lux, .skin-morph {
+ // $spacer being modified looks bad on this element
+ .topic-list-header .btn, .topic-main-buttons .btn {
+ padding: 6px 12px;
+ }
+}
+
+.skin-yeti {
+ .badge {
+ padding-left: 0.5rem;
+ padding-right: 0.5rem;
+ }
+}
+
+// table color fix, remove once https://github.com/thomaspark/bootswatch/issues/1276 is published
+.skin-darkly, .skin-superhero, .skin-solar, .skin-quartz {
+ table > :not(caption) > * > * {
+ color: white;
+ }
+}
+
+.skin-superhero {
+ // fix read button in dropdowns
+ .mark-read .read {
+ color: $primary!important;
+ }
+}
+
+.skin-slate {
+ // fix unread button colors in dropdowns
+ .mark-read .unread {
+ color: $secondary!important;
+ }
+}
+
+:root {
+ .skin-darkly, .skin-slate, .skin-cyborg {
+ --bs-border-color: #929292;
+ }
+ .skin-zephyr {
+ --bs-secondary-rgb: var(--bs-secondary-color);
+ }
+}
\ No newline at end of file
diff --git a/nodebb-theme-slackers/scss/status.scss b/nodebb-theme-slackers/scss/status.scss
new file mode 100644
index 0000000000..37c5f0a474
--- /dev/null
+++ b/nodebb-theme-slackers/scss/status.scss
@@ -0,0 +1,25 @@
+.status {
+ padding: 3px;
+
+ &.online {
+ background-color: $success;
+ }
+
+ &.away {
+ background-color: $warning;
+ }
+
+ &.dnd {
+ background-color: $danger;
+ }
+
+ &.offline {
+ background-color: $gray-600;
+ }
+}
+
+@include media-breakpoint-up(sm) {
+ .status {
+ padding: 5px;
+ }
+}
\ No newline at end of file
diff --git a/nodebb-theme-slackers/scss/topic.scss b/nodebb-theme-slackers/scss/topic.scss
new file mode 100644
index 0000000000..94f10bc36d
--- /dev/null
+++ b/nodebb-theme-slackers/scss/topic.scss
@@ -0,0 +1,139 @@
+body.template-topic {
+ .breadcrumb .breadcrumb-item:last-child {
+ display: none;
+ }
+ .topic {
+ .posts-container {
+ max-width: 960px;
+ width: 960px;
+ }
+
+ .posts {
+ // fixes code blocks pushing content out on mobile
+ @include media-breakpoint-down(md) {
+ max-width: calc(100vw - $grid-gutter-width);
+ }
+
+ &.timeline {
+ @include timeline-style;
+ }
+
+ .post-header {
+ font-size: 0.8125rem;
+ line-height: 1.25rem;
+
+ .bookmarked {
+ transition: $transition-fade;
+ }
+ }
+
+ > [component="post"] > [component="post/footer"] {
+ margin-left: calc($spacer * 2.5);
+ }
+
+ [component="post"] {
+ &.selected .post-container {
+ background-color: mix($body-bg, $body-color, 90%);
+ }
+ &.deleted .post-container .content { opacity: .65; }
+
+ [component="post/content"] {
+ @include fix-lists();
+
+ > blockquote {
+ > blockquote {
+ > *:not(.blockquote) {
+ display: none;
+ }
+ }
+
+ > blockquote.uncollapsed {
+ > *:not(.blockquote) {
+ display: block;
+ }
+ }
+ }
+
+ @include media-breakpoint-up(lg) {
+ table { // text-break breaks table formatting
+ word-break:initial!important;
+ }
+ }
+ }
+ }
+
+ [component="post/upvote"], [component="post/downvote"] {
+ &.upvoted, &.downvoted {
+ background-color: var(--btn-ghost-active-color);
+
+ &:hover {
+ background-color: var(--btn-ghost-hover-color);
+ }
+ }
+ }
+ }
+ }
+
+ .quick-reply {
+ @include topic-avatars();
+ }
+
+ [component="post/replies/container"] {
+ .icon {
+ display: none !important;
+ }
+
+ .post-header .icon {
+ display: initial !important;
+
+ .status {
+ display: none;
+ }
+ }
+
+ .timeline-event {
+ display: none !important;
+ }
+
+ [component="post"] {
+ padding-top: 0 !important;
+ padding-bottom: $spacer;
+ &:last-of-type {
+ padding-bottom: 0;
+ .post-footer {
+ border-bottom: none !important;
+ }
+ }
+ }
+ }
+
+
+ [component="topic/thumb/list"] {
+ height: calc($font-size-base * 4);
+ }
+}
+
+@include media-breakpoint-up(sm) {
+ body.template-topic {
+ .topic .posts {
+ [component="post"] {
+ [component="post/actions"] {
+ opacity: 0;
+ transition: $transition-fade;
+
+ &:has([aria-expanded="true"]) {
+ opacity: 1;
+ }
+ }
+ [component="post/actions"]:focus-within {
+ opacity: 1;
+ }
+ &:hover {
+ > .post-footer > [component="post/actions"] {
+ opacity: 1;
+ }
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/nodebb-theme-slackers/templates/category.tpl b/nodebb-theme-slackers/templates/category.tpl
new file mode 100644
index 0000000000..cf3973a793
--- /dev/null
+++ b/nodebb-theme-slackers/templates/category.tpl
@@ -0,0 +1,90 @@
+
+{{{ if config.theme.enableBreadcrumbs }}}
+
+{{{ end }}}
+
+