diff --git a/app/controllers/public/api/v1/portals/base_controller.rb b/app/controllers/public/api/v1/portals/base_controller.rb
index 70aa1e92e..df9526a69 100644
--- a/app/controllers/public/api/v1/portals/base_controller.rb
+++ b/app/controllers/public/api/v1/portals/base_controller.rb
@@ -14,7 +14,7 @@ class Public::Api::V1::Portals::BaseController < PublicController
@theme = if %w[dark light].include?(params[:theme])
params[:theme]
else
- ''
+ 'system'
end
end
diff --git a/app/helpers/portal_helper.rb b/app/helpers/portal_helper.rb
index 3551b5ac6..2011bb525 100644
--- a/app/helpers/portal_helper.rb
+++ b/app/helpers/portal_helper.rb
@@ -8,4 +8,29 @@ module PortalHelper
bg_image = theme == 'dark' ? 'grid_dark.svg' : 'grid.svg'
"background: url(/assets/images/hc/#{bg_image}) #{generate_portal_bg_color(portal_color, theme)}"
end
+
+ def language_name(locale)
+ language_map = YAML.load_file(Rails.root.join('config/languages/language_map.yml'))
+ language_map[locale] || locale
+ end
+
+ def get_theme_names(theme)
+ if theme == 'light'
+ I18n.t('public_portal.header.appearance.light')
+ elsif theme == 'dark'
+ I18n.t('public_portal.header.appearance.dark')
+ else
+ I18n.t('public_portal.header.appearance.system')
+ end
+ end
+
+ def get_theme_icon(theme)
+ if theme == 'light'
+ 'icons/sun'
+ elsif theme == 'dark'
+ 'icons/moon'
+ else
+ 'icons/monitor'
+ end
+ end
end
diff --git a/app/javascript/portal/portalHelpers.js b/app/javascript/portal/portalHelpers.js
index 8b36e63f9..de1a36548 100644
--- a/app/javascript/portal/portalHelpers.js
+++ b/app/javascript/portal/portalHelpers.js
@@ -4,7 +4,7 @@ import Vue from 'vue';
import PublicArticleSearch from './components/PublicArticleSearch.vue';
import TableOfContents from './components/TableOfContents.vue';
-export const getHeadingsFromTheArticle = () => {
+export const getHeadingsfromTheArticle = () => {
const rows = [];
const articleElement = document.getElementById('cw-article-content');
articleElement.querySelectorAll('h1, h2, h3').forEach(element => {
@@ -68,6 +68,37 @@ export const updateThemeStyles = theme => {
setPortalClass(theme);
};
+export const toggleAppearanceDropdown = () => {
+ const dropdown = document.getElementById('appearance-dropdown');
+ if (!dropdown) return;
+ dropdown.style.display =
+ dropdown.style.display === 'none' || !dropdown.style.display
+ ? 'flex'
+ : 'none';
+};
+
+export const updateURLParameter = (param, paramVal) => {
+ const urlObj = new URL(window.location);
+ urlObj.searchParams.set(param, paramVal);
+ return urlObj.toString();
+};
+
+export const removeURLParameter = parameter => {
+ const urlObj = new URL(window.location);
+ urlObj.searchParams.delete(parameter);
+ return urlObj.toString();
+};
+
+export const switchTheme = theme => {
+ updateThemeStyles(theme);
+ const newUrl =
+ theme !== 'system'
+ ? updateURLParameter('theme', theme)
+ : removeURLParameter('theme');
+ window.location.href = newUrl;
+ toggleAppearanceDropdown();
+};
+
export const InitializationHelpers = {
navigateToLocalePage: () => {
const allLocaleSwitcher = document.querySelector('.locale-switcher');
@@ -98,7 +129,7 @@ export const InitializationHelpers = {
if (isOnArticlePage) {
new Vue({
components: { TableOfContents },
- data: { rows: getHeadingsFromTheArticle() },
+ data: { rows: getHeadingsfromTheArticle() },
template: '',
}).$mount('#cw-hc-toc');
}
@@ -131,6 +162,26 @@ export const InitializationHelpers = {
}
},
+ initializeToggleButton: () => {
+ const toggleButton = document.getElementById('toggle-appearance');
+ if (toggleButton) {
+ toggleButton.addEventListener('click', toggleAppearanceDropdown);
+ }
+ },
+
+ initializeThemeSwitchButtons: () => {
+ const appearanceDropdown = document.getElementById('appearance-dropdown');
+ if (!appearanceDropdown) return;
+ appearanceDropdown.addEventListener('click', event => {
+ const target = event.target.closest('button[data-theme]');
+
+ if (target) {
+ const theme = target.getAttribute('data-theme');
+ switchTheme(theme);
+ }
+ });
+ },
+
initialize: () => {
if (window.portalConfig.isPlainLayoutEnabled === 'true') {
InitializationHelpers.appendPlainParamToURLs();
@@ -138,7 +189,9 @@ export const InitializationHelpers = {
InitializationHelpers.navigateToLocalePage();
InitializationHelpers.initializeSearch();
InitializationHelpers.initializeTableOfContents();
- // InitializationHelpers.initializeTheme();
+ InitializationHelpers.initializeTheme();
+ InitializationHelpers.initializeToggleButton();
+ InitializationHelpers.initializeThemeSwitchButtons();
}
},
diff --git a/app/javascript/portal/specs/portal.spec.js b/app/javascript/portal/specs/portal.spec.js
index 69e1e81f0..e203fdd53 100644
--- a/app/javascript/portal/specs/portal.spec.js
+++ b/app/javascript/portal/specs/portal.spec.js
@@ -6,6 +6,9 @@ import {
setPortalStyles,
setPortalClass,
updateThemeStyles,
+ toggleAppearanceDropdown,
+ updateURLParameter,
+ removeURLParameter,
} from '../portalHelpers';
describe('#navigateToLocalePage', () => {
@@ -126,12 +129,78 @@ describe('Theme Functions', () => {
document.body.innerHTML = '';
});
- it('sets portal class based on theme', () => {
+ it('sets portal class to "dark" based on theme', () => {
setPortalClass('dark');
-
expect(mockPortalDiv.classList.contains('dark')).toBe(true);
expect(mockPortalDiv.classList.contains('light')).toBe(false);
});
+
+ it('sets portal class to "light" based on theme', () => {
+ setPortalClass('light');
+ expect(mockPortalDiv.classList.contains('light')).toBe(true);
+ expect(mockPortalDiv.classList.contains('dark')).toBe(false);
+ });
+ });
+
+ describe('toggleAppearanceDropdown', () => {
+ it('sets dropdown display to flex if initially none', () => {
+ document.body.innerHTML = `
`;
+ toggleAppearanceDropdown();
+ const dropdown = document.getElementById('appearance-dropdown');
+ expect(dropdown.style.display).toBe('flex');
+ });
+
+ it('sets dropdown display to none if initially flex', () => {
+ document.body.innerHTML = ``;
+ toggleAppearanceDropdown();
+ const dropdown = document.getElementById('appearance-dropdown');
+ expect(dropdown.style.display).toBe('none');
+ });
+
+ it('does nothing if dropdown element does not exist', () => {
+ document.body.innerHTML = ``;
+ expect(() => toggleAppearanceDropdown()).not.toThrow();
+ });
+ });
+
+ describe('updateURLParameter', () => {
+ it('updates a given parameter with a new value', () => {
+ const originalUrl = 'http://example.com?param=oldValue';
+ delete window.location;
+ window.location = new URL(originalUrl);
+
+ const updatedUrl = updateURLParameter('param', 'newValue');
+ expect(updatedUrl).toContain('param=newValue');
+ });
+
+ it('adds a new parameter if it does not exist', () => {
+ const originalUrl = 'http://example.com';
+ delete window.location;
+ window.location = new URL(originalUrl);
+
+ const updatedUrl = updateURLParameter('newParam', 'value');
+ expect(updatedUrl).toContain('newParam=value');
+ });
+ });
+
+ describe('removeURLParameter', () => {
+ it('removes an existing parameter', () => {
+ const originalUrl = 'http://example.com?param=value';
+ delete window.location;
+ window.location = new URL(originalUrl);
+
+ const updatedUrl = removeURLParameter('param');
+ expect(updatedUrl).not.toContain('param=value');
+ });
+
+ it('does nothing if the parameter does not exist', () => {
+ const originalUrl = 'http://example.com/';
+ delete window.location;
+ window.location = new URL(originalUrl);
+
+ const updatedUrl = removeURLParameter('param');
+ expect(updatedUrl).toBe(originalUrl);
+ });
});
describe('#updateThemeStyles', () => {
@@ -223,4 +292,43 @@ describe('Theme Functions', () => {
expect(mockPortalDiv.classList.contains('light')).toBe(false);
});
});
+
+ describe('initializeToggleButton', () => {
+ it('adds a click listener to the toggle button', () => {
+ document.body.innerHTML = ``;
+ InitializationHelpers.initializeToggleButton();
+ const toggleButton = document.getElementById('toggle-appearance');
+ expect(toggleButton.onclick).toBeDefined();
+ });
+
+ it('does nothing if the toggle button is not present', () => {
+ document.body.innerHTML = ``;
+ expect(() =>
+ InitializationHelpers.initializeToggleButton()
+ ).not.toThrow();
+ });
+ });
+
+ describe('initializeThemeSwitchButtons', () => {
+ it('adds click listeners to theme switch buttons', () => {
+ document.body.innerHTML = ``;
+ InitializationHelpers.initializeThemeSwitchButtons();
+ const buttons = document.querySelectorAll('button[data-theme]');
+ buttons.forEach(button => expect(button.onclick).toBeDefined());
+ });
+
+ it('does nothing if theme switch buttons are not present', () => {
+ document.body.innerHTML = ``;
+ expect(() =>
+ InitializationHelpers.initializeThemeSwitchButtons()
+ ).not.toThrow();
+ });
+
+ it('does nothing if appearance-dropdown is not present', () => {
+ document.body.innerHTML = ``;
+ expect(() =>
+ InitializationHelpers.initializeThemeSwitchButtons()
+ ).not.toThrow();
+ });
+ });
});
diff --git a/app/views/icons/_check-mark.html.erb b/app/views/icons/_check-mark.html.erb
new file mode 100644
index 000000000..19020de8c
--- /dev/null
+++ b/app/views/icons/_check-mark.html.erb
@@ -0,0 +1,3 @@
+
diff --git a/app/views/icons/_chevron-down.html.erb b/app/views/icons/_chevron-down.html.erb
new file mode 100644
index 000000000..12b05bd0e
--- /dev/null
+++ b/app/views/icons/_chevron-down.html.erb
@@ -0,0 +1,3 @@
+
diff --git a/app/views/icons/_chevron-right.html.erb b/app/views/icons/_chevron-right.html.erb
new file mode 100644
index 000000000..ea10fc633
--- /dev/null
+++ b/app/views/icons/_chevron-right.html.erb
@@ -0,0 +1,3 @@
+
diff --git a/app/views/icons/_globe.html.erb b/app/views/icons/_globe.html.erb
new file mode 100644
index 000000000..63aeab697
--- /dev/null
+++ b/app/views/icons/_globe.html.erb
@@ -0,0 +1,5 @@
+
diff --git a/app/views/icons/_monitor.html.erb b/app/views/icons/_monitor.html.erb
new file mode 100644
index 000000000..cf338480c
--- /dev/null
+++ b/app/views/icons/_monitor.html.erb
@@ -0,0 +1,5 @@
+
diff --git a/app/views/icons/_moon.html.erb b/app/views/icons/_moon.html.erb
new file mode 100644
index 000000000..7db34b908
--- /dev/null
+++ b/app/views/icons/_moon.html.erb
@@ -0,0 +1,3 @@
+
diff --git a/app/views/icons/_palette.html.erb b/app/views/icons/_palette.html.erb
new file mode 100644
index 000000000..996c92330
--- /dev/null
+++ b/app/views/icons/_palette.html.erb
@@ -0,0 +1,7 @@
+
diff --git a/app/views/icons/_redirect.html.erb b/app/views/icons/_redirect.html.erb
new file mode 100644
index 000000000..eb47e0f2b
--- /dev/null
+++ b/app/views/icons/_redirect.html.erb
@@ -0,0 +1,5 @@
+
diff --git a/app/views/icons/_sun.html.erb b/app/views/icons/_sun.html.erb
new file mode 100644
index 000000000..a53942349
--- /dev/null
+++ b/app/views/icons/_sun.html.erb
@@ -0,0 +1,11 @@
+
diff --git a/app/views/layouts/portal.html.erb b/app/views/layouts/portal.html.erb
index 2ae188fc3..611aab22c 100644
--- a/app/views/layouts/portal.html.erb
+++ b/app/views/layouts/portal.html.erb
@@ -28,7 +28,7 @@ By default, it renders:
<% end %>
-
+
<% if !@is_plain_layout_enabled %>
<%= render "public/api/v1/portals/header", portal: @portal %>
@@ -42,7 +42,7 @@ By default, it renders: