feat: Update public portal colors with new design (#8230)

This commit is contained in:
Sivin Varghese 2023-11-23 08:16:52 +05:30 committed by GitHub
parent 4fc5f765de
commit 2d1f70eb79
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
25 changed files with 889 additions and 597 deletions

View File

@ -11,11 +11,7 @@ class Public::Api::V1::Portals::BaseController < PublicController
end end
def set_color_scheme def set_color_scheme
@theme = if %w[dark light].include?(params[:theme]) @theme_from_params = params[:theme] if %w[dark light].include?(params[:theme])
params[:theme]
else
'system'
end
end end
def portal def portal

View File

@ -6,7 +6,17 @@ module PortalHelper
def generate_portal_bg(portal_color, theme) def generate_portal_bg(portal_color, theme)
bg_image = theme == 'dark' ? 'hexagon-dark.svg' : 'hexagon-light.svg' bg_image = theme == 'dark' ? 'hexagon-dark.svg' : 'hexagon-light.svg'
"background: url(/assets/images/hc/#{bg_image}) #{generate_portal_bg_color(portal_color, theme)}" "url(/assets/images/hc/#{bg_image}) #{generate_portal_bg_color(portal_color, theme)}"
end
def generate_gradient_to_bottom(theme)
base_color = theme == 'dark' ? '#151718' : 'white'
"linear-gradient(to bottom, transparent, #{base_color})"
end
def generate_portal_hover_color(portal_color, theme)
base_color = theme == 'dark' ? '#1B1B1B' : '#F9F9F9'
"color-mix(in srgb, #{portal_color} 5%, #{base_color})"
end end
def language_name(locale) def language_name(locale)
@ -14,32 +24,38 @@ module PortalHelper
language_map[locale] || locale language_map[locale] || locale
end end
def get_theme_names(theme) def theme_query_string(theme)
if theme == 'light' theme.present? && theme != 'system' ? "?theme=#{theme}" : ''
I18n.t('public_portal.header.appearance.light') end
elsif theme == 'dark'
I18n.t('public_portal.header.appearance.dark') def generate_home_link(portal_slug, portal_locale, theme, is_plain_layout_enabled)
if is_plain_layout_enabled
"/hc/#{portal_slug}/#{portal_locale}#{theme_query_string(theme)}"
else else
I18n.t('public_portal.header.appearance.system') "/hc/#{portal_slug}/#{portal_locale}"
end end
end end
def get_theme_icon(theme) def generate_category_link(params)
if theme == 'light' portal_slug = params[:portal_slug]
'icons/sun' category_locale = params[:category_locale]
elsif theme == 'dark' category_slug = params[:category_slug]
'icons/moon' theme = params[:theme]
is_plain_layout_enabled = params[:is_plain_layout_enabled]
if is_plain_layout_enabled
"/hc/#{portal_slug}/#{category_locale}/categories/#{category_slug}#{theme_query_string(theme)}"
else else
'icons/monitor' "/hc/#{portal_slug}/#{category_locale}/categories/#{category_slug}"
end end
end end
def generate_gradient_to_bottom(theme) def generate_article_link(portal_slug, article_slug, theme, is_plain_layout_enabled)
"background-image: linear-gradient(to bottom, transparent, #{theme == 'dark' ? '#151718' : 'white'})" if is_plain_layout_enabled
end "/hc/#{portal_slug}/articles/#{article_slug}#{theme_query_string(theme)}"
else
def generate_article_link(portal_slug, article_slug, theme) "/hc/#{portal_slug}/articles/#{article_slug}"
"/hc/#{portal_slug}/articles/#{article_slug}#{theme.present? && theme != 'system' ? "?theme=#{theme}" : ''}" end
end end
def render_category_content(content) def render_category_content(content)

View File

@ -3,6 +3,7 @@ import Vue from 'vue';
import PublicArticleSearch from './components/PublicArticleSearch.vue'; import PublicArticleSearch from './components/PublicArticleSearch.vue';
import TableOfContents from './components/TableOfContents.vue'; import TableOfContents from './components/TableOfContents.vue';
import { initializeTheme } from './portalThemeHelper.js';
export const getHeadingsfromTheArticle = () => { export const getHeadingsfromTheArticle = () => {
const rows = []; const rows = [];
@ -21,53 +22,6 @@ export const getHeadingsfromTheArticle = () => {
return rows; return rows;
}; };
export const generatePortalBgColor = (portalColor, theme) => {
const baseColor = theme === 'dark' ? 'black' : 'white';
return `color-mix(in srgb, ${portalColor} 20%, ${baseColor})`;
};
export const generatePortalBg = (portalColor, theme) => {
const bgImage = theme === 'dark' ? 'hexagon-dark.svg' : 'hexagon-light.svg';
return `background: url(/assets/images/hc/${bgImage}) ${generatePortalBgColor(
portalColor,
theme
)}`;
};
export const generateGradientToBottom = theme => {
return `background-image: linear-gradient(to bottom, transparent, ${
theme === 'dark' ? '#151718' : 'white'
})`;
};
export const setPortalStyles = theme => {
const portalColor = window.portalConfig.portalColor;
const portalBgDiv = document.querySelector('#portal-bg');
const portalBgGradientDiv = document.querySelector('#portal-bg-gradient');
if (portalBgDiv) {
// Set background for #portal-bg
portalBgDiv.setAttribute('style', generatePortalBg(portalColor, theme));
}
if (portalBgGradientDiv) {
// Set gradient background for #portal-bg-gradient
portalBgGradientDiv.setAttribute('style', generateGradientToBottom(theme));
}
};
export const setPortalClass = theme => {
const portalDiv = document.querySelector('#portal');
portalDiv.classList.remove('light', 'dark');
if (!portalDiv) return;
portalDiv.classList.add(theme);
};
export const updateThemeStyles = theme => {
setPortalStyles(theme);
setPortalClass(theme);
};
export const openExternalLinksInNewTab = () => { export const openExternalLinksInNewTab = () => {
const { customDomain, hostURL } = window.portalConfig; const { customDomain, hostURL } = window.portalConfig;
const isSameHost = const isSameHost =
@ -106,37 +60,6 @@ export const openExternalLinksInNewTab = () => {
}); });
}; };
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 = { export const InitializationHelpers = {
navigateToLocalePage: () => { navigateToLocalePage: () => {
const allLocaleSwitcher = document.querySelector('.locale-switcher'); const allLocaleSwitcher = document.querySelector('.locale-switcher');
@ -184,53 +107,17 @@ export const InitializationHelpers = {
}); });
}, },
initializeTheme: () => { initializeThemesInPortal: initializeTheme,
const mediaQueryList = window.matchMedia('(prefers-color-scheme: dark)');
const getThemePreference = () =>
mediaQueryList.matches ? 'dark' : 'light';
const themeFromServer = window.portalConfig.theme;
if (themeFromServer === 'system') {
// Handle dynamic theme changes for system theme
mediaQueryList.addEventListener('change', event => {
const newTheme = event.matches ? 'dark' : 'light';
updateThemeStyles(newTheme);
});
const themePreference = getThemePreference();
updateThemeStyles(themePreference);
}
},
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: () => { initialize: () => {
openExternalLinksInNewTab(); openExternalLinksInNewTab();
if (window.portalConfig.isPlainLayoutEnabled === 'true') { if (window.portalConfig.isPlainLayoutEnabled === 'true') {
InitializationHelpers.appendPlainParamToURLs(); InitializationHelpers.appendPlainParamToURLs();
} else { } else {
InitializationHelpers.initializeThemesInPortal();
InitializationHelpers.navigateToLocalePage(); InitializationHelpers.navigateToLocalePage();
InitializationHelpers.initializeSearch(); InitializationHelpers.initializeSearch();
InitializationHelpers.initializeTableOfContents(); InitializationHelpers.initializeTableOfContents();
InitializationHelpers.initializeTheme();
InitializationHelpers.initializeToggleButton();
InitializationHelpers.initializeThemeSwitchButtons();
} }
}, },

View File

@ -0,0 +1,127 @@
import { adjustColorForContrast } from '../shared/helpers/colorHelper.js';
export const setPortalHoverColor = theme => {
// This function is to set the hover color for the portal
if (theme === 'system') {
const prefersDarkMode = window.matchMedia(
'(prefers-color-scheme: dark)'
).matches;
theme = prefersDarkMode ? 'dark' : 'light';
}
const portalColor = window.portalConfig.portalColor;
const bgColor = theme === 'dark' ? '#151718' : 'white';
const hoverColor = adjustColorForContrast(portalColor, bgColor);
// Set hover color for border and text dynamically
document.documentElement.style.setProperty(
'--dynamic-hover-color',
hoverColor
);
};
export const removeQueryParamsFromUrl = (queryParam = 'theme') => {
// This function is to remove the theme query param from the URL
// This is done so that the theme is not persisted in the URL
// This is called when the theme is switched from the dropdown
const url = new URL(window.location.href);
const param = url.searchParams.get(queryParam);
if (param) {
url.searchParams.delete(queryParam);
window.history.replaceState({}, '', url.toString()); // Convert URL to string
}
};
export const updateThemeInHeader = theme => {
// This function is to update the theme selection in the header in real time
const themeToggleButton = document.getElementById('toggle-appearance');
if (!themeToggleButton) return;
const allElementInButton =
themeToggleButton.querySelectorAll('.theme-button');
if (!allElementInButton) return;
allElementInButton.forEach(button => {
button.classList.toggle('hidden', button.dataset.theme !== theme);
button.classList.toggle('flex', button.dataset.theme === theme);
});
};
export const switchTheme = theme => {
if (theme === 'system') {
localStorage.removeItem('theme');
const prefersDarkMode = window.matchMedia(
'(prefers-color-scheme: dark)'
).matches;
// remove this so that the system theme is used
document.documentElement.classList.remove('dark', 'light');
document.documentElement.classList.add(prefersDarkMode ? 'dark' : 'light');
} else {
localStorage.theme = theme;
document.documentElement.classList.remove('dark', 'light');
document.documentElement.classList.add(theme);
}
setPortalHoverColor(theme);
updateThemeInHeader(theme);
removeQueryParamsFromUrl();
};
export const initializeThemeSwitchButtons = () => {
const appearanceDropdown = document.getElementById('appearance-dropdown');
appearanceDropdown.dataset.currentTheme = localStorage.theme || 'system';
appearanceDropdown.addEventListener('click', event => {
const target = event.target.closest('button[data-theme]');
if (target) {
const { theme } = target.dataset;
// setting this data property will automatically toggle the checkmark using CSS
appearanceDropdown.dataset.currentTheme = theme;
switchTheme(theme);
// wait for a bit before hiding the dropdown
appearanceDropdown.style.display = 'none';
}
});
};
export const initializeToggleButton = () => {
const themeToggleButton = document.getElementById('toggle-appearance');
themeToggleButton?.addEventListener('click', () => {
const appearanceDropdown = document.getElementById('appearance-dropdown');
const isCurrentlyHidden = appearanceDropdown.style.display === 'none';
// Toggle the appearanceDropdown
appearanceDropdown.style.display = isCurrentlyHidden ? 'flex' : 'none';
});
};
export const initializeMediaQueryListener = () => {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
mediaQuery.addEventListener('change', () => {
if (['light', 'dark'].includes(localStorage.theme)) return;
switchTheme('system');
});
};
export const initializeTheme = () => {
if (window.portalConfig.isPlainLayoutEnabled === 'true') return;
// start with updating the theme in the header, this will set the current theme on the button
// and set the hover color at the start of init, this is set again when the theme is switched
setPortalHoverColor(localStorage.theme || 'system');
window.updateThemeInHeader = updateThemeInHeader;
updateThemeInHeader(localStorage.theme || 'system');
// add the event listeners for the dropdown toggle and theme buttons
initializeToggleButton();
initializeThemeSwitchButtons();
// add the media query listener to update the theme when the system theme changes
initializeMediaQueryListener();
};

View File

@ -1,15 +1,4 @@
import { import { InitializationHelpers } from '../portalHelpers';
InitializationHelpers,
generatePortalBgColor,
generatePortalBg,
generateGradientToBottom,
setPortalStyles,
setPortalClass,
updateThemeStyles,
toggleAppearanceDropdown,
updateURLParameter,
removeURLParameter,
} from '../portalHelpers';
describe('#navigateToLocalePage', () => { describe('#navigateToLocalePage', () => {
it('returns correct cookie name', () => { it('returns correct cookie name', () => {
@ -32,303 +21,3 @@ describe('#navigateToLocalePage', () => {
); );
}); });
}); });
describe('Theme Functions', () => {
describe('#generatePortalBgColor', () => {
it('returns mixed color for dark theme', () => {
const result = generatePortalBgColor('#FF5733', 'dark');
expect(result).toBe('color-mix(in srgb, #FF5733 20%, black)');
});
it('returns mixed color for light theme', () => {
const result = generatePortalBgColor('#FF5733', 'light');
expect(result).toBe('color-mix(in srgb, #FF5733 20%, white)');
});
});
describe('#generatePortalBg', () => {
it('returns background for dark theme', () => {
const result = generatePortalBg('#FF5733', 'dark');
expect(result).toBe(
'background: url(/assets/images/hc/hexagon-dark.svg) color-mix(in srgb, #FF5733 20%, black)'
);
});
it('returns background for light theme', () => {
const result = generatePortalBg('#FF5733', 'light');
expect(result).toBe(
'background: url(/assets/images/hc/hexagon-light.svg) color-mix(in srgb, #FF5733 20%, white)'
);
});
});
describe('#generateGradientToBottom', () => {
it('returns gradient for dark theme', () => {
const result = generateGradientToBottom('dark');
expect(result).toBe(
'background-image: linear-gradient(to bottom, transparent, #151718)'
);
});
it('returns gradient for light theme', () => {
const result = generateGradientToBottom('light');
expect(result).toBe(
'background-image: linear-gradient(to bottom, transparent, white)'
);
});
});
describe('#setPortalStyles', () => {
let mockPortalBgDiv;
let mockPortalBgGradientDiv;
beforeEach(() => {
// Mocking portal background div
mockPortalBgDiv = document.createElement('div');
mockPortalBgDiv.id = 'portal-bg';
document.body.appendChild(mockPortalBgDiv);
// Mocking portal background gradient div
mockPortalBgGradientDiv = document.createElement('div');
mockPortalBgGradientDiv.id = 'portal-bg-gradient';
document.body.appendChild(mockPortalBgGradientDiv);
});
afterEach(() => {
document.body.innerHTML = '';
});
it('sets styles for portal background based on theme', () => {
window.portalConfig = { portalColor: '#FF5733' };
setPortalStyles('dark');
const expectedPortalBgStyle =
'background: url(/assets/images/hc/hexagon-dark.svg) color-mix(in srgb, #FF5733 20%, black)';
const expectedGradientStyle =
'background-image: linear-gradient(to bottom, transparent, #151718)';
expect(mockPortalBgDiv.getAttribute('style')).toBe(expectedPortalBgStyle);
expect(mockPortalBgGradientDiv.getAttribute('style')).toBe(
expectedGradientStyle
);
});
});
describe('#setPortalClass', () => {
let mockPortalDiv;
beforeEach(() => {
// Mocking portal div
mockPortalDiv = document.createElement('div');
mockPortalDiv.id = 'portal';
mockPortalDiv.classList.add('light');
document.body.appendChild(mockPortalDiv);
});
afterEach(() => {
document.body.innerHTML = '';
});
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 = `<div id="appearance-dropdown" style="display: none;"></div>`;
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 = `<div id="appearance-dropdown" style="display: flex;"></div>`;
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', () => {
let mockPortalDiv;
let mockPortalBgDiv;
let mockPortalBgGradientDiv;
beforeEach(() => {
// Mocking portal div
mockPortalDiv = document.createElement('div');
mockPortalDiv.id = 'portal';
document.body.appendChild(mockPortalDiv);
// Mocking portal background div
mockPortalBgDiv = document.createElement('div');
mockPortalBgDiv.id = 'portal-bg';
document.body.appendChild(mockPortalBgDiv);
// Mocking portal background gradient div
mockPortalBgGradientDiv = document.createElement('div');
mockPortalBgGradientDiv.id = 'portal-bg-gradient';
document.body.appendChild(mockPortalBgGradientDiv);
});
afterEach(() => {
document.body.innerHTML = '';
});
it('updates theme styles based on theme', () => {
window.portalConfig = { portalColor: '#FF5733' };
updateThemeStyles('dark');
const expectedPortalBgStyle =
'background: url(/assets/images/hc/hexagon-dark.svg) color-mix(in srgb, #FF5733 20%, black)';
const expectedGradientStyle =
'background-image: linear-gradient(to bottom, transparent, #151718)';
expect(mockPortalDiv.classList.contains('dark')).toBe(true);
expect(mockPortalBgDiv.getAttribute('style')).toBe(expectedPortalBgStyle);
expect(mockPortalBgGradientDiv.getAttribute('style')).toBe(
expectedGradientStyle
);
});
});
describe('#initializeTheme', () => {
let mockPortalDiv;
beforeEach(() => {
mockPortalDiv = document.createElement('div');
mockPortalDiv.id = 'portal';
document.body.appendChild(mockPortalDiv);
});
afterEach(() => {
document.body.innerHTML = '';
});
it('updates theme based on system preferences', () => {
const mediaQueryList = {
matches: true,
addEventListener: jest.fn(),
};
window.matchMedia = jest.fn().mockReturnValue(mediaQueryList);
window.portalConfig = { theme: 'system' };
InitializationHelpers.initializeTheme();
expect(mediaQueryList.addEventListener).toBeCalledWith(
'change',
expect.any(Function)
);
expect(mockPortalDiv.classList.contains('dark')).toBe(true);
});
it('does not update theme if themeFromServer is not "system"', () => {
const mediaQueryList = {
matches: true,
addEventListener: jest.fn(),
};
window.matchMedia = jest.fn().mockReturnValue(mediaQueryList);
window.portalConfig = { theme: 'dark' };
InitializationHelpers.initializeTheme();
expect(mediaQueryList.addEventListener).not.toBeCalled();
expect(mockPortalDiv.classList.contains('dark')).toBe(false);
expect(mockPortalDiv.classList.contains('light')).toBe(false);
});
});
describe('initializeToggleButton', () => {
it('adds a click listener to the toggle button', () => {
document.body.innerHTML = `<button id="toggle-appearance"></button>`;
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 = `<div id="appearance-dropdown"><button data-theme="dark"></button><button data-theme="light"></button></div>`;
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();
});
});
});

View File

@ -0,0 +1,279 @@
import {
setPortalHoverColor,
removeQueryParamsFromUrl,
updateThemeInHeader,
switchTheme,
initializeThemeSwitchButtons,
initializeToggleButton,
initializeMediaQueryListener,
initializeTheme,
} from '../portalThemeHelper.js';
import { adjustColorForContrast } from '../../shared/helpers/colorHelper.js';
describe('portalThemeHelper', () => {
let themeToggleButton;
let appearanceDropdown;
beforeEach(() => {
themeToggleButton = document.createElement('div');
themeToggleButton.id = 'toggle-appearance';
document.body.appendChild(themeToggleButton);
appearanceDropdown = document.createElement('div');
appearanceDropdown.id = 'appearance-dropdown';
document.body.appendChild(appearanceDropdown);
window.matchMedia = jest.fn().mockImplementation(query => ({
matches: query === '(prefers-color-scheme: dark)',
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
}));
window.portalConfig = { portalColor: '#ff5733' };
document.documentElement.style.setProperty = jest.fn();
document.documentElement.classList.remove('dark', 'light');
jest.clearAllMocks();
});
afterEach(() => {
themeToggleButton.remove();
appearanceDropdown.remove();
delete window.portalConfig;
document.documentElement.style.setProperty.mockRestore();
document.documentElement.classList.remove('dark', 'light');
localStorage.clear();
});
describe('#setPortalHoverColor', () => {
it('should apply dark hover color in dark theme', () => {
const hoverColor = adjustColorForContrast('#ff5733', '#151718');
setPortalHoverColor('dark');
expect(document.documentElement.style.setProperty).toHaveBeenCalledWith(
'--dynamic-hover-color',
hoverColor
);
});
it('should apply light hover color in light theme', () => {
const hoverColor = adjustColorForContrast('#ff5733', '#ffffff');
setPortalHoverColor('light');
expect(document.documentElement.style.setProperty).toHaveBeenCalledWith(
'--dynamic-hover-color',
hoverColor
);
});
});
describe('#removeQueryParamsFromUrl', () => {
let originalLocation;
beforeEach(() => {
originalLocation = window.location;
delete window.location;
window.location = new URL('http://localhost:3000/');
window.history.replaceState = jest.fn();
});
afterEach(() => {
window.location = originalLocation;
});
it('should not remove query params if theme is not in the URL', () => {
removeQueryParamsFromUrl();
expect(window.history.replaceState).not.toHaveBeenCalled();
});
it('should remove theme query param from the URL', () => {
window.location = new URL(
'http://localhost:3000/?theme=light&show_plain_layout=true'
);
removeQueryParamsFromUrl('theme');
expect(window.history.replaceState).toHaveBeenCalledWith(
{},
'',
'http://localhost:3000/?show_plain_layout=true'
);
});
});
describe('#updateThemeInHeader', () => {
beforeEach(() => {
themeToggleButton.innerHTML = `
<div class="theme-button" data-theme="light"></div>
<div class="theme-button" data-theme="dark"></div>
<div class="theme-button" data-theme="system"></div>
`;
});
it('should not update header if theme toggle button is not found', () => {
themeToggleButton.remove();
updateThemeInHeader('light');
expect(document.querySelector('.theme-button')).toBeNull();
});
it('should show the theme button for the selected theme', () => {
updateThemeInHeader('light');
const lightButton = themeToggleButton.querySelector(
'.theme-button[data-theme="light"]'
);
expect(lightButton.classList).toContain('flex');
});
});
describe('#switchTheme', () => {
it('should set theme to system theme and update classes', () => {
window.matchMedia = jest.fn().mockReturnValue({ matches: true });
switchTheme('system');
expect(localStorage.theme).toBeUndefined();
expect(document.documentElement.classList).toContain('dark');
});
it('should set theme to light theme and update classes', () => {
switchTheme('light');
expect(localStorage.theme).toBe('light');
expect(document.documentElement.classList).toContain('light');
});
it('should set theme to dark theme and update classes', () => {
switchTheme('dark');
expect(localStorage.theme).toBe('dark');
expect(document.documentElement.classList).toContain('dark');
});
});
describe('#initializeThemeSwitchButtons', () => {
beforeEach(() => {
appearanceDropdown.innerHTML = `
<button data-theme="light"><span class="check-mark-icon light-theme"></span></button>
<button data-theme="dark"><span class="check-mark-icon dark-theme"></span></button>
<button data-theme="system"><span class="check-mark-icon system-theme"></span></button>
`;
});
it('does nothing if the appearance dropdown is not found', () => {
appearanceDropdown.remove();
expect(appearanceDropdown.dataset.currentTheme).toBeUndefined();
});
it('should set current theme to system if no theme in localStorage', () => {
localStorage.removeItem('theme');
initializeThemeSwitchButtons();
expect(appearanceDropdown.dataset.currentTheme).toBe('system');
});
it('sets the current theme to the light theme', () => {
localStorage.theme = 'light';
appearanceDropdown.dataset.currentTheme = 'light';
initializeThemeSwitchButtons();
expect(appearanceDropdown.dataset.currentTheme).toBe('light');
});
it('sets the current theme to the dark theme', () => {
localStorage.theme = 'dark';
appearanceDropdown.dataset.currentTheme = 'dark';
initializeThemeSwitchButtons();
expect(appearanceDropdown.dataset.currentTheme).toBe('dark');
});
});
describe('#initializeToggleButton', () => {
it('does nothing if the theme toggle button is not found', () => {
themeToggleButton.remove();
initializeToggleButton();
expect(appearanceDropdown.style.display).toBe('');
});
it('toggles the appearance dropdown show/hide', () => {
themeToggleButton.click();
appearanceDropdown.style.display = 'flex';
expect(appearanceDropdown.style.display).toBe('flex');
themeToggleButton.click();
appearanceDropdown.style.display = 'none';
expect(appearanceDropdown.style.display).toBe('none');
});
});
describe('#initializeMediaQueryListener', () => {
let mediaQuery;
beforeEach(() => {
mediaQuery = {
addEventListener: jest.fn(),
matches: false,
};
window.matchMedia = jest.fn().mockReturnValue(mediaQuery);
});
it('adds a listener to the media query', () => {
initializeMediaQueryListener();
expect(window.matchMedia).toHaveBeenCalledWith(
'(prefers-color-scheme: dark)'
);
expect(mediaQuery.addEventListener).toHaveBeenCalledWith(
'change',
expect.any(Function)
);
});
it('does not switch theme if local storage theme is light or dark', () => {
localStorage.theme = 'light';
initializeMediaQueryListener();
mediaQuery.matches = true;
mediaQuery.addEventListener.mock.calls[0][1]();
expect(localStorage.theme).toBe('light');
});
it('switches to dark theme if system preference changes to dark and no theme is set in local storage', () => {
localStorage.removeItem('theme');
initializeMediaQueryListener();
mediaQuery.matches = true;
mediaQuery.addEventListener.mock.calls[0][1]();
expect(document.documentElement.classList).toContain('dark');
});
it('switches to light theme if system preference changes to light and no theme is set in local storage', () => {
localStorage.removeItem('theme');
initializeMediaQueryListener();
mediaQuery.matches = false;
mediaQuery.addEventListener.mock.calls[0][1]();
expect(document.documentElement.classList).toContain('light');
});
});
describe('#initializeTheme', () => {
it('should not initialize theme if plain layout is enabled', () => {
window.portalConfig.isPlainLayoutEnabled = 'true';
initializeTheme();
expect(localStorage.theme).toBeUndefined();
expect(document.documentElement.classList).not.toContain('light');
expect(document.documentElement.classList).not.toContain('dark');
});
it('sets the theme to the system theme', () => {
initializeTheme();
expect(localStorage.theme).toBeUndefined();
const prefersDarkMode = window.matchMedia(
'(prefers-color-scheme: dark)'
).matches;
expect(document.documentElement.classList.contains('light')).toBe(
!prefersDarkMode
);
});
it('sets the theme to the light theme', () => {
localStorage.theme = 'light';
document.documentElement.classList.add('light');
initializeTheme();
expect(localStorage.theme).toBe('light');
expect(document.documentElement.classList.contains('light')).toBe(true);
});
it('sets the theme to the dark theme', () => {
localStorage.theme = 'dark';
document.documentElement.classList.add('dark');
initializeTheme();
expect(localStorage.theme).toBe('dark');
expect(document.documentElement.classList.contains('dark')).toBe(true);
});
});
});

View File

@ -1,3 +1,5 @@
import { toHex, mix, getLuminance, getContrast } from 'color2k';
export const isWidgetColorLighter = color => { export const isWidgetColorLighter = color => {
const colorToCheck = color.replace('#', ''); const colorToCheck = color.replace('#', '');
const c_r = parseInt(colorToCheck.substr(0, 2), 16); const c_r = parseInt(colorToCheck.substr(0, 2), 16);
@ -6,3 +8,21 @@ export const isWidgetColorLighter = color => {
const brightness = (c_r * 299 + c_g * 587 + c_b * 114) / 1000; const brightness = (c_r * 299 + c_g * 587 + c_b * 114) / 1000;
return brightness > 225; return brightness > 225;
}; };
export const adjustColorForContrast = (color, backgroundColor) => {
const targetRatio = 3.1;
const MAX_ITERATIONS = 20;
let adjustedColor = color;
for (let iteration = 0; iteration < MAX_ITERATIONS; iteration += 1) {
const currentRatio = getContrast(adjustedColor, backgroundColor);
if (currentRatio >= targetRatio) {
break;
}
const adjustmentDirection =
getLuminance(adjustedColor) < 0.5 ? '#fff' : '#151718';
adjustedColor = mix(adjustedColor, adjustmentDirection, 0.05);
}
return toHex(adjustedColor);
};

View File

@ -1,4 +1,8 @@
import { isWidgetColorLighter } from 'shared/helpers/colorHelper'; import { toHex, getContrast } from 'color2k';
import {
isWidgetColorLighter,
adjustColorForContrast,
} from 'shared/helpers/colorHelper';
describe('#isWidgetColorLighter', () => { describe('#isWidgetColorLighter', () => {
it('returns true if color is lighter', () => { it('returns true if color is lighter', () => {
@ -8,3 +12,56 @@ describe('#isWidgetColorLighter', () => {
expect(isWidgetColorLighter('#000000')).toEqual(false); expect(isWidgetColorLighter('#000000')).toEqual(false);
}); });
}); });
describe('#adjustColorForContrast', () => {
const targetRatio = 3.1;
const getContrastRatio = (color1, color2) => {
// getContrast from 'color2k'
return getContrast(color1, color2);
};
it('adjusts a color to meet the contrast ratio against a light background', () => {
const color = '#ff0000';
const backgroundColor = '#ffffff';
const adjustedColor = adjustColorForContrast(color, backgroundColor);
const ratio = getContrastRatio(adjustedColor, backgroundColor);
expect(ratio).toBeGreaterThanOrEqual(targetRatio);
});
it('adjusts a color to meet the contrast ratio against a dark background', () => {
const color = '#00ff00';
const backgroundColor = '#000000';
const adjustedColor = adjustColorForContrast(color, backgroundColor);
const ratio = getContrastRatio(adjustedColor, backgroundColor);
expect(ratio).toBeGreaterThanOrEqual(targetRatio);
});
it('returns a string representation of the color', () => {
const color = '#00ff00';
const backgroundColor = '#000000';
const adjustedColor = adjustColorForContrast(color, backgroundColor);
expect(typeof adjustedColor).toEqual('string');
});
it('handles cases where the color already meets the contrast ratio', () => {
const color = '#000000';
const backgroundColor = '#ffffff';
const adjustedColor = adjustColorForContrast(color, backgroundColor);
const ratio = getContrastRatio(adjustedColor, backgroundColor);
expect(ratio).toBeGreaterThanOrEqual(targetRatio);
expect(adjustedColor).toEqual(toHex(color));
});
it('does not modify a color that already exceeds the contrast ratio', () => {
const color = '#000000';
const backgroundColor = '#ffffff';
const adjustedColor = adjustColorForContrast(color, backgroundColor);
expect(adjustedColor).toEqual(toHex(color));
});
});

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 160 KiB

After

Width:  |  Height:  |  Size: 846 B

View File

@ -10,6 +10,7 @@ By default, it renders:
(if provided by a `content_for` block in a nested page) (if provided by a `content_for` block in a nested page)
- Flashes - Flashes
- Links to stylesheets and JavaScripts - Links to stylesheets and JavaScripts
- The appearance dropdown styles are added to the top to prevent FOUC
%> %>
<!DOCTYPE html> <!DOCTYPE html>
@ -21,16 +22,43 @@ By default, it renders:
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<%= javascript_pack_tag 'portal' %> <%= javascript_pack_tag 'portal' %>
<%= stylesheet_pack_tag 'portal' %> <%= stylesheet_pack_tag 'portal' %>
<style>
#appearance-dropdown[data-current-theme="system"] .check-mark-icon.light-theme,
#appearance-dropdown[data-current-theme="system"] .check-mark-icon.dark-theme,
#appearance-dropdown[data-current-theme="dark"] .check-mark-icon.light-theme,
#appearance-dropdown[data-current-theme="dark"] .check-mark-icon.system-theme,
#appearance-dropdown[data-current-theme="light"] .check-mark-icon.dark-theme,
#appearance-dropdown[data-current-theme="light"] .check-mark-icon.system-theme {
display: none;
}
</style>
<%= csrf_meta_tags %> <%= csrf_meta_tags %>
<% if content_for?(:head) %> <% if content_for?(:head) %>
<%= yield(:head) %> <%= yield(:head) %>
<% else %> <% else %>
<title><%= @portal.page_title%></title> <title><%= @portal.page_title%></title>
<% end %> <% end %>
<% unless @theme_from_params.blank? %>
<%# this adds the theme from params, ensuring that there a localstorage value set %>
<%# this will further trigger the next script to ensure color mode is toggled without a FOUC %>
<script>localStorage.theme = '<%= @theme_from_params %>';</script>
<% end %>
<script>
if (localStorage.theme === 'dark' || (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
// we can use document.body here but that would mean pushing this script inside the body
// since the body is not created yet. This is done to avoid FOUC, at a tiny cost of Time to Interactive
document.documentElement.classList.add('dark')
} else {
document.documentElement.classList.remove('dark')
document.documentElement.classList.add('light')
}
</script>
</head> </head>
<body> <body>
<div id="portal" class="antialiased <%= @theme unless @theme == 'system' %>"> <div id="portal" class="antialiased">
<main class="main-content min-h-screen flex flex-col bg-white dark:bg-slate-900" role="main"> <main class="flex flex-col min-h-screen bg-white main-content dark:bg-slate-900" role="main">
<% if !@is_plain_layout_enabled %> <% if !@is_plain_layout_enabled %>
<%= render "public/api/v1/portals/header", portal: @portal %> <%= render "public/api/v1/portals/header", portal: @portal %>
<% end %> <% end %>
@ -42,17 +70,53 @@ By default, it renders:
</div> </div>
</body> </body>
<style> <style>
:root { html.dark {
color-scheme: <% if @theme == 'system' %>light dark<% else %><%= @theme %><% end %>; --dynamic-portal-bg: <%= generate_portal_bg(@portal.color, 'dark') %>;
--dynamic-portal-bg-gradient: <%= generate_gradient_to_bottom('dark') %>;
--dynamic-hover-bg-color: <%= generate_portal_hover_color(@portal.color , 'dark') %>;
}
html.light {
--dynamic-portal-bg: <%= generate_portal_bg(@portal.color, 'light') %>;
--dynamic-portal-bg-gradient: <%= generate_gradient_to_bottom('light') %>;
--dynamic-hover-bg-color: <%= generate_portal_hover_color(@portal.color , 'light') %>;
}
/* Portal background */
#portal-bg {
background: var(--dynamic-portal-bg);
}
/* Portal background gradient */
#portal-bg-gradient {
background-image: var(--dynamic-portal-bg-gradient);
}
/* Category block item hover color */
#category-item:hover {
background-color: var(--dynamic-hover-bg-color);
}
/* Header section */
#header-action-button:hover,
#toggle-appearance:hover,
#toggle-theme-button:hover {
color: var(--dynamic-hover-color);
stroke: var(--dynamic-hover-color);
}
#category-block:hover {
border-color: var(--dynamic-hover-color);
}
#category-block:hover #category-name {
color: var(--dynamic-hover-color);
} }
</style> </style>
<script> <script>
window.portalConfig = { window.portalConfig = {
portalSlug: '<%= @portal.slug %>', portalSlug: '<%= @portal.slug %>',
portalColor: '<%= @portal.color %>', portalColor: '<%= @portal.color %>',
theme: '<%= @theme_from_params %>',
customDomain: '<%= @portal.custom_domain %>', customDomain: '<%= @portal.custom_domain %>',
hostURL: '<%= ENV.fetch('FRONTEND_URL', '') %>', hostURL: '<%= ENV.fetch('FRONTEND_URL', '') %>',
theme: '<%= @theme %>',
localeCode: '<%= @locale %>', localeCode: '<%= @locale %>',
searchTranslations: { searchTranslations: {
searchPlaceholder: '<%= I18n.t('public_portal.search.search_placeholder') %>', searchPlaceholder: '<%= I18n.t('public_portal.search.search_placeholder') %>',

View File

@ -1,13 +1,22 @@
<section class="lg:container w-full flex flex-col h-full"> <% category_link_params = {
<div class="flex flex-col gap-8 h-full group <%= !@is_plain_layout_enabled ? 'border border-solid border-slate-100 dark:border-slate-800 hover:border-woot-600 dark:hover:border-woot-600 py-5 px-3 rounded-lg' : '' %>"> portal_slug: portal.slug,
<div class="flex justify-between items-center w-full"> category_locale: category.locale,
<div class="flex items-start flex-col gap-1"> category_slug: category.slug,
theme: @theme_from_params,
is_plain_layout_enabled: @is_plain_layout_enabled
}
%>
<section class="flex flex-col w-full h-full lg:container">
<div id="<%= !@is_plain_layout_enabled ? 'category-block' : '' %>" class="flex flex-col gap-8 h-full <%= !@is_plain_layout_enabled ? 'border border-solid border-slate-100 dark:border-slate-800 py-5 px-3 rounded-lg' : '' %>">
<div class="flex items-center justify-between w-full">
<div class="flex flex-col items-start gap-1">
<div class="flex flex-row items-center gap-2 <%= !@is_plain_layout_enabled && 'px-1' %>"> <div class="flex flex-row items-center gap-2 <%= !@is_plain_layout_enabled && 'px-1' %>">
<% if category.icon.present? %> <% if category.icon.present? %>
<span class="text-lg rounded-md cursor-pointer <%= !@is_plain_layout_enabled && 'pl-1' %>"><%= category.icon %></span> <span class="text-lg rounded-md cursor-pointer <%= !@is_plain_layout_enabled && 'pl-1' %>"><%= category.icon %></span>
<% end %> <% end %>
<h3 class="text-xl text-slate-800 dark:text-slate-50 font-semibold leading-relaxed hover:cursor-pointer <%= @is_plain_layout_enabled ? 'hover:underline' : 'group-hover:text-woot-600 dark:group-hover:text-woot-600' %> <%= category.icon.blank? && !@is_plain_layout_enabled ? 'pl-1' : '' %>"> <h3 id="<%= !@is_plain_layout_enabled ? 'category-name' : '' %>" class="text-xl text-slate-800 dark:text-slate-50 font-semibold leading-relaxed hover:cursor-pointer <%= @is_plain_layout_enabled ? 'hover:underline' : '' %> <%= category.icon.blank? && !@is_plain_layout_enabled ? 'pl-1' : '' %>">
<a href="/hc/<%= portal.slug %>/<%= category.locale %>/categories/<%= category.slug %><%= @theme.present? && @theme != 'system' ? '?theme='+@theme : '' %>"> <a href="<%= generate_category_link(category_link_params) %>">
<%= category.name %> <%= category.name %>
</a> </a>
</h3> </h3>
@ -19,15 +28,15 @@
</div> </div>
<div class="flex flex-col gap-2 flex-grow <%= category.description.blank? && '-mt-4' %>"> <div class="flex flex-col gap-2 flex-grow <%= category.description.blank? && '-mt-4' %>">
<% if category.articles.published.size==0 %> <% if category.articles.published.size==0 %>
<div class="h-full flex items-center justify-center bg-slate-50 dark:bg-slate-800 rounded-xl mb-4"> <div class="flex items-center justify-center h-full mb-4 bg-slate-50 dark:bg-slate-800 rounded-xl">
<p class="text-sm text-slate-500"><%= I18n.t('public_portal.common.no_articles') %></p> <p class="text-sm text-slate-500"><%= I18n.t('public_portal.common.no_articles') %></p>
</div> </div>
<% else %> <% else %>
<% category.articles.published.order(position: :asc).take(5).each do |article| %> <% category.articles.published.order(position: :asc).take(5).each do |article| %>
<a class="text-slate-700 dark:text-slate-100 leading-7" href="<%= generate_article_link(portal.slug, article.slug, @theme) %>"> <a class="leading-7 text-slate-700 dark:text-slate-100" href="<%= generate_article_link(portal.slug, article.slug, @theme_from_params, @is_plain_layout_enabled) %>">
<div class="flex justify-between hover:cursor-pointer items-center py-1 rounded-lg gap-3 <%= !@is_plain_layout_enabled ? 'px-2 hover:bg-slate-50 dark:hover:bg-slate-800' : 'hover:underline' %>"> <div id="<%= !@is_plain_layout_enabled ? 'category-item' : '' %>" class="flex justify-between hover:cursor-pointer items-start py-1 rounded-lg gap-3 <%= !@is_plain_layout_enabled ? 'px-2' : 'hover:underline' %>">
<%= article.title %> <%= article.title %>
<span class="flex items-center font-normal"> <span class="flex items-center font-normal mt-1.5">
<%= render partial: 'icons/chevron-right' %> <%= render partial: 'icons/chevron-right' %>
</span> </span>
</div> </div>
@ -40,7 +49,7 @@
<span class="text-sm font-medium text-slate-600 dark:text-slate-400"><%= render 'public/api/v1/portals/article_count', article_count: category.articles.published.order(position: :asc).size %></span> <span class="text-sm font-medium text-slate-600 dark:text-slate-400"><%= render 'public/api/v1/portals/article_count', article_count: category.articles.published.order(position: :asc).size %></span>
</div> </div>
<div> <div>
<a href="/hc/<%= portal.slug %>/<%= category.locale %>/categories/<%= category.slug %><%= @theme.present? ? '?theme='+@theme : '' %>" class="flex flex-row items-center text-sm font-medium text-slate-600 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-100"> <a href="<%= generate_category_link(category_link_params) %>" class="flex flex-row items-center text-sm font-medium text-slate-600 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-100">
<%= I18n.t('public_portal.common.view_all_articles') %> <%= I18n.t('public_portal.common.view_all_articles') %>
</a> </a>
</div> </div>

View File

@ -1,20 +1,20 @@
<% featured_articles = articles.where(category_id: categories).search_by_status(:published).order_by_views.limit(6) %> <% featured_articles = articles.where(category_id: categories).search_by_status(:published).order_by_views.limit(6) %>
<% if featured_articles.count >= 6 %> <% if featured_articles.count >= 6 %>
<section class="lg:container w-full flex flex-col h-full"> <section class="flex flex-col w-full h-full lg:container">
<div class="flex flex-col gap-5 border border-solid border-slate-100 dark:border-slate-800 rounded-lg py-5 px-3"> <div class="flex flex-col gap-5 px-3 py-5 border border-solid rounded-lg border-slate-100 dark:border-slate-800">
<div class="flex justify-between items-center w-full"> <div class="flex items-center justify-between w-full">
<div class="flex items-start flex-col gap-1"> <div class="flex flex-col items-start gap-1">
<div class="flex flex-row items-center gap-2 px-2"> <div class="flex flex-row items-center gap-2 px-2">
<h3 class="text-xl text-slate-800 dark:text-slate-50 font-semibold leading-relaxed"> <h3 class="text-xl font-semibold leading-relaxed text-slate-800 dark:text-slate-50">
<%= I18n.t('public_portal.header.featured_articles') %> <%= I18n.t('public_portal.header.featured_articles') %>
</h3> </h3>
</div> </div>
</div> </div>
</div> </div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-2 gap-y-2 gap-2"> <div class="grid grid-cols-1 gap-2 md:grid-cols-2 gap-x-2 gap-y-2">
<% featured_articles.each do |article| %> <% featured_articles.each do |article| %>
<a class="text-slate-700 dark:text-slate-100 leading-7" href="<%= generate_article_link(portal.slug, article.slug, @theme) %>"> <a class="leading-7 text-slate-700 dark:text-slate-100" href="<%= generate_article_link(portal.slug, article.slug, @theme_from_params, @is_plain_layout_enabled) %>">
<div class="flex justify-between items-start py-1 px-2 rounded-lg gap-3 hover:bg-slate-50 dark:hover:bg-slate-800"> <div id="category-item" class="flex items-start justify-between gap-3 px-2 py-1 rounded-lg">
<%= article.title %> <%= article.title %>
<span class="flex items-center font-normal mt-1.5"> <span class="flex items-center font-normal mt-1.5">
<%= render partial: 'icons/chevron-right' %> <%= render partial: 'icons/chevron-right' %>

View File

@ -1,9 +1,9 @@
<header class="bg-white dark:bg-slate-900 w-full shadow-sm sticky top-0 z-50"> <header class="sticky top-0 z-50 w-full bg-white shadow-sm dark:bg-slate-900">
<nav class=" flex mx-auto max-w-5xl px-4 md:px-8" aria-label="Top"> <nav class="flex max-w-5xl px-4 mx-auto md:px-8" aria-label="Top">
<div class="w-full py-5 flex items-center overflow-hidden"> <div class="flex items-center w-full py-5 overflow-hidden">
<a href="/hc/<%= @portal.slug %>/<%= @portal.config['default_locale'] || params[:locale] %>/<%= @theme.present? && @theme != 'system' ? '?theme='+@theme : '' %>" class="h-10 text-lg flex items-center text-slate-900 dark:text-white font-semibold"> <a href="<%= generate_home_link(@portal.slug, @portal.config['default_locale'] || params[:locale], @theme_from_params, @is_plain_layout_enabled) %>" class="flex items-center h-10 text-lg font-semibold text-slate-900 dark:text-white">
<% if @portal.logo.present? %> <% if @portal.logo.present? %>
<img src="<%= url_for(@portal.logo) %>" class="h-10 w-auto mr-2" /> <img src="<%= url_for(@portal.logo) %>" class="w-auto h-10 mr-2" />
<% end %> <% end %>
<%= @portal.name %> <%= @portal.name %>
</a> </a>
@ -12,9 +12,9 @@
<%# Go to homepage link section %> <%# Go to homepage link section %>
<div class="flex items-center justify-between gap-2 sm:gap-5"> <div class="flex items-center justify-between gap-2 sm:gap-5">
<% if @portal.homepage_link %> <% if @portal.homepage_link %>
<div class="ml-8 border-l-1 border-slate-50 dark:border-slate-800 hidden md:block cursor-pointer px-1 py-2"> <div class="hidden px-1 py-2 ml-8 cursor-pointer border-l-1 border-slate-50 dark:border-slate-800 md:block">
<div class="flex-grow flex-shrink-0"> <div class="flex-grow flex-shrink-0">
<a target="_blank" rel="noopener noreferrer nofollow" href="<%= @portal.homepage_link %>" class="flex flex-row items-center gap-1 text-sm font-medium whitespace-nowrap text-slate-800 dark:text-slate-100 hover:text-woot-500 dark:hover:text-woot-500 stroke-slate-700 dark:stroke-slate-200 hover:stroke-woot-500 dark:hover:stroke-woot-500"> <a id="header-action-button" target="_blank" rel="noopener noreferrer nofollow" href="<%= @portal.homepage_link %>" class="flex flex-row items-center gap-1 text-sm font-medium whitespace-nowrap text-slate-800 dark:text-slate-100 stroke-slate-700 dark:stroke-slate-200">
<%= render partial: 'icons/redirect' %> <%= render partial: 'icons/redirect' %>
<%= I18n.t('public_portal.header.go_to_homepage') %> <%= I18n.t('public_portal.header.go_to_homepage') %>
</a> </a>
@ -23,54 +23,64 @@
<% end %> <% end %>
<%# Appearance toggle section %> <%# Appearance toggle section %>
<div class="flex-grow flex-shrink-0 relative cursor-pointer px-1 py-2"> <div class="relative flex-grow flex-shrink-0 px-1 py-2 cursor-pointer">
<button id="toggle-appearance" class="toggle-appearance flex flex-row items-center stroke-slate-700 dark:stroke-slate-200 hover:stroke-woot-500 dark:hover:stroke-woot-500 text-slate-800 dark:text-slate-100 hover:text-woot-500 dark:hover:text-woot-500 gap-1" type="button"> <button id="toggle-appearance" class="flex justify-between min-w-[76px] flex-row items-center stroke-slate-700 dark:stroke-slate-200 text-slate-800 dark:text-slate-100 gap-1" type="button">
<%= render partial: get_theme_icon(@theme) %> <div data-theme="system" class="flex-row items-center gap-1 theme-button <%= @theme_from_params == 'system' ? 'flex' : 'hidden' %>">
<span class="text-sm font-medium"><%= get_theme_names(@theme) %></span> <%= render partial: 'icons/monitor' %>
<div class="pointer-events-none flex items-center px-1"> <span class="text-sm font-medium"><%= I18n.t('public_portal.header.appearance.system') %></span>
</div>
<div data-theme="light" class="flex-row items-center gap-1 theme-button <%= @theme_from_params == 'light' ? 'flex' : 'hidden' %>">
<%= render partial: 'icons/sun' %>
<span class="text-sm font-medium"><%= I18n.t('public_portal.header.appearance.light') %></span>
</div>
<div data-theme="dark" class="flex-row items-center gap-1 theme-button <%= @theme_from_params == 'dark' ? 'flex' : 'hidden' %>">
<%= render partial: 'icons/moon' %>
<span class="text-sm font-medium"><%= I18n.t('public_portal.header.appearance.dark') %></span>
</div>
<div class="flex items-center px-1 pointer-events-none">
<%= render partial: 'icons/chevron-down' %> <%= render partial: 'icons/chevron-down' %>
</div> </div>
</button> </button>
<%# Appearance dropdown section %> <%# Appearance dropdown section %>
<div id="appearance-dropdown" class="hidden absolute flex-col h-auto w-32 bg-white dark:bg-slate-900 border border-solid rounded top-9 border-slate-100 dark:border-slate-800" aria-hidden="true" data-dropdown="appearance-dropdown"> <div id="appearance-dropdown" data-current-theme="<%= @theme_from_params %>" class="absolute flex-col w-32 h-auto bg-white border border-solid rounded dark:bg-slate-900 top-9 right-1 border-slate-100 dark:border-slate-800" aria-hidden="true" style="display: none;" data-dropdown="appearance-dropdown">
<button data-theme="system" class="flex flex-row items-center justify-between px-2 py-2 border-b border-solid border-slate-100 dark:border-slate-800 gap-1 stroke-slate-700 dark:stroke-slate-200 hover:stroke-woot-500 dark:hover:stroke-woot-500 text-slate-800 dark:text-slate-100 hover:text-woot-500 dark:hover:text-woot-500"> <button id="toggle-theme-button" data-theme="system" class="flex flex-row items-center justify-between gap-1 px-2 py-2 border-b border-solid border-slate-100 dark:border-slate-800 stroke-slate-700 dark:stroke-slate-200 text-slate-800 dark:text-slate-100">
<div class="flex flex-row items-center gap-1"> <div class="flex flex-row items-center gap-1">
<%= render partial: 'icons/monitor' %> <%= render partial: 'icons/monitor' %>
<span class="text-xs font-medium"><%= I18n.t('public_portal.header.appearance.system') %></span> <span class="text-xs font-medium"><%= I18n.t('public_portal.header.appearance.system') %></span>
</div> </div>
<% if @theme.present? && @theme == 'system' %> <span class="check-mark-icon system-theme">
<%= render partial: 'icons/check-mark' %> <%= render partial: 'icons/check-mark' %>
<% end %> </span>
</button> </button>
<button data-theme="light" class="flex flex-row items-center justify-between px-2 py-2 border-b border-solid border-slate-100 dark:border-slate-800 gap-1 stroke-slate-700 dark:stroke-slate-200 hover:stroke-woot-500 dark:hover:stroke-woot-500 text-slate-800 dark:text-slate-100 hover:text-woot-500 dark:hover:text-woot-500"> <button id="toggle-theme-button" data-theme="light" class="flex flex-row items-center justify-between gap-1 px-2 py-2 border-b border-solid border-slate-100 dark:border-slate-800 stroke-slate-700 dark:stroke-slate-200 text-slate-800 dark:text-slate-100">
<div class="flex flex-row items-center gap-1"> <div class="flex flex-row items-center gap-1">
<%= render partial: 'icons/sun' %> <%= render partial: 'icons/sun' %>
<span class="text-xs font-medium"><%= I18n.t('public_portal.header.appearance.light') %></span> <span class="text-xs font-medium"><%= I18n.t('public_portal.header.appearance.light') %></span>
</div> </div>
<% if @theme.present? && @theme == 'light' %> <span class="check-mark-icon light-theme">
<%= render partial: 'icons/check-mark' %> <%= render partial: 'icons/check-mark' %>
<% end %> </span>
</button> </button>
<button data-theme="dark" class="flex flex-row items-center justify-between px-2 py-2 gap-1 stroke-slate-700 dark:stroke-slate-200 hover:stroke-woot-500 dark:hover:stroke-woot-500 text-slate-800 dark:text-slate-100 hover:text-woot-500 dark:hover:text-woot-500"> <button id="toggle-theme-button" data-theme="dark" class="flex flex-row items-center justify-between gap-1 px-2 py-2 stroke-slate-700 dark:stroke-slate-200 text-slate-800 dark:text-slate-100">
<div class="flex flex-row items-center gap-1"> <div class="flex flex-row items-center gap-1">
<%= render partial: 'icons/moon' %> <%= render partial: 'icons/moon' %>
<span class="text-xs font-medium"><%= I18n.t('public_portal.header.appearance.dark') %></span> <span class="text-xs font-medium"><%= I18n.t('public_portal.header.appearance.dark') %></span>
</div> </div>
<% if @theme.present? && @theme == 'dark' %> <span class="check-mark-icon dark-theme">
<%= render partial: 'icons/check-mark' %> <%= render partial: 'icons/check-mark' %>
<% end %> </span>
</button> </button>
</div> </div>
</div> </div>
<%# Locale switcher section %> <%# Locale switcher section %>
<% if @portal.config["allowed_locales"].length > 1 %> <% if @portal.config["allowed_locales"].length > 1 %>
<div class="flex items-center stroke-slate-700 dark:stroke-slate-200 hover:stroke-woot-500 dark:hover:stroke-woot-500 text-slate-800 dark:text-slate-100 hover:text-woot-500 dark:hover:text-woot-500"> <div id="header-action-button" class="flex items-center stroke-slate-700 dark:stroke-slate-200 text-slate-800 dark:text-slate-100">
<div class="flex items-center gap-1 px-1 py-2 cursor-pointer"> <div class="flex items-center gap-1 px-1 py-2 cursor-pointer">
<%= render partial: 'icons/globe' %> <%= render partial: 'icons/globe' %>
<select <select
data-portal-slug="<%= @portal.slug %>" data-portal-slug="<%= @portal.slug %>"
class="bg-white dark:bg-slate-900 appearance-none w-24 overflow-hidden text-ellipsis whitespace-nowrap leading-tight font-medium focus:outline-none text-sm focus:shadow-outline locale-switcher cursor-pointer" class="w-24 overflow-hidden text-sm font-medium leading-tight bg-white appearance-none cursor-pointer dark:bg-slate-900 text-ellipsis whitespace-nowrap focus:outline-none focus:shadow-outline locale-switcher"
> >
<% @portal.config["allowed_locales"].each do |locale| %> <% @portal.config["allowed_locales"].each do |locale| %>
<option <%= locale == params[:locale] ? 'selected': '' %> value="<%= locale %>"><%= "#{language_name(locale)} (#{locale})" %></option> <option <%= locale == params[:locale] ? 'selected': '' %> value="<%= locale %>"><%= "#{language_name(locale)} (#{locale})" %></option>

View File

@ -1,6 +1,6 @@
<% if !@is_plain_layout_enabled %> <% if !@is_plain_layout_enabled %>
<section id="portal-bg" class="w-full bg-woot-50 dark:bg-woot-900 shadow-inner" style="<%= generate_portal_bg(@portal.color, @theme) %>"> <section id="portal-bg" class="w-full bg-white dark:bg-slate-900 shadow-inner">
<div id="portal-bg-gradient" class="pt-8 pb-8 md:pt-14 md:pb-6" style="<%= generate_gradient_to_bottom(@theme) %>"> <div id="portal-bg-gradient" class="pt-8 pb-8 md:pt-14 md:pb-6 min-h-[240px] md:min-h-[260px]">
<div class="mx-auto max-w-5xl px-4 md:px-8 flex flex-col items-center sm:items-start"> <div class="mx-auto max-w-5xl px-4 md:px-8 flex flex-col items-center sm:items-start">
<h1 class="text-2xl md:text-4xl text-slate-900 dark:text-white font-semibold leading-normal"> <h1 class="text-2xl md:text-4xl text-slate-900 dark:text-white font-semibold leading-normal">
<%= portal.header_text %> <%= portal.header_text %>

View File

@ -1,17 +1,17 @@
<section class="lg:container w-full flex flex-col h-full"> <section class="flex flex-col w-full h-full lg:container">
<div class="flex flex-col gap-8 h-full group <%= !@is_plain_layout_enabled ? 'border border-solid border-slate-100 dark:border-slate-800 hover:border-woot-600 dark:hover:border-woot-600 py-5 px-3 rounded-lg' : '' %>"> <div id="<%= !@is_plain_layout_enabled ? 'category-block' : '' %>" class="flex flex-col gap-8 h-full <%= !@is_plain_layout_enabled ? 'border border-solid border-slate-100 dark:border-slate-800 py-5 px-3 rounded-lg' : '' %>">
<div class="flex justify-between items-center w-full <%= !@is_plain_layout_enabled ? 'px-1' : '' %>"> <div class="flex justify-between items-center w-full <%= !@is_plain_layout_enabled ? 'px-1' : '' %>">
<h3 class="text-xl text-slate-800 dark:text-slate-50 font-semibold leading-relaxed hover:cursor-pointer <%= @is_plain_layout_enabled ? 'hover:underline' : 'pl-1 group-hover:text-woot-600 dark:group-hover:text-woot-600' %>"> <h3 id="<%= !@is_plain_layout_enabled ? 'category-name' : '' %>" class="text-xl text-slate-800 dark:text-slate-50 font-semibold leading-relaxed hover:cursor-pointer <%= @is_plain_layout_enabled ? 'hover:underline' : 'pl-1' %>">
<%= I18n.t('public_portal.header.uncategorized') %> <%= I18n.t('public_portal.header.uncategorized') %>
</h3> </h3>
</div> </div>
<div class="-mt-4"> <div class="-mt-4">
<% portal.articles.published.where(category_id: nil).order(position: :asc).take(5).each do |article| %> <% portal.articles.published.where(category_id: nil).order(position: :asc).take(5).each do |article| %>
<a <a
class="text-slate-700 dark:text-slate-100 leading-7" class="leading-7 text-slate-700 dark:text-slate-100"
href="<%= generate_article_link(portal.slug, article.slug, @theme) %>" href="<%= generate_article_link(portal.slug, article.slug, @theme_from_params, @is_plain_layout_enabled) %>"
> >
<div class="flex justify-between hover:cursor-pointer items-center py-1 rounded-lg gap-3 <%= !@is_plain_layout_enabled ? 'px-2 hover:bg-slate-50 dark:hover:bg-slate-800' : 'hover:underline' %>"> <div id="<%= !@is_plain_layout_enabled ? 'category-item' : '' %>" class="flex justify-between hover:cursor-pointer items-center py-1 rounded-lg gap-3 <%= !@is_plain_layout_enabled ? 'px-2' : 'hover:underline' %>">
<%= article.title %> <%= article.title %>
<span class="flex items-center font-normal"> <span class="flex items-center font-normal">
<%= render partial: 'icons/chevron-right' %> <%= render partial: 'icons/chevron-right' %>

View File

@ -1,31 +1,40 @@
<div class="flex items-center flex-row mb-6 gap-px"> <% category_link_params = {
portal_slug: @portal.slug,
category_locale: @article.category.locale,
category_slug: @article.category.slug,
theme: @theme_from_params,
is_plain_layout_enabled: @is_plain_layout_enabled
}
%>
<div class="flex flex-row items-center gap-px mb-6">
<a <a
class="text-slate-500 dark:text-slate-200 text-sm gap-1 hover:cursor-pointer <%= @is_plain_layout_enabled && 'hover:underline' %> leading-8 font-semibold" class="text-slate-500 dark:text-slate-200 text-sm gap-1 hover:cursor-pointer <%= @is_plain_layout_enabled && 'hover:underline' %> leading-8 font-semibold"
href="/hc/<%= @portal.slug %>/<%= @article.category&.locale %><%= @theme.present? && @theme != 'system' ? '?theme='+@theme : '' %>" href="<%= generate_home_link(@portal.slug, @article.category&.locale, @theme_from_params, @is_plain_layout_enabled) %>"
> >
<%= I18n.t('public_portal.common.home') %> <%= I18n.t('public_portal.common.home') %>
</a> </a>
<span class="w-4 h-4 [&>svg]:w-3 [&>svg]:h-3 flex items-center justify-center text-xs text-slate-500 dark:text-slate-300"><%= render partial: 'icons/chevron-right' %></span> <span class="w-4 h-4 [&>svg]:w-3 [&>svg]:h-3 flex items-center justify-center text-xs text-slate-500 dark:text-slate-300"><%= render partial: 'icons/chevron-right' %></span>
<% if @article.category %> <% if @article.category %>
<a class="text-slate-500 dark:text-slate-200 text-sm gap-1 whitespace-nowrap hover:cursor-pointer <%= @is_plain_layout_enabled && 'hover:underline' %> leading-8 font-semibold" href="/hc/<%= @portal.slug %>/<%= @article.category.locale %>/categories/<%= @article.category.slug %><%= @theme.present? && @theme != 'system' ? '?theme='+@theme : '' %>"> <a class="text-slate-500 dark:text-slate-200 text-sm gap-1 whitespace-nowrap hover:cursor-pointer <%= @is_plain_layout_enabled && 'hover:underline' %> leading-8 font-semibold" href="<%= generate_category_link(category_link_params) %>">
<%= @article.category&.name %> <%= @article.category&.name %>
</a> </a>
<span class="w-4 h-4 [&>svg]:w-3 [&>svg]:h-3 flex items-center justify-center text-xs text-slate-500 dark:text-slate-300"><%= render partial: 'icons/chevron-right' %></span> <span class="w-4 h-4 [&>svg]:w-3 [&>svg]:h-3 flex items-center justify-center text-xs text-slate-500 dark:text-slate-300"><%= render partial: 'icons/chevron-right' %></span>
<span class="text-sm text-slate-800 dark:text-slate-100 font-semibold overflow-hidden text-ellipsis min-w-0 whitespace-nowrap"><%= @article.title %></span> <span class="min-w-0 overflow-hidden text-sm font-semibold text-slate-800 dark:text-slate-100 text-ellipsis whitespace-nowrap"><%= @article.title %></span>
<% else %> <% else %>
<span class="text-slate-700 dark:text-slate-100 leading-8 text-sm font-semibold" ><%= I18n.t('public_portal.header.uncategorized') %></span> <span class="text-sm font-semibold leading-8 text-slate-700 dark:text-slate-100" ><%= I18n.t('public_portal.header.uncategorized') %></span>
<% end %> <% end %>
</div> </div>
<h1 class="text-3xl font-semibold leading-normal md:tracking-normal md:text-4xl text-slate-900 dark:text-white"> <h1 class="text-3xl font-semibold leading-normal md:tracking-normal md:text-4xl text-slate-900 dark:text-white">
<%= article.title %> <%= article.title %>
</h1> </h1>
<div class="flex flex-col items-start justify-between w-full md:flex-row md:items-center pt-6"> <div class="flex flex-col items-start justify-between w-full pt-6 md:flex-row md:items-center">
<div class="flex items-start space-x-1"> <div class="flex items-start space-x-1">
<% if article.author&.avatar_url&.present? %> <% if article.author&.avatar_url&.present? %>
<div class="pr-px mt-0.5 min-w-[20px] min-h-[20px]"> <div class="pr-px mt-0.5 min-w-[20px] min-h-[20px]">
<img src="<%= article.author.avatar_url %>" alt="<%= article.author.display_name %>" class="w-5 h-5 border rounded-full"> <img src="<%= article.author.avatar_url %>" alt="<%= article.author.display_name %>" class="w-5 h-5 border rounded-full">
</div> </div>
<% end %> <% end %>
<span class="flex items-center text-base text-slate-600 dark:text-slate-400 font-medium">By <%= article.author.available_name %> • <%= I18n.t('public_portal.common.last_updated_on', last_updated_on: article.updated_at.strftime("%b %d, %Y")) %></span> <span class="flex items-center text-base font-medium text-slate-600 dark:text-slate-400">By <%= article.author.available_name %> • <%= I18n.t('public_portal.common.last_updated_on', last_updated_on: article.updated_at.strftime("%b %d, %Y")) %></span>
</div> </div>
</div> </div>

View File

@ -1,10 +1,10 @@
<div class="bg-slate-50 dark:bg-slate-800"> <div class="bg-slate-50 dark:bg-slate-800">
<div class="max-w-4xl px-6 py-16 mx-auto space-y-12 w-full"> <div class="w-full max-w-4xl px-6 py-16 mx-auto space-y-12">
<div class="space-y-4"> <div class="space-y-4">
<div> <div>
<a <a
class="text-slate-800 hover:underline leading-8" class="leading-8 text-slate-800 hover:underline"
href="/hc/<%= @portal.slug %>/<%= @category.present? ? @category.slug : '' %><%= @theme.present? && @theme != 'system' ? '?theme='+@theme : '' %>" href="<%= generate_home_link(@portal.slug, @category.present? ? @category.slug : '', @theme_from_params, @is_plain_layout_enabled) %>"
> >
<%= @portal.name %> <%= I18n.t('public_portal.common.home') %> <%= @portal.name %> <%= I18n.t('public_portal.common.home') %>
</a> </a>
@ -12,13 +12,13 @@
<span>/</span> <span>/</span>
</div> </div>
<% @articles.each do |article| %> <% @articles.each do |article| %>
<h1 class="text-4xl font-semibold md:tracking-normal leading-snug md:text-5xl text-slate-900 dark:text-white"> <h1 class="text-4xl font-semibold leading-snug md:tracking-normal md:text-5xl text-slate-900 dark:text-white">
<%= article.title %></h1> <%= article.title %></h1>
<div class="flex flex-col items-start justify-between w-full md:flex-row md:items-center pt-2"> <div class="flex flex-col items-start justify-between w-full pt-2 md:flex-row md:items-center">
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<img src="<%= article.author.avatar_url %>" alt="" class="w-12 h-812 border rounded-full"> <img src="<%= article.author.avatar_url %>" alt="" class="w-12 border rounded-full h-812">
<div> <div>
<h5 class="text-base font-medium text-slate-900 dark:text-white mb-2"><%= article.author.name %></h5> <h5 class="mb-2 text-base font-medium text-slate-900 dark:text-white"><%= article.author.name %></h5>
<p class="text-sm font-normal text-slate-700 dark:text-slate-100"> <p class="text-sm font-normal text-slate-700 dark:text-slate-100">
<%= article.author.updated_at.strftime("%B %d %Y") %></p> <%= article.author.updated_at.strftime("%B %d %Y") %></p>
</div> </div>
@ -28,9 +28,9 @@
</div> </div>
</div> </div>
</div> </div>
<div class="max-w-4xl flex-grow w-full px-8 py-16 mx-auto space-y-12"> <div class="flex-grow w-full max-w-4xl px-8 py-16 mx-auto space-y-12">
<article class="space-y-8"> <article class="space-y-8">
<div class="text-slate-800 dark:text-slate-50 font-sans leading-8 text-lg max-w-3xl blog-content"> <div class="max-w-3xl font-sans text-lg leading-8 text-slate-800 dark:text-slate-50 blog-content">
</div> </div>
</article> </article>
</div> </div>

View File

@ -12,8 +12,8 @@
<% end %> <% end %>
<% if !@is_plain_layout_enabled %> <% if !@is_plain_layout_enabled %>
<div id="portal-bg" class="bg-woot-50 dark:bg-woot-900 shadow-inner" style="<%= generate_portal_bg(@portal.color, @theme) %>"> <div id="portal-bg" class="bg-white dark:bg-slate-900 shadow-inner">
<div id="portal-bg-gradient" class="pt-8 pb-8 md:pt-14 md:pb-6" style="<%= generate_gradient_to_bottom(@theme) %>"> <div id="portal-bg-gradient" class="pt-8 pb-8 md:pt-14 md:pb-6">
<div class="max-w-5xl px-4 md:px-8 mx-auto flex flex-col"> <div class="max-w-5xl px-4 md:px-8 mx-auto flex flex-col">
<%= render "public/api/v1/portals/articles/article_header", article: @article %> <%= render "public/api/v1/portals/articles/article_header", article: @article %>
</div> </div>

View File

@ -1,8 +1,17 @@
<section class="lg:container w-full py-6 px-4 flex flex-col h-full"> <% category_link_params = {
<div class="flex justify-between items-center w-full"> portal_slug: portal.slug,
<h3 class="text-xl text-slate-900 dark:text-white font-semibold leading-relaxed hover:underline"> category_locale: category.locale,
<a href="/hc/<%= portal.slug %>/<%= category.locale %>/categories/<%= category.slug %><%= @theme.present? && @theme != 'system' ? '?theme='+@theme : '' %>"> category_slug: category.slug,
theme: @theme_from_params,
is_plain_layout_enabled: @is_plain_layout_enabled
}
%>
<section class="flex flex-col w-full h-full px-4 py-6 lg:container">
<div class="flex items-center justify-between w-full">
<h3 class="text-xl font-semibold leading-relaxed text-slate-900 dark:text-white hover:underline">
<a href="<%= generate_category_link(category_link_params) %>">
<%= category.name %> <%= category.name %>
</a> </a>
</h3> </h3>
@ -10,17 +19,17 @@
<%= render 'public/api/v1/portals/article_count', article_count: category.articles.published.order(position: :asc).size %> <%= render 'public/api/v1/portals/article_count', article_count: category.articles.published.order(position: :asc).size %>
</span> </span>
</div> </div>
<div class="py-4 w-full mt-2 flex-grow"> <div class="flex-grow w-full py-4 mt-2">
<% if category.articles.published.size == 0 %> <% if category.articles.published.size == 0 %>
<div class="h-full flex items-center justify-center bg-slate-50 dark:bg-slate-800 rounded-xl mb-4"> <div class="flex items-center justify-center h-full mb-4 bg-slate-50 dark:bg-slate-800 rounded-xl">
<p class="text-sm text-slate-500"><%= I18n.t('public_portal.common.no_articles') %></p> <p class="text-sm text-slate-500"><%= I18n.t('public_portal.common.no_articles') %></p>
</div> </div>
<% else %> <% else %>
<% category.articles.published.order(position: :asc).take(5).each do |article| %> <% category.articles.published.order(position: :asc).take(5).each do |article| %>
<div class="flex justify-between content-center h-8 my-1"> <div class="flex content-center justify-between h-8 my-1">
<a <a
class="text-slate-800 dark:text-slate-50 hover:underline leading-8" class="leading-8 text-slate-800 dark:text-slate-50 hover:underline"
href="/hc/<%= portal.slug %>/articles/<%= article.slug %><%= @theme.present? && @theme != 'system' ? '?theme='+@theme : '' %>" href="<%= generate_article_link(portal.slug, article.slug, @theme_from_params, @is_plain_layout_enabled) %>"
> >
<%= article.title %> <%= article.title %>
</a> </a>
@ -45,8 +54,8 @@
</div> </div>
<div> <div>
<a <a
href="/hc/<%= portal.slug %>/<%= category.locale %>/categories/<%= category.slug %><%= @theme.present? && @theme != 'system' ? '?theme='+@theme : '' %>" href="<%= generate_category_link(category_link_params) %>"
class="flex flex-row items-center text-base font-medium text-woot-600 dark:text-woot-500 hover:text-slate-900 dark:hover:text-white hover:underline mt-4" class="flex flex-row items-center mt-4 text-base font-medium text-woot-600 dark:text-woot-500 hover:text-slate-900 dark:hover:text-white hover:underline"
> >
<%= I18n.t('public_portal.common.view_all_articles') %> <%= I18n.t('public_portal.common.view_all_articles') %>
<span class="ml-2"> <span class="ml-2">

View File

@ -2,7 +2,7 @@
<div class="flex items-center flex-row"> <div class="flex items-center flex-row">
<a <a
class="text-slate-500 dark:text-slate-200 text-sm gap-1 <%= @is_plain_layout_enabled && 'hover:underline' %> hover:cursor-pointer leading-8 font-semibold" class="text-slate-500 dark:text-slate-200 text-sm gap-1 <%= @is_plain_layout_enabled && 'hover:underline' %> hover:cursor-pointer leading-8 font-semibold"
href="/hc/<%= portal.slug %>/<%= category.locale %><%= @theme.present? && @theme != 'system' ? '?theme='+@theme : '' %>" href="<%= generate_home_link(portal.slug, category.locale, @theme_from_params, @is_plain_layout_enabled) %>"
> >
<%= I18n.t('public_portal.common.home') %> <%= I18n.t('public_portal.common.home') %>
</a> </a>
@ -14,11 +14,11 @@
<% if category.icon.present? %> <% if category.icon.present? %>
<span class="text-4xl"><%= category.icon %></span> <span class="text-4xl"><%= category.icon %></span>
<% end %> <% end %>
<h1 class="text-3xl font-semibold leading-[52.5px] text-slate-900 dark:text-white"> <h1 class="text-3xl font-bold tracking-wide leading-[52.5px] text-slate-900 dark:text-white">
<%= category.name %> <%= category.name %>
</h1> </h1>
<% if category.description.present? %> <% if category.description.present? %>
<span class="font-medium text-slate-800 dark:text-slate-75 text-base leading-5"><%= category.description %></span> <span class="font-medium text-slate-700 dark:text-slate-200 text-base leading-5"><%= category.description %></span>
<% end %> <% end %>
</div> </div>
<span class="flex items-center text-base text-slate-600 dark:text-slate-400 font-medium"><%= render 'public/api/v1/portals/article_count', article_count: category.articles.published.size %></span> <span class="flex items-center text-base text-slate-600 dark:text-slate-400 font-medium"><%= render 'public/api/v1/portals/article_count', article_count: category.articles.published.size %></span>

View File

@ -6,8 +6,8 @@
<% end %> <% end %>
<% if !@is_plain_layout_enabled %> <% if !@is_plain_layout_enabled %>
<div id="portal-bg" class="bg-woot-50 dark:bg-woot-900" style="<%= generate_portal_bg(@portal.color, @theme) %>"> <div id="portal-bg" class="bg-white dark:bg-slate-900">
<div id="portal-bg-gradient" class="pt-8 pb-8 md:pt-14 md:pb-6" style="<%= generate_gradient_to_bottom(@theme) %>"> <div id="portal-bg-gradient" class="pt-8 pb-8 md:pt-14 md:pb-6">
<%= render 'public/api/v1/portals/categories/category-hero', category: @category, portal: @portal %> <%= render 'public/api/v1/portals/categories/category-hero', category: @category, portal: @portal %>
</div> </div>
</div> </div>
@ -22,14 +22,14 @@
</div> </div>
<% else %> <% else %>
<% @category.articles.published.order(:position).each do |article| %> <% @category.articles.published.order(:position).each do |article| %>
<div class="<%= !@is_plain_layout_enabled ? 'group border border-solid border-slate-100 dark:border-slate-800 hover:border-woot-600 dark:hover:border-woot-600 rounded-lg' : '' %>"> <div id="<%= !@is_plain_layout_enabled ? 'category-block' : '' %>" class="<%= !@is_plain_layout_enabled ? 'border border-solid border-slate-100 dark:border-slate-800 rounded-lg' : 'group' %>">
<a <a
class="<%= !@is_plain_layout_enabled ? 'p-4' : 'px-0 py-1' %> text-slate-800 dark:text-slate-50 flex justify-between content-center hover:cursor-pointer" class="<%= !@is_plain_layout_enabled ? 'p-4' : 'px-0 py-1' %> text-slate-800 dark:text-slate-50 flex justify-between content-center hover:cursor-pointer"
href="/hc/<%= @portal.slug %>/articles/<%= article.slug %><%= @theme.present? && @theme != 'system' ? '?theme='+@theme : '' %>" href="<%= generate_article_link(@portal.slug, article.slug, @theme_from_params, @is_plain_layout_enabled) %>"
> >
<div class="flex flex-col gap-5"> <div class="flex flex-col gap-5">
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<h3 class="text-lg text-slate-900 dark:text-slate-50 font-semibold <%= @is_plain_layout_enabled ? 'hover:underline' : 'group-hover:text-woot-600 dark:group-hover:text-woot-600' %>"><%= article.title %></h3> <h3 id="<%= !@is_plain_layout_enabled ? 'category-name' : '' %>" class="text-lg text-slate-900 dark:text-slate-50 font-semibold <%= @is_plain_layout_enabled ? 'group-hover:underline' : '' %>"><%= article.title %></h3>
<p class="text-base font-normal text-slate-500 dark:text-slate-200 line-clamp-1 break-all"><%= render_category_content(article.content) %></p> <p class="text-base font-normal text-slate-500 dark:text-slate-200 line-clamp-1 break-all"><%= render_category_content(article.content) %></p>
</div> </div>
<span class="text-sm text-slate-600 dark:text-slate-400 font-medium flex items-center"><%= I18n.t('public_portal.common.last_updated_on', last_updated_on: article.updated_at.strftime("%b %d, %Y")) %></span> <span class="text-sm text-slate-600 dark:text-slate-400 font-medium flex items-center"><%= I18n.t('public_portal.common.last_updated_on', last_updated_on: article.updated_at.strftime("%b %d, %Y")) %></span>

View File

@ -5,7 +5,7 @@
<h1 class="text-6xl text-center font-semibold text-slate-800 dark:text-slate-100 leading-relaxed"><%= I18n.t('public_portal.404.title') %></h1> <h1 class="text-6xl text-center font-semibold text-slate-800 dark:text-slate-100 leading-relaxed"><%= I18n.t('public_portal.404.title') %></h1>
<p class="text-center text-slate-700 dark:text-slate-300 my-1"><%= I18n.t('public_portal.404.description') %></p> <p class="text-center text-slate-700 dark:text-slate-300 my-1"><%= I18n.t('public_portal.404.description') %></p>
<div class="text-center my-8"> <div class="text-center my-8">
<a href="/hc/<%= @portal.slug %>/<%= @portal.config['default_locale'] || params[:locale] %>/<%= @theme.present? ? '?theme='+@theme : '' %>" class="text-woot-500 font-semibold underline"> <a href="<%= generate_home_link(@portal.slug, @portal.config['default_locale'] || params[:locale], @theme_from_params, @is_plain_layout_enabled) %>" class="text-woot-500 font-semibold underline">
<%= I18n.t('public_portal.404.back_to_home') %> <%= I18n.t('public_portal.404.back_to_home') %>
</a> </a>
</div> </div>

View File

@ -50,6 +50,7 @@
"babel-plugin-syntax-jsx": "^6.18.0", "babel-plugin-syntax-jsx": "^6.18.0",
"babel-plugin-transform-vue-jsx": "^3.7.0", "babel-plugin-transform-vue-jsx": "^3.7.0",
"chart.js": "~2.9.4", "chart.js": "~2.9.4",
"color2k": "^2.0.2",
"company-email-validator": "^1.0.8", "company-email-validator": "^1.0.8",
"core-js": "3.11.0", "core-js": "3.11.0",
"date-fns": "2.21.1", "date-fns": "2.21.1",

View File

@ -33,71 +33,175 @@ describe PortalHelper do
describe '#generate_portal_bg' do describe '#generate_portal_bg' do
context 'when theme is dark' do context 'when theme is dark' do
it 'returns the correct background with dark grid image and color mix with black' do it 'returns the correct background with dark grid image and color mix with black' do
expected_bg = 'background: url(/assets/images/hc/hexagon-dark.svg) color-mix(in srgb, #ff0000 20%, black)' expected_bg = 'url(/assets/images/hc/hexagon-dark.svg) color-mix(in srgb, #ff0000 20%, black)'
expect(helper.generate_portal_bg('#ff0000', 'dark')).to eq(expected_bg) expect(helper.generate_portal_bg('#ff0000', 'dark')).to eq(expected_bg)
end end
end end
context 'when theme is not dark' do context 'when theme is not dark' do
it 'returns the correct background with light grid image and color mix with white' do it 'returns the correct background with light grid image and color mix with white' do
expected_bg = 'background: url(/assets/images/hc/hexagon-light.svg) color-mix(in srgb, #ff0000 20%, white)' expected_bg = 'url(/assets/images/hc/hexagon-light.svg) color-mix(in srgb, #ff0000 20%, white)'
expect(helper.generate_portal_bg('#ff0000', 'light')).to eq(expected_bg) expect(helper.generate_portal_bg('#ff0000', 'light')).to eq(expected_bg)
end end
end end
context 'when provided with various colors' do context 'when provided with various colors' do
it 'adjusts the background appropriately for dark theme' do it 'adjusts the background appropriately for dark theme' do
expected_bg = 'background: url(/assets/images/hc/hexagon-dark.svg) color-mix(in srgb, #00ff00 20%, black)' expected_bg = 'url(/assets/images/hc/hexagon-dark.svg) color-mix(in srgb, #00ff00 20%, black)'
expect(helper.generate_portal_bg('#00ff00', 'dark')).to eq(expected_bg) expect(helper.generate_portal_bg('#00ff00', 'dark')).to eq(expected_bg)
end end
it 'adjusts the background appropriately for light theme' do it 'adjusts the background appropriately for light theme' do
expected_bg = 'background: url(/assets/images/hc/hexagon-light.svg) color-mix(in srgb, #0000ff 20%, white)' expected_bg = 'url(/assets/images/hc/hexagon-light.svg) color-mix(in srgb, #0000ff 20%, white)'
expect(helper.generate_portal_bg('#0000ff', 'light')).to eq(expected_bg) expect(helper.generate_portal_bg('#0000ff', 'light')).to eq(expected_bg)
end end
end end
end end
describe '#get_theme_names' do
it 'returns the light theme name' do
expect(helper.get_theme_names('light')).to eq(I18n.t('public_portal.header.appearance.light'))
end
it 'returns the dark theme name' do
expect(helper.get_theme_names('dark')).to eq(I18n.t('public_portal.header.appearance.dark'))
end
it 'returns the system theme name for any other value' do
expect(helper.get_theme_names('any_other_value')).to eq(I18n.t('public_portal.header.appearance.system'))
end
end
describe '#get_theme_icon' do
it 'returns the light theme icon' do
expect(helper.get_theme_icon('light')).to eq('icons/sun')
end
it 'returns the dark theme icon' do
expect(helper.get_theme_icon('dark')).to eq('icons/moon')
end
it 'returns the system theme icon for any other value' do
expect(helper.get_theme_icon('any_other_value')).to eq('icons/monitor')
end
end
describe '#generate_gradient_to_bottom' do describe '#generate_gradient_to_bottom' do
context 'when theme is dark' do context 'when theme is dark' do
it 'returns the correct background gradient' do it 'returns the correct gradient' do
expected_gradient = 'background-image: linear-gradient(to bottom, transparent, #151718)' expect(helper.generate_gradient_to_bottom('dark')).to eq(
expect(helper.generate_gradient_to_bottom('dark')).to eq(expected_gradient) 'linear-gradient(to bottom, transparent, #151718)'
)
end end
end end
context 'when theme is not dark' do context 'when theme is not dark' do
it 'returns the correct background gradient' do it 'returns the correct gradient' do
expected_gradient = 'background-image: linear-gradient(to bottom, transparent, white)' expect(helper.generate_gradient_to_bottom('light')).to eq(
expect(helper.generate_gradient_to_bottom('light')).to eq(expected_gradient) 'linear-gradient(to bottom, transparent, white)'
)
end
end
context 'when provided with various colors' do
it 'adjusts the gradient appropriately' do
expect(helper.generate_gradient_to_bottom('dark')).to eq(
'linear-gradient(to bottom, transparent, #151718)'
)
expect(helper.generate_gradient_to_bottom('light')).to eq(
'linear-gradient(to bottom, transparent, white)'
)
end
end
end
describe '#generate_portal_hover_color' do
context 'when theme is dark' do
it 'returns the correct color mix with #1B1B1B' do
expect(helper.generate_portal_hover_color('#ff0000', 'dark')).to eq(
'color-mix(in srgb, #ff0000 5%, #1B1B1B)'
)
end
end
context 'when theme is not dark' do
it 'returns the correct color mix with #F9F9F9' do
expect(helper.generate_portal_hover_color('#ff0000', 'light')).to eq(
'color-mix(in srgb, #ff0000 5%, #F9F9F9)'
)
end
end
context 'when provided with various colors' do
it 'adjusts the color mix appropriately' do
expect(helper.generate_portal_hover_color('#00ff00', 'dark')).to eq(
'color-mix(in srgb, #00ff00 5%, #1B1B1B)'
)
expect(helper.generate_portal_hover_color('#0000ff', 'light')).to eq(
'color-mix(in srgb, #0000ff 5%, #F9F9F9)'
)
end
end
end
describe '#theme_query_string' do
context 'when theme is present and not system' do
it 'returns the correct query string' do
expect(helper.theme_query_string('dark')).to eq('?theme=dark')
end
end
context 'when theme is not present' do
it 'returns the correct query string' do
expect(helper.theme_query_string(nil)).to eq('')
end
end
context 'when theme is system' do
it 'returns the correct query string' do
expect(helper.theme_query_string('system')).to eq('')
end
end
end
describe '#generate_home_link' do
context 'when theme is not present' do
it 'returns the correct link' do
expect(helper.generate_home_link('portal_slug', 'en', nil, true)).to eq(
'/hc/portal_slug/en'
)
end
end
context 'when theme is present and plain layout is enabled' do
it 'returns the correct link' do
expect(helper.generate_home_link('portal_slug', 'en', 'dark', true)).to eq(
'/hc/portal_slug/en?theme=dark'
)
end
end
context 'when plain layout is not enabled' do
it 'returns the correct link' do
expect(helper.generate_home_link('portal_slug', 'en', 'dark', false)).to eq(
'/hc/portal_slug/en'
)
end
end
end
describe '#generate_category_link' do
context 'when theme is not present' do
it 'returns the correct link' do
expect(helper.generate_category_link(
portal_slug: 'portal_slug',
category_locale: 'en',
category_slug: 'category_slug',
theme: nil,
is_plain_layout_enabled: true
)).to eq(
'/hc/portal_slug/en/categories/category_slug'
)
end
end
context 'when theme is present and plain layout is enabled' do
it 'returns the correct link' do
expect(helper.generate_category_link(
portal_slug: 'portal_slug',
category_locale: 'en',
category_slug: 'category_slug',
theme: 'dark',
is_plain_layout_enabled: true
)).to eq(
'/hc/portal_slug/en/categories/category_slug?theme=dark'
)
end
end
context 'when plain layout is not enabled' do
it 'returns the correct link' do
expect(helper.generate_category_link(
portal_slug: 'portal_slug',
category_locale: 'en',
category_slug: 'category_slug',
theme: 'dark',
is_plain_layout_enabled: false
)).to eq(
'/hc/portal_slug/en/categories/category_slug'
)
end end
end end
end end
@ -105,19 +209,27 @@ describe PortalHelper do
describe '#generate_article_link' do describe '#generate_article_link' do
context 'when theme is not present' do context 'when theme is not present' do
it 'returns the correct link' do it 'returns the correct link' do
expect(helper.generate_article_link('portal_slug', 'article_slug', nil)).to eq( expect(helper.generate_article_link('portal_slug', 'article_slug', nil, true)).to eq(
'/hc/portal_slug/articles/article_slug' '/hc/portal_slug/articles/article_slug'
) )
end end
end end
context 'when theme is present' do context 'when theme is present and plain layout is enabled' do
it 'returns the correct link' do it 'returns the correct link' do
expect(helper.generate_article_link('portal_slug', 'article_slug', 'dark')).to eq( expect(helper.generate_article_link('portal_slug', 'article_slug', 'dark', true)).to eq(
'/hc/portal_slug/articles/article_slug?theme=dark' '/hc/portal_slug/articles/article_slug?theme=dark'
) )
end end
end end
context 'when plain layout is not enabled' do
it 'returns the correct link' do
expect(helper.generate_article_link('portal_slug', 'article_slug', 'dark', false)).to eq(
'/hc/portal_slug/articles/article_slug'
)
end
end
end end
describe '#render_category_content' do describe '#render_category_content' do

View File

@ -8926,6 +8926,11 @@ color-support@^1.1.2:
resolved "https://registry.yarnpkg.com/color-support/-/color-support-1.1.3.tgz#93834379a1cc9a0c61f82f52f0d04322251bd5a2" resolved "https://registry.yarnpkg.com/color-support/-/color-support-1.1.3.tgz#93834379a1cc9a0c61f82f52f0d04322251bd5a2"
integrity sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg== integrity sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==
color2k@^2.0.2:
version "2.0.2"
resolved "https://registry.yarnpkg.com/color2k/-/color2k-2.0.2.tgz#ac2b4aea11c822a6bcb70c768b5a289f4fffcebb"
integrity sha512-kJhwH5nAwb34tmyuqq/lgjEKzlFXn1U99NlnB6Ws4qVaERcRUYeYP1cBw6BJ4vxaWStAUEef4WMr7WjOCnBt8w==
color@^3.0.0: color@^3.0.0:
version "3.1.3" version "3.1.3"
resolved "https://registry.yarnpkg.com/color/-/color-3.1.3.tgz#ca67fb4e7b97d611dcde39eceed422067d91596e" resolved "https://registry.yarnpkg.com/color/-/color-3.1.3.tgz#ca67fb4e7b97d611dcde39eceed422067d91596e"