Skip to content
Open
Show file tree
Hide file tree
Changes from 48 commits
Commits
Show all changes
55 commits
Select commit Hold shift + click to select a range
a820935
feat(icons): add dark mode icon
b-l-i-n-d Jul 29, 2026
7a3fc99
feat(brand-logo): add brand logo template and frontend styles
b-l-i-n-d Jul 29, 2026
f0a3ff2
feat(brand-logo): add brand logo settings field
b-l-i-n-d Jul 29, 2026
288c2cc
feat(brand-logo): add brand logo settings field
b-l-i-n-d Jul 29, 2026
ec78aa8
feat(page-elements): replace spotlight mode with page element header/…
b-l-i-n-d Jul 29, 2026
39d57cf
feat(icons): replace dashicons with custom footer svg icon in page el…
b-l-i-n-d Jul 29, 2026
6727240
refactor: remove unused CSS selectors from brand logo and page elemen…
b-l-i-n-d Jul 29, 2026
e7b2912
refactor: rename brand logo CSS classes from BEM to hyphenated
b-l-i-n-d Jul 29, 2026
ceb3978
fix: preserve spotlight mode behavior in page elements default visibi…
b-l-i-n-d Jul 29, 2026
b0adfa7
refactor: Add `show_learning_site` header and footer options check
b-l-i-n-d Jul 29, 2026
0a17681
refactor: Revert unintentional changes
b-l-i-n-d Jul 29, 2026
3332625
refactor: Update code readability
b-l-i-n-d Jul 30, 2026
a29d553
style: Update header positioning
b-l-i-n-d Jul 30, 2026
798ac8b
Merge branch 'dev' into feat/logo
b-l-i-n-d Aug 4, 2026
d64abd6
fix(settings): resolve PHPStan type errors in Options_V2
b-l-i-n-d Aug 4, 2026
11d63b5
refactor(settings): generic image upload list field type
b-l-i-n-d Aug 4, 2026
25965d5
refactor(settings): generic toggle matrix field type
b-l-i-n-d Aug 4, 2026
6e747d1
refactor: Resolve pho stan warning
b-l-i-n-d Aug 4, 2026
164455a
docs: Update since tag
b-l-i-n-d Aug 5, 2026
a2b2a5f
refactor: Update since tag and default values
b-l-i-n-d Aug 5, 2026
32d3157
refactor(settings): make image upload list reusable
b-l-i-n-d Aug 6, 2026
d960a31
refactor: Remove redundant default value
b-l-i-n-d Aug 6, 2026
44576ba
Merge branch 'dev' into feat/logo
b-l-i-n-d Aug 6, 2026
8842a35
feat: Add sidebar logo visbility controll
b-l-i-n-d Aug 6, 2026
02f4327
Merge branch 'dev' into feat/logo
b-l-i-n-d Aug 7, 2026
172c235
feat(learning-area): track theme site-shell header offset
b-l-i-n-d Aug 7, 2026
0de52dd
style(learning-area): Update footer and sidebar style conflict.
b-l-i-n-d Aug 10, 2026
55318c8
docs: Update since tag
b-l-i-n-d Aug 13, 2026
abb1871
refactor: Use ignore-kids to resolve unintentional icon changes
b-l-i-n-d Aug 13, 2026
93d47bc
feat(brand-logo): migrate course builder logo to light brand logo set…
b-l-i-n-d Aug 14, 2026
f40341c
feat(settings): reset image_upload_list and toggle_matrix fields
b-l-i-n-d Aug 14, 2026
aa5ad9a
style(admin): hide isolated course details wrappers when options disa…
b-l-i-n-d Aug 14, 2026
c43ef02
refactor(page-elements): migrate spotlight mode to per-shell visibility
b-l-i-n-d Aug 14, 2026
7c6f665
docs: add project context glossary and learning-area ADR
b-l-i-n-d Aug 14, 2026
c3458fc
feat(learning-area): wrap quiz attempt and details in site shell
b-l-i-n-d Aug 14, 2026
2a9c87b
feat(learning-area): track site-header overlay offset for quiz layout
b-l-i-n-d Aug 14, 2026
3346fec
style(learning-area): bound quiz and summary layout to theme shell
b-l-i-n-d Aug 14, 2026
c9a6487
refactor(dashboard): move mobile nav into dashboard layout flow
b-l-i-n-d Aug 14, 2026
7244b23
refactor(template): centralize site-shell data resolution
b-l-i-n-d Aug 17, 2026
d28fad2
feat(dashboard): apply site-shell offset controller to dashboard layouts
b-l-i-n-d Aug 17, 2026
0d67d6a
style(dashboard): offset dashboard layout beneath theme header in sit…
b-l-i-n-d Aug 17, 2026
cb4a620
fix(quiz): bound quiz summary within site shell and reserve header ov…
b-l-i-n-d Aug 17, 2026
aebe0a2
fix(logo): use site title as logo alt text
b-l-i-n-d Aug 17, 2026
9605c15
refactor(site-shell): move site-shell controller to frontend services
b-l-i-n-d Aug 17, 2026
42fab85
Merge branch dev into feat/logo
b-l-i-n-d Aug 17, 2026
b271225
style: Remove unwanted scss
b-l-i-n-d Aug 17, 2026
c3c5baf
feat(image-upload-list): Add description support
b-l-i-n-d Aug 21, 2026
674e8ef
chore: Remove unnecessary code
b-l-i-n-d Aug 21, 2026
e4d661e
fix(course): guard attachment image url retrieval for brand logo
b-l-i-n-d Aug 24, 2026
de74c63
refactor(template): support header and footer visibility in custom he…
b-l-i-n-d Aug 24, 2026
e5ba0ca
fix(options): add fallback normalization for legacy spotlight mode an…
b-l-i-n-d Aug 24, 2026
b225934
fix(template): add header role heading selector for theme header dete…
b-l-i-n-d Aug 24, 2026
45392e1
docs(options): update @since version tag for image_upload_list
b-l-i-n-d Aug 24, 2026
4c3794c
fix(options): check both header and footer visibility for legacy spot…
b-l-i-n-d Aug 24, 2026
ded4ede
feat(learning-area): move expand button under more pages container an…
b-l-i-n-d Aug 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion assets/core/ts/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ const defaultTutorConfig = {
instructor_can_change_course_author: 'off',
instructor_can_manage_co_instructors: 'off',
chatgpt_enable: 'off',
course_builder_logo_url: '',
brand_logo_light: '',
chatgpt_key_exist: false,
hide_admin_bar_for_users: 'off',
enable_redirect_on_course_publish_from_frontend: 'off',
Expand Down
1 change: 1 addition & 0 deletions assets/icons/dark.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions assets/icons/footer.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
67 changes: 67 additions & 0 deletions assets/src/js/admin-dashboard/segments/options.js
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,73 @@ document.addEventListener('DOMContentLoaded', function () {
};
}

/**
* Initialize a reusable image upload list field.
*
* Wires up the select/remove buttons rendered by the `image_upload_list`
* field type, so any similar field can reuse it with its own settings.
*
* @since 4.0.6
*
* @param {Object} config
* @param {string} config.mediaTitle Media frame title.
* @param {string} config.mediaButtonText Media frame select button text.
* @param {string[]} [config.allowedMimeTypes] Restrict selectable attachment mime types. Empty means any image.
* @return {void}
*/
function initImageUploadList({ mediaTitle, mediaButtonText, allowedMimeTypes = [] }) {
$(document).on('click', '.tutor-image-upload-select', function (event) {
event.preventDefault();

const $upload = $(this).closest('.tutor-image-upload-item');
const $input = $upload.find('.tutor-image-upload-input');
const $preview = $upload.find('.tutor-image-upload-preview');
const $image = $preview.find('img');
const $remove = $upload.find('.tutor-image-upload-remove');
const frame = wp.media({
title: mediaTitle,
button: { text: mediaButtonText },
library: { type: 'image' },
multiple: false,
});

frame.on('select', function () {
const attachment = frame.state().get('selection').first().toJSON();
if (allowedMimeTypes.length && !allowedMimeTypes.includes(attachment.mime)) {
tutor_toast(__('Error!', 'tutor'), __('Please select a supported image.', 'tutor'), 'error');
return;
}

$input.val(attachment.id).trigger('change');
$image.attr('src', attachment.url).prop('hidden', false);
$preview.addClass('has-image');
$upload.addClass('has-image');
$remove.prop('hidden', false);
$('#save_tutor_option').prop('disabled', false);
});

frame.open();
});

$(document).on('click', '.tutor-image-upload-remove', function (event) {
event.preventDefault();

const $upload = $(this).closest('.tutor-image-upload-item');
$upload.find('.tutor-image-upload-input').val('').trigger('change');
$upload.find('.tutor-image-upload-preview img').attr('src', '').prop('hidden', true);
$upload.find('.tutor-image-upload-preview').removeClass('has-image');
$upload.removeClass('has-image');
$(this).prop('hidden', true);
$('#save_tutor_option').prop('disabled', false);
});
}

initImageUploadList({
mediaTitle: __('Select a brand logo', 'tutor'),
mediaButtonText: __('Use this logo', 'tutor'),
allowedMimeTypes: ['image/jpeg', 'image/png'],
});

const validateEmail = (email) => {
const re = /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
return re.test(String(email).toLowerCase());
Expand Down
34 changes: 32 additions & 2 deletions assets/src/js/admin-dashboard/segments/reset.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ const resetConfirmation = () => {
if (xhttp.readyState === 4) {
let pageData = JSON.parse(xhttp.response).data;
pageData.forEach((item) => {
const field_types_associate = ['color_field', 'upload_full', 'checkbox_notification', 'checkgroup', 'group_radio_full_3', 'group_radio', 'radio_vertical', 'checkbox_horizontal', 'radio_horizontal', 'radio_horizontal_full', 'radio_horizontal_image', 'checkbox_vertical', 'toggle_switch', 'toggle_switch_button', 'text', 'textarea', 'email', 'hidden', 'select', 'number'];
const field_types_associate = ['color_field', 'upload_full', 'image_upload_list', 'toggle_matrix', 'checkbox_notification', 'checkgroup', 'group_radio_full_3', 'group_radio', 'radio_vertical', 'checkbox_horizontal', 'radio_horizontal', 'radio_horizontal_full', 'radio_horizontal_image', 'checkbox_vertical', 'toggle_switch', 'toggle_switch_button', 'text', 'textarea', 'email', 'hidden', 'select', 'number'];

if (field_types_associate.includes(item.type)) {
let itemName = 'tutor_option[' + item.key + ']';
Expand Down Expand Up @@ -84,6 +84,36 @@ const resetConfirmation = () => {
});
}

} else if (item.type == 'image_upload_list') {
Object.values(item.items || {}).forEach((imageItem) => {
const imageInput = elementByName(`tutor_option[${imageItem.key}]`)[0];
if (!imageInput) return;

const uploadItem = imageInput.closest('.tutor-image-upload-item');
const defaultValue = imageItem.default || '';
const defaultImageUrl = uploadItem?.dataset.defaultImageUrl || '';
imageInput.value = defaultValue;
uploadItem?.classList.toggle('has-image', !!defaultImageUrl);
uploadItem?.querySelector('.tutor-image-upload-preview')?.classList.toggle('has-image', !!defaultImageUrl);
const image = uploadItem?.querySelector('.tutor-image-upload-preview img');
if (image) {
image.src = defaultImageUrl;
image.hidden = !defaultImageUrl;
}
});
} else if (item.type == 'toggle_matrix') {
Object.values(item.items || {}).forEach((matrixItem) => {
Object.values(matrixItem).forEach((setting) => {
if (!setting || !setting.key) return;
const matrixInput = elementByName(`tutor_option[${setting.key}]`)[0];
if (!matrixInput) return;

const isEnabled = setting.default === 'on';
matrixInput.value = isEnabled ? 'on' : 'off';
const checkbox = matrixInput.nextElementSibling;
if (checkbox) checkbox.checked = isEnabled;
});
});
} else if (item.type == 'upload_full') {
elementItem.value = '';
elementItem.nextElementSibling.src = '';
Expand Down Expand Up @@ -193,4 +223,4 @@ if (null !== optionForm) {
document.getElementById('save_tutor_option').disabled = false;
}
});
}
}
2 changes: 2 additions & 0 deletions assets/src/js/frontend/dashboard/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import { initializeReviews } from '@FrontendComponents/reviews';
import { initializeTour } from '@FrontendComponents/tour';
import { initializeCommon } from '@FrontendServices/common';
import { initializeSiteShell } from '@FrontendServices/site-shell';

import { initializeConfetti } from './confetti';
import { initializeHeader } from './header';
Expand Down Expand Up @@ -74,6 +75,7 @@ const getCurrentPage = (): string => {
};

const initializeDashboard = () => {
initializeSiteShell();
initializeHeader();
initializeCommon();
initializeTour();
Expand Down
2 changes: 2 additions & 0 deletions assets/src/js/frontend/learning-area/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import { initializeReviews } from '@FrontendComponents/reviews';
import { initializeTour } from '@FrontendComponents/tour';
import { initializeCommon } from '@FrontendServices/common';
import { initializeSiteShell } from '@FrontendServices/site-shell';

import { initializeCommon as initializeLearningAreaCommon } from './common';
import { initializeLesson } from './lesson';
Expand All @@ -21,6 +22,7 @@ const decodePathSegment = (segment: string): string => {
};

const initializeLearningArea = () => {
initializeSiteShell();
initializeLearningAreaCommon();
initializeCommon();
initializeSidebar();
Expand Down
136 changes: 136 additions & 0 deletions assets/src/js/frontend/services/site-shell.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
const CSS_VARIABLE_HEADER_OFFSET = '--tutor-site-header-offset';
const CSS_VARIABLE_HEADER_OVERLAY_OFFSET = '--tutor-site-header-overlay-offset';

const WP_ADMIN_BAR_ID = 'wpadminbar';

const SITE_SHELL_ROOT_SELECTOR = '[data-tutor-learning-site-shell], [data-tutor-dashboard-site-shell]';

/**
* Returns the lowest visible bottom edge among the site header and admin bar.
*
* A normal-flow header contributes while it is visible, then naturally drops
* out of the boundary as the document scrolls. Fixed and sticky headers keep
* contributing because their bottom edge remains in the viewport.
*/
const getVisibleHeaderBoundary = (elements: HTMLElement[]): number => {
return elements.reduce((offset, element) => {
return Math.max(offset, element.getBoundingClientRect().bottom, 0);
}, 0);
};

/**
* A static (normal-flow) header only needs its computed position checked,
* not its inline style, since positioning is almost always set via CSS.
*/
const isStaticPosition = (element: HTMLElement): boolean => {
return window.getComputedStyle(element).position === 'static';
};

class SiteShellController {
private animationFrame: number | null = null;
private resizeObserver: ResizeObserver | null = null;
private themeHeader: HTMLElement | null = null;

constructor(
private readonly root: HTMLElement,
private readonly themeHeaderSelector: string,
) {}

start(): void {
this.themeHeader = this.findThemeHeader();
this.updateOffset();

// A normal-flow (static) header naturally drops out of the visible
// boundary once scrolled past — its contribution to updateOffset()
// only matters on first paint, so skip the ongoing listeners/observer.
if (this.themeHeader && isStaticPosition(this.themeHeader)) {
return;
}

if (this.themeHeader && typeof ResizeObserver !== 'undefined') {
this.resizeObserver = new ResizeObserver(() => this.scheduleOffsetUpdate());
this.resizeObserver.observe(this.themeHeader);
}

window.addEventListener('resize', this.scheduleOffsetUpdate);
window.addEventListener('scroll', this.scheduleOffsetUpdate, { passive: true });
}

destroy(): void {
this.resizeObserver?.disconnect();
this.resizeObserver = null;

window.removeEventListener('resize', this.scheduleOffsetUpdate);
window.removeEventListener('scroll', this.scheduleOffsetUpdate);

if (this.animationFrame !== null) {
window.cancelAnimationFrame(this.animationFrame);
this.animationFrame = null;
}

this.themeHeader = null;
}

private findThemeHeader(): HTMLElement | null {
if (!this.themeHeaderSelector) {
return null;
}

try {
const header = document.querySelector(this.themeHeaderSelector);
return header instanceof HTMLElement ? header : null;
} catch {
return null;
}
}

private scheduleOffsetUpdate = (): void => {
if (this.animationFrame !== null) {
return;
}

this.animationFrame = window.requestAnimationFrame(() => {
this.animationFrame = null;
this.updateOffset();
});
};

private updateOffset(): void {
const adminBar = document.getElementById(WP_ADMIN_BAR_ID);
const elements = [this.themeHeader, adminBar].filter(
(element): element is HTMLElement => element instanceof HTMLElement,
);
const offset = getVisibleHeaderBoundary(elements);

this.root.style.setProperty(CSS_VARIABLE_HEADER_OFFSET, `${offset}px`);

// The active-quiz form is positioned below a normal-flow theme header,
// but starts behind a fixed or stuck header. This value lets its
// absolutely positioned question panel reserve only the part of the
// visible boundary that overlays the Learning Area itself.
const rootTop = this.root.getBoundingClientRect().top;
const overlayOffset = Math.max(0, offset - Math.max(0, rootTop));
this.root.style.setProperty(CSS_VARIABLE_HEADER_OVERLAY_OFFSET, `${overlayOffset}px`);
}
}

/**
* Initializes all Tutor site-shell roots found in the current DOM.
*
* Returns a disposer that tears down every controller it created (removes
* listeners, disconnects observers, cancels pending rAFs). Callers are
* responsible for invoking it when the roots are removed or re-initialized,
* to avoid duplicate listeners/observers stacking up across repeated calls.
*/
export const initializeSiteShell = (): (() => void) => {
const controllers = Array.from(document.querySelectorAll<HTMLElement>(SITE_SHELL_ROOT_SELECTOR)).map((root) => {
const selector = root.dataset.tutorThemeHeaderSelector || '';
const controller = new SiteShellController(root, selector);
controller.start();
return controller;
});

return () => {
controllers.forEach((controller) => controller.destroy());
};
};
2 changes: 1 addition & 1 deletion assets/src/js/v3/@types/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,7 @@ declare global {
instructor_can_change_course_author: 'on' | 'off';
instructor_can_manage_co_instructors: 'on' | 'off';
chatgpt_enable: 'on' | 'off';
course_builder_logo_url: string | false;
brand_logo_light: string | false;
chatgpt_key_exist: boolean;
hide_admin_bar_for_users: 'on' | 'off';
enable_redirect_on_course_publish_from_frontend: 'on' | 'off';
Expand Down
9 changes: 2 additions & 7 deletions assets/src/js/v3/shared/components/Logo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,10 @@ interface LogoProps {
}

const Logo = ({ wrapperCss }: LogoProps) => {
const isTutorPro = !!tutorConfig.tutor_pro_url;

return (
<button tabIndex={-1} type="button" css={[styleUtils.resetButton, styles.logo, wrapperCss]}>
<Show
when={isTutorPro && tutorConfig.settings?.course_builder_logo_url}
fallback={<LogoSvg width={108} height={24} />}
>
{(logo) => <img src={logo} alt="Tutor LMS" />}
<Show when={tutorConfig.settings?.brand_logo_light} fallback={<LogoSvg width={108} height={24} />}>
{(logo) => <img src={logo} alt={tutorConfig.site_title} />}
</Show>
</button>
);
Expand Down
2 changes: 1 addition & 1 deletion assets/src/js/v3/shared/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ const defaultTutorConfig = {
instructor_can_change_course_author: 'off',
instructor_can_manage_co_instructors: 'off',
chatgpt_enable: 'off',
course_builder_logo_url: '',
brand_logo_light: '',
chatgpt_key_exist: false,
hide_admin_bar_for_users: 'off',
enable_redirect_on_course_publish_from_frontend: 'off',
Expand Down
2 changes: 2 additions & 0 deletions assets/src/js/v3/shared/icons/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ export const icons = [
'css',
'csv',
'currency',
'dark',
'dbf',
'delete',
'delete2',
Expand Down Expand Up @@ -181,6 +182,7 @@ export const icons = [
'fireDisabled',
'fla',
'font',
'footer',
'freeShippingType',
'gift',
'giftCard',
Expand Down
Loading
Loading