feat: LTI support and Moodle plugin

This commit is contained in:
Markos Gogoulos
2026-05-11 12:47:09 +03:00
committed by GitHub
parent b7427869b6
commit 55ab7ff34f
307 changed files with 19966 additions and 3748 deletions
@@ -45,12 +45,14 @@ export const LayoutProvider = ({ children }) => {
const site = useContext(SiteContext);
const cache = new BrowserCache('MediaCMS[' + site.id + '][layout]', 86400);
const isMediaPage = useMemo(() => PageStore.get('current-page') === 'media', []);
const isMediaPage = useMemo(() => PageStore.get('current-page') === 'media' || window.MediaCMS?.mediaId !== undefined, []);
const isEmbeddedApp = useMemo(() => inEmbeddedApp(), []);
const enabledSidebar = Boolean(document.getElementById('app-sidebar') || document.querySelector('.page-sidebar'));
const [visibleSidebar, setVisibleSidebar] = useState(cache.get('visible-sidebar'));
const [visibleSidebar, setVisibleSidebar] = useState(
isMediaPage || isEmbeddedApp ? false : cache.get('visible-sidebar')
);
const [visibleMobileSearch, setVisibleMobileSearch] = useState(false);
const toggleMobileSearch = () => {
@@ -3,18 +3,100 @@ export function inEmbeddedApp() {
const params = new URL(globalThis.location.href).searchParams;
const mode = params.get('mode');
if (mode === 'embed_mode') {
sessionStorage.setItem('media_cms_embed_mode', 'true');
if (mode === 'lms_embed_mode') {
sessionStorage.setItem('lms_embed_mode', 'true');
return true;
}
if (mode === 'standard') {
sessionStorage.removeItem('media_cms_embed_mode');
sessionStorage.removeItem('lms_embed_mode');
return false;
}
return sessionStorage.getItem('media_cms_embed_mode') === 'true';
return sessionStorage.getItem('lms_embed_mode') === 'true';
} catch (e) {
return false;
}
}
export function isShareMediaDisabled(): boolean {
try {
const params = new URL(globalThis.location.href).searchParams;
const shareMedia = params.get('share_media');
const mode = params.get('mode');
if (shareMedia === '0') {
sessionStorage.setItem('lms_share_media_disabled', 'true');
return true;
}
// Fresh LTI landing (mode=lms_embed_mode in URL) without share_media=0
// means sharing is enabled — clear any stale disabled flag.
if (shareMedia === '1' || mode === 'lms_embed_mode') {
sessionStorage.removeItem('lms_share_media_disabled');
return false;
}
return sessionStorage.getItem('lms_share_media_disabled') === 'true';
} catch (e) {
return false;
}
}
export function isSelectMediaMode() {
try {
const params = new URL(globalThis.location.href).searchParams;
const action = params.get('action');
return action === 'select_media';
} catch (e) {
return false;
}
}
export function inSelectMediaEmbedMode() {
return inEmbeddedApp() && isSelectMediaMode();
}
// When MediaCMS is embedded inside a host platform (e.g. an LMS), the host passes a
// `parent_media_base` URL via LTI custom params so that media title links in the embed
// player navigate the parent frame to the host's own media viewer (e.g. Moodle My Media)
// instead of opening a bare MediaCMS URL. The VideoViewer appends `?token=<friendly_token>`
// and uses `target="_parent"` to perform the navigation.
export function getParentMediaBase(): string | null {
try {
const params = new URL(globalThis.location.href).searchParams;
const mode = params.get('mode');
const base = params.get('parent_media_base');
if (mode === 'standard') {
sessionStorage.removeItem('parent_media_base');
return null;
}
if (base) {
sessionStorage.setItem('parent_media_base', base);
return base;
}
return sessionStorage.getItem('parent_media_base');
} catch (e) {
return null;
}
}
export function getLtiContextId(): string | null {
try {
const params = new URL(globalThis.location.href).searchParams;
const contextId = params.get('lti_context_id');
if (contextId) {
sessionStorage.setItem('lti_context_id', contextId);
return contextId;
}
return sessionStorage.getItem('lti_context_id');
} catch (e) {
return null;
}
}
@@ -14,4 +14,4 @@ export * from './quickSort';
export * from './requests';
export { translateString } from './translate';
export { replaceString } from './replacementStrings';
export * from './embeddedApp';
export { getParentMediaBase, inEmbeddedApp, inSelectMediaEmbedMode, isSelectMediaMode, isShareMediaDisabled } from './embeddedApp';
@@ -1,5 +1,5 @@
// check templates/config/installation/translations.html for more
export function translateString(str) {
return window.TRANSLATION?.[str] ?? str;
return window.TRANSLATION?.[str] || str;
}
@@ -1,5 +1,6 @@
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { translateString } from '../helpers';
import { inEmbeddedApp } from '../helpers/embeddedApp';
/**
* Custom hook for managing bulk actions on media items
@@ -22,6 +23,20 @@ export function useBulkActions() {
const [showPublishStateModal, setShowPublishStateModal] = useState(false);
const [showCategoryModal, setShowCategoryModal] = useState(false);
const [showTagModal, setShowTagModal] = useState(false);
const [showCourseCleanupModal, setShowCourseCleanupModal] = useState(false);
const [hasContributorCourses, setHasContributorCourses] = useState(false);
useEffect(() => {
if (!inEmbeddedApp()) return;
fetch('/api/v1/categories/contributor?lms_courses_only=true')
.then((r) => r.ok ? r.json() : null)
.then((data) => {
if (!data) return;
const courses = data.results || data;
setHasContributorCourses(Array.isArray(courses) && courses.length > 0);
})
.catch(() => {});
}, []);
// Get CSRF token from cookies
const getCsrfToken = () => {
@@ -95,6 +110,11 @@ export function useBulkActions() {
const handleBulkAction = (action) => {
const selectedCount = selectedMedia.size;
if (action === 'course-cleanup') {
setShowCourseCleanupModal(true);
return;
}
if (selectedCount === 0) {
return;
}
@@ -111,6 +131,10 @@ export function useBulkActions() {
setShowConfirmModal(true);
setPendingAction(action);
setConfirmMessage(translateString('You are going to disable comments to') + ` ${selectedCount} ` + translateString('media, are you sure?'));
} else if (action === 'delete-comments') {
setShowConfirmModal(true);
setPendingAction(action);
setConfirmMessage(translateString('You are going to delete all comments from') + ` ${selectedCount} ` + translateString('media, are you sure?'));
} else if (action === 'enable-download') {
setShowConfirmModal(true);
setPendingAction(action);
@@ -165,6 +189,8 @@ export function useBulkActions() {
executeEnableComments();
} else if (action === 'disable-comments') {
executeDisableComments();
} else if (action === 'delete-comments') {
executeDeleteComments();
} else if (action === 'enable-download') {
executeEnableDownload();
} else if (action === 'disable-download') {
@@ -271,6 +297,37 @@ export function useBulkActions() {
});
};
// Execute delete comments
const executeDeleteComments = () => {
const selectedIds = Array.from(selectedMedia);
fetch('/api/v1/media/user/bulk_actions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': getCsrfToken(),
},
body: JSON.stringify({
action: 'delete_comments',
media_ids: selectedIds,
}),
})
.then((response) => {
if (!response.ok) {
throw new Error('Failed to delete comments');
}
return response.json();
})
.then((data) => {
showNotificationMessage(translateString('Successfully deleted comments'));
clearSelection();
})
.catch((error) => {
showNotificationMessage(translateString('Failed to delete comments.'), 'error');
clearSelection();
});
};
// Execute enable download
const executeEnableDownload = () => {
const selectedIds = Array.from(selectedMedia);
@@ -463,6 +520,22 @@ export function useBulkActions() {
setShowTagModal(false);
};
// Course cleanup modal handlers
const handleCourseCleanupModalCancel = () => {
setShowCourseCleanupModal(false);
};
const handleCourseCleanupModalSuccess = (message) => {
showNotificationMessage(message);
clearSelectionAndRefresh();
setShowCourseCleanupModal(false);
};
const handleCourseCleanupModalError = (message) => {
showNotificationMessage(message, 'error');
setShowCourseCleanupModal(false);
};
return {
// State
selectedMedia,
@@ -480,6 +553,8 @@ export function useBulkActions() {
showPublishStateModal,
showCategoryModal,
showTagModal,
showCourseCleanupModal,
hasContributorCourses,
// Handlers
handleMediaSelection,
@@ -507,6 +582,9 @@ export function useBulkActions() {
handleTagModalCancel,
handleTagModalSuccess,
handleTagModalError,
handleCourseCleanupModalCancel,
handleCourseCleanupModalSuccess,
handleCourseCleanupModalError,
// Utility
getCsrfToken,
@@ -195,13 +195,18 @@ class MediaPageStore extends EventEmitter {
this.emit('loaded_media_data');
}
this.loadPlaylists();
if (MediaCMS.features.media.actions.comment_mention === true) {
this.loadUsers();
}
// Skip loading playlists and comments when in embed mode (to reduce API calls)
const isEmbedMode = window.location.pathname.startsWith('/embed');
if (this.mediacms_config.member.can.readComment) {
this.loadComments();
if (!isEmbedMode) {
this.loadPlaylists();
if (MediaCMS.features.media.actions.comment_mention === true) {
this.loadUsers();
}
if (this.mediacms_config.member.can.readComment) {
this.loadComments();
}
}
}
@@ -882,7 +887,7 @@ class MediaPageStore extends EventEmitter {
submitCommentResponse(response) {
if (response && 201 === response.status && response.data && Object.keys(response.data)) {
MediaPageStoreData[this.id].comments.push(response.data);
MediaPageStoreData[this.id].comments.unshift(response.data);
this.emit('comment_submit', response.data.uid);
}
setTimeout(