From a50bedf4f6e2157412e0f6d89ebb0f8a0280cb49 Mon Sep 17 00:00:00 2001 From: mohamed mahmoud Date: Mon, 18 May 2026 02:19:30 +0300 Subject: [PATCH 1/3] define the improving role from client side and represent a changes for new version of lesson structure and functionalities --- src/domain/entity/LessonEntity.js | 30 ++ src/domain/mapper/EntityMapper.js | 5 +- src/domain/useCase/useFetchRoles.js | 34 ++ src/domain/useCase/useUpdateUserRole.js | 24 ++ src/infrastructure/DTO/LessonDTO.js | 5 + src/infrastructure/DTO/Request/BaseRequest.js | 16 +- .../DTO/Request/LessonRequest.js | 7 +- .../repository/RoleRepository.js | 19 ++ .../feature/cms/components/CMSActionBar.jsx | 8 +- .../CMSCreateCategorizationModal.jsx | 16 +- .../feature/cms/components/CMSMobileNav.jsx | 12 +- .../cms/components/CMSResourceTable.jsx | 146 ++++---- .../feature/cms/components/CMSSidebar.jsx | 12 +- .../cms/components/CategorizationSelector.jsx | 6 +- .../cms/components/ChangeUserRoleModal.jsx | 133 ++++++++ .../feature/cms/components/EmbedUrlInput.jsx | 213 ++++++++++++ .../cms/components/course/LessonFormModal.jsx | 4 +- .../viewers/CategoryDistributionViewer.jsx | 10 +- .../viewers/ContributorStatsViewer.jsx | 6 +- .../viewers/DefaultDashboardViewer.jsx | 16 +- .../viewers/GrowthTimelineViewer.jsx | 10 +- .../dashboard/widgets/TopContentWidget.jsx | 8 +- .../dashboard/widgets/TrendTagsAnalyzer.jsx | 32 +- .../cms/components/problem/ProblemForm.jsx | 8 +- .../components/problem/ProblemTypesEditor.jsx | 2 +- .../feature/cms/routes/AddLessonPage.jsx | 39 ++- .../cms/routes/CMSAdminNotificationsPage.jsx | 26 +- .../feature/cms/routes/CMSCoursesPage.jsx | 4 +- .../feature/cms/routes/CMSDashboardPage.jsx | 38 +-- .../feature/cms/routes/CMSPage.jsx | 2 +- .../feature/cms/routes/CMSPayoutsPage.jsx | 34 +- .../feature/cms/routes/CMSProblemsPage.jsx | 4 +- .../feature/cms/routes/CMSReportsPage.jsx | 30 +- .../feature/cms/routes/CMSUsersPage.jsx | 36 +- .../feature/cms/routes/EditLessonPage.jsx | 43 ++- .../cms/routes/ReportTypeManagementPage.jsx | 14 +- .../course/components/EmbeddedVideoPlayer.jsx | 312 ++++++++++++++++++ .../course/components/LearnSidebar.jsx | 4 +- .../course/components/LessonViewer.jsx | 12 +- update_theme.cjs | 41 +++ vite.config.js | 10 +- 41 files changed, 1186 insertions(+), 245 deletions(-) create mode 100644 src/domain/useCase/useFetchRoles.js create mode 100644 src/domain/useCase/useUpdateUserRole.js create mode 100644 src/infrastructure/repository/RoleRepository.js create mode 100644 src/presentation/feature/cms/components/ChangeUserRoleModal.jsx create mode 100644 src/presentation/feature/cms/components/EmbedUrlInput.jsx create mode 100644 src/presentation/feature/course/components/EmbeddedVideoPlayer.jsx create mode 100644 update_theme.cjs diff --git a/src/domain/entity/LessonEntity.js b/src/domain/entity/LessonEntity.js index 4f0e7ef..3174ec0 100644 --- a/src/domain/entity/LessonEntity.js +++ b/src/domain/entity/LessonEntity.js @@ -23,6 +23,19 @@ export class LessonEntity extends ContentEntity { this.description = props.description; this.isPublic = props.isPublic || false; this.instructor = props.instructor; + + // Embedded video support + this.embedSource = props.embedSource || null; + this.embedUrl = props.embedUrl || null; + this.embedMetadata = props.embedMetadata || null; + } + + /** + * Checks if the lesson is an embedded external video. + * @returns {boolean} + */ + get isEmbedded() { + return this.type === 'embedded'; } /** @@ -40,4 +53,21 @@ export class LessonEntity extends ContentEntity { get isArticle() { return this.type === 'article'; } + + /** + * Returns the provider-specific embed URL for iframe rendering. + * Falls back to raw embedUrl if metadata is missing. + * @returns {string|null} + */ + get embedPlayerUrl() { + return this.embedMetadata?.embedUrl || this.embedUrl || null; + } + + /** + * Returns the cached thumbnail from the provider. + * @returns {string|null} + */ + get embedThumbnail() { + return this.embedMetadata?.thumbnail || null; + } } diff --git a/src/domain/mapper/EntityMapper.js b/src/domain/mapper/EntityMapper.js index c09eda7..d36bc63 100644 --- a/src/domain/mapper/EntityMapper.js +++ b/src/domain/mapper/EntityMapper.js @@ -131,7 +131,10 @@ export class EntityMapper { description: dto.description, isPublic: dto.public, instructor: this.toUser(dto.instructor), - isDraft: !!dto.isDraft + isDraft: !!dto.isDraft, + embedSource: dto.embedSource, + embedUrl: dto.embedUrl, + embedMetadata: dto.embedMetadata, }); } diff --git a/src/domain/useCase/useFetchRoles.js b/src/domain/useCase/useFetchRoles.js new file mode 100644 index 0000000..33e731e --- /dev/null +++ b/src/domain/useCase/useFetchRoles.js @@ -0,0 +1,34 @@ +import { useAsyncUseCase } from './useAsyncUseCase'; +import { RoleRepository } from '../../infrastructure/repository/RoleRepository'; +import { useMemo, useEffect, useCallback } from 'react'; + +/** + * Hook for managing roles. + */ +export const useFetchRoles = () => { + const repository = useMemo(() => new RoleRepository(), []); + + const fetchRoles = useCallback(async () => { + const rawRoles = await repository.getAllRoles(); + // Return structured roles for selection + return rawRoles.map(role => ({ + id: role.id, + name: role.name, + description: role.description, + type: role.type + })); + }, [repository]); + + const { execute, returnedData, inProgress, error } = useAsyncUseCase(fetchRoles); + + useEffect(() => { + execute(); + }, [execute]); + + return { + roles: returnedData || [], + isLoading: inProgress, + error, + reloadRoles: execute + }; +}; diff --git a/src/domain/useCase/useUpdateUserRole.js b/src/domain/useCase/useUpdateUserRole.js new file mode 100644 index 0000000..fd4aeb9 --- /dev/null +++ b/src/domain/useCase/useUpdateUserRole.js @@ -0,0 +1,24 @@ +import { useAsyncUseCase } from './useAsyncUseCase'; +import { UserRepository } from '../../infrastructure/repository/UserRepository'; +import { useMemo, useCallback } from 'react'; + +/** + * Hook to update a user's role. + */ +export const useUpdateUserRole = () => { + const repository = useMemo(() => new UserRepository(), []); + + const updateRole = useCallback(async ({ userId, roleId }) => { + // We pass the new role ID to the user update endpoint + const updatedUser = await repository.updateUser(userId, { role: roleId }); + return updatedUser; + }, [repository]); + + const { execute, inProgress, error } = useAsyncUseCase(updateRole); + + return { + updateUserRole: execute, + isUpdating: inProgress, + error + }; +}; diff --git a/src/infrastructure/DTO/LessonDTO.js b/src/infrastructure/DTO/LessonDTO.js index 1bdc69e..23af2c5 100644 --- a/src/infrastructure/DTO/LessonDTO.js +++ b/src/infrastructure/DTO/LessonDTO.js @@ -12,6 +12,11 @@ export class LessonDTO extends BaseContentDTO { this.description = data.description; // {object | array} - Blocks this.public = !!data.public; // {boolean} + // Embedded video fields + this.embedSource = data.embed_source || null; // {string | null} - Provider: youtube, vimeo, custom + this.embedUrl = data.embed_url || null; // {string | null} - Raw URL as entered by admin + this.embedMetadata = data.embed_metadata || null; // {object | null} - Cached provider metadata + /** * Relationships */ diff --git a/src/infrastructure/DTO/Request/BaseRequest.js b/src/infrastructure/DTO/Request/BaseRequest.js index 224062f..758f7cb 100644 --- a/src/infrastructure/DTO/Request/BaseRequest.js +++ b/src/infrastructure/DTO/Request/BaseRequest.js @@ -7,14 +7,22 @@ export class BaseRequest { /** * Standard method to format the payload for Strapi. * Also applies security sanitization to prevent XSS and injections. + * Note: embed_* fields are exempted from sanitization because they contain + * URLs with special characters (://) that get destroyed by validator.escape(). + * These fields are validated by the backend provider registry instead. * @returns {object} - Raw fields sanitized */ toJSON() { const payload = {}; + const SANITIZE_EXEMPT = ['embed_url', 'embed_source', 'embed_metadata']; + Object.keys(this).forEach(key => { if (this[key] !== undefined) { - // Apply sanitization to all outgoing data - payload[key] = SecurityUtils.sanitizeData(this[key]); + if (SANITIZE_EXEMPT.includes(key)) { + payload[key] = this[key]; // Pass through raw — validated by backend + } else { + payload[key] = SecurityUtils.sanitizeData(this[key]); + } } }); return payload; @@ -36,9 +44,9 @@ export class BaseRequest { * @throws {Error} if data is invalid or unsafe. */ validate() { - // Security Audit + // Security Audit — skip embed fields (URLs break XSS detection) Object.keys(this).forEach(key => { - if (this[key] !== undefined) { + if (this[key] !== undefined && !key.includes("embed")) { SecurityUtils.validateSafety(this[key]); } }); diff --git a/src/infrastructure/DTO/Request/LessonRequest.js b/src/infrastructure/DTO/Request/LessonRequest.js index ec75c8b..0cf3028 100644 --- a/src/infrastructure/DTO/Request/LessonRequest.js +++ b/src/infrastructure/DTO/Request/LessonRequest.js @@ -7,13 +7,18 @@ export class LessonRequest extends BaseRequest { constructor(data = {}) { super(); this.title = data.title; - this.type_of_lesson = data.type || 'video'; // Enum: video, article + this.type_of_lesson = data.type || 'video'; // Enum: video, article, embedded this.video = data.videoId || null; // {id} this.description = data.description; // {blocks} this.week = data.weekId; // {id | documentId} this.public = !!data.public; this.isDraft = data.isDraft ?? true; + // Embedded video fields (only relevant when type = 'embedded') + this.embed_url = data.embedUrl || data.embed_url || null; + this.embed_source = data.embedSource || data.embed_source || null; + this.embed_metadata = data.embedMetadata || data.embed_metadata || null; + // Strapi v4/v5 relationship arrays this.course_types = data.courseTypeIds; this.problem_types = data.problemTypeIds; diff --git a/src/infrastructure/repository/RoleRepository.js b/src/infrastructure/repository/RoleRepository.js new file mode 100644 index 0000000..d56b4b3 --- /dev/null +++ b/src/infrastructure/repository/RoleRepository.js @@ -0,0 +1,19 @@ +import { BaseRepository } from './BaseRepository'; + +export class RoleRepository extends BaseRepository { + constructor() { + super(); + this.endpoint = '/api/users-permissions/roles'; + } + + async getAllRoles() { + try { + const response = await this.get(this.endpoint); + // Strapi /api/users-permissions/roles usually returns { roles: [...] } + return response?.roles || response?.data || response || []; + } catch (error) { + console.error('[RoleRepository] Get all roles failed:', error); + throw error; + } + } +} diff --git a/src/presentation/feature/cms/components/CMSActionBar.jsx b/src/presentation/feature/cms/components/CMSActionBar.jsx index 615d8fc..ac0ccd0 100644 --- a/src/presentation/feature/cms/components/CMSActionBar.jsx +++ b/src/presentation/feature/cms/components/CMSActionBar.jsx @@ -10,17 +10,17 @@ export const CMSActionBar = ({ onExit }) => {
-
-
+
+
Archive Active
diff --git a/src/presentation/feature/cms/components/CMSCreateCategorizationModal.jsx b/src/presentation/feature/cms/components/CMSCreateCategorizationModal.jsx index bfbe59c..b49b42d 100644 --- a/src/presentation/feature/cms/components/CMSCreateCategorizationModal.jsx +++ b/src/presentation/feature/cms/components/CMSCreateCategorizationModal.jsx @@ -25,25 +25,25 @@ export const CMSCreateCategorizationModal = ({
{/* Backdrop */}
{/* Modal Content */} -
-
+
+
-
+
-

{modalHeader}

+

{modalHeader}

Append new taxonomy node

@@ -60,7 +60,7 @@ export const CMSCreateCategorizationModal = ({ type="text" value={title} onChange={(e) => setTitle(e.target.value)} - className="w-full h-14 px-6 bg-white border border-border-default rounded-2xl font-serif text-sm focus:ring-2 focus:ring-accent-primary/20 focus:border-accent-primary outline-none transition-all shadow-sm" + className="w-full h-14 px-6 bg-surface-elevated border border-border-default rounded-2xl font-serif text-sm focus:ring-2 focus:ring-accent-primary/20 focus:border-accent-primary outline-none transition-all shadow-sm" placeholder="e.g. Advanced Calculus, Backend Systems..." />
@@ -77,7 +77,7 @@ export const CMSCreateCategorizationModal = ({ @@ -238,14 +239,14 @@ export const CMSResourceTable = ({ placeholder={`Query for ${sectionName.toLowerCase()}...`} value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} - className="w-full h-12 md:h-16 pl-14 md:pl-16 pr-6 bg-ivory border border-border-default rounded-2xl md:rounded-[24px] font-serif italic text-xs md:text-sm focus:ring-2 focus:ring-accent-primary/20 focus:border-accent-primary outline-none transition-all shadow-whisper" + className="w-full h-12 md:h-16 pl-14 md:pl-16 pr-6 bg-surface border border-border-default rounded-2xl md:rounded-[24px] font-serif italic text-xs md:text-sm focus:ring-2 focus:ring-accent-primary/20 focus:border-accent-primary outline-none transition-all shadow-whisper" />
{selectedIds.length > 0 && ( -
+
{selectedIds.length} Selections -
+
{/* 3. REPOSITORY LEDGER */} -
+
{/* Ledger Header */} -
+
- - Descriptor + Descriptor
-
+
{columns?.status || 'Shelf Status'} {columns?.metric || 'Engagement'} @@ -278,7 +279,7 @@ export const CMSResourceTable = ({ {isLoading ? (
-

Consulting Archives...

+

Consulting Archives...

) : paginatedItems.length === 0 ? (
@@ -304,25 +305,26 @@ export const CMSResourceTable = ({ key={id} className={cn( "px-3 md:px-10 py-4 md:py-7 transition-all duration-500 group relative", - isSelected ? "bg-parchment/60" : "hover:bg-parchment/30" + isSelected ? "bg-background/60" : "hover:bg-background/30" )} + ref={openDropdownId === id ? dropdownRef : null} > {/* Desktop: horizontal row */}
-
+
-

{title}

+

{title}

Catalogued on {dateStr} • Ref: {id.toString().slice(0, 8)}

@@ -333,26 +335,26 @@ export const CMSResourceTable = ({ {isDraft ? 'Draft' : 'Published'}
-
+
{engagementScore !== null ? `${engagementScore}` : '—'}
-
+
+ ) : ( + + Edit Manuscript + + )}
@@ -406,17 +417,17 @@ export const CMSResourceTable = ({
-

{title}

+

{title}

{dateStr} • {id.toString().slice(0, 8)}

{/* Mobile action button */} -
+
+ ) : ( + + Edit + + )}
-
+
{activePage} / {totalPages}
diff --git a/src/presentation/feature/cms/components/CMSSidebar.jsx b/src/presentation/feature/cms/components/CMSSidebar.jsx index 5a7fbc2..d68724e 100644 --- a/src/presentation/feature/cms/components/CMSSidebar.jsx +++ b/src/presentation/feature/cms/components/CMSSidebar.jsx @@ -23,7 +23,7 @@ export const CMSSidebar = ({ sections, activeSection }) => { className={cn( "w-full flex items-center justify-between py-2.5 px-3 transition-all duration-300 group rounded-xl", activeSection === section.name - ? "bg-near-black text-ivory shadow-lg" + ? "bg-text-primary text-surface shadow-lg" : "text-text-muted hover:bg-surface-sunken hover:text-text-primary" )} > @@ -36,7 +36,7 @@ export const CMSSidebar = ({ sections, activeSection }) => { {section.count} @@ -47,15 +47,15 @@ export const CMSSidebar = ({ sections, activeSection }) => {
-
-

+
+

Storage Vault

-
+

- Capacity used: 42.8 GB of 500 GB + Capacity used: 42.8 GB of 500 GB

diff --git a/src/presentation/feature/cms/components/CategorizationSelector.jsx b/src/presentation/feature/cms/components/CategorizationSelector.jsx index 1f4b337..2891bc4 100644 --- a/src/presentation/feature/cms/components/CategorizationSelector.jsx +++ b/src/presentation/feature/cms/components/CategorizationSelector.jsx @@ -47,7 +47,7 @@ export const CategorizationSelector = ({ selectedItems.map(item => (
{item.title || item.type}
@@ -123,7 +123,7 @@ export const CategorizationSelector = ({ diff --git a/src/presentation/feature/cms/components/ChangeUserRoleModal.jsx b/src/presentation/feature/cms/components/ChangeUserRoleModal.jsx new file mode 100644 index 0000000..8d79e71 --- /dev/null +++ b/src/presentation/feature/cms/components/ChangeUserRoleModal.jsx @@ -0,0 +1,133 @@ +import React, { useState, useEffect } from 'react'; +import { ShieldCheck, X, Loader2, Save } from 'lucide-react'; +import { cn } from '@core/utils/cn'; +import { useFetchRoles } from '@domain/useCase/useFetchRoles'; +import { useUpdateUserRole } from '@domain/useCase/useUpdateUserRole'; + +export const ChangeUserRoleModal = ({ user, onClose, onSuccess }) => { + const { roles, isLoading: isLoadingRoles } = useFetchRoles(); + const { updateUserRole, isUpdating } = useUpdateUserRole(); + const [selectedRole, setSelectedRole] = useState(null); + + useEffect(() => { + if (roles.length > 0 && user?.role?.id) { + setSelectedRole(user.role.id); + } + }, [roles, user]); + + const handleSubmit = async () => { + if (!selectedRole || selectedRole === user?.role?.id) return; + + try { + await updateUserRole({ userId: user.id, roleId: selectedRole }); + onSuccess(); + } catch (error) { + console.error("Failed to update role", error); + } + }; + + const hasChanged = selectedRole && selectedRole !== user?.role?.id; + + return ( +
+
+ +
+ {/* Header */} +
+
+
+ +
+
+

User Privileges

+

Adjust role for {user?.username}

+
+
+ +
+ + {/* Body */} +
+ {isLoadingRoles ? ( +
+ + Fetching Roles... +
+ ) : ( +
+ + +
+ {roles.map((role) => ( + + ))} +
+
+ )} +
+ + {/* Footer */} +
+ + +
+
+
+ ); +}; diff --git a/src/presentation/feature/cms/components/EmbedUrlInput.jsx b/src/presentation/feature/cms/components/EmbedUrlInput.jsx new file mode 100644 index 0000000..bbd99eb --- /dev/null +++ b/src/presentation/feature/cms/components/EmbedUrlInput.jsx @@ -0,0 +1,213 @@ +import React, { useState, useEffect, useMemo, useRef } from 'react'; +import { Link as LinkIcon, Loader2, ExternalLink, Youtube, MonitorPlay } from 'lucide-react'; +import { cn } from '@core/utils/cn'; + +/** + * EmbedUrlInput: URL input with live video preview. + * + * Detects provider from URL, shows iframe preview after debounce, + * and displays thumbnail from provider (no oEmbed fetch — CORS-free). + * Full metadata is resolved by the backend on save. + * + * @param {string} value - Current URL value + * @param {Function} onChange - URL change callback + */ + +const PROVIDERS = { + youtube: { + name: 'YouTube', + color: 'red', + match: (url) => /(?:youtube\.com\/(?:watch\?v=|embed\/|shorts\/)|youtu\.be\/)/.test(url), + extractId: (url) => { + const patterns = [ + /(?:youtube\.com\/watch\?v=)([a-zA-Z0-9_-]{11})/, + /(?:youtu\.be\/)([a-zA-Z0-9_-]{11})/, + /(?:youtube\.com\/embed\/)([a-zA-Z0-9_-]{11})/, + /(?:youtube\.com\/shorts\/)([a-zA-Z0-9_-]{11})/, + ]; + for (const p of patterns) { + const m = url.match(p); + if (m) return m[1]; + } + return null; + }, + getEmbedUrl: (id) => `https://www.youtube.com/embed/${id}`, + getThumbnail: (id) => `https://img.youtube.com/vi/${id}/hqdefault.jpg`, + }, + vimeo: { + name: 'Vimeo', + color: 'blue', + match: (url) => /vimeo\.com\/\d+/.test(url), + extractId: (url) => { + const m = url.match(/vimeo\.com\/(\d+)/); + return m ? m[1] : null; + }, + getEmbedUrl: (id) => `https://player.vimeo.com/video/${id}`, + getThumbnail: () => null, + }, +}; + +function detectProvider(url) { + if (!url) return null; + for (const [key, provider] of Object.entries(PROVIDERS)) { + if (provider.match(url)) return { key, ...provider }; + } + return null; +} + +export const EmbedUrlInput = ({ value, onChange }) => { + const [previewData, setPreviewData] = useState(null); + const previewDebounceRef = useRef(null); + + // Instant detection for badge only (no iframe) + const detected = useMemo(() => { + const provider = detectProvider(value); + if (!provider) return null; + const videoId = provider.extractId(value); + if (!videoId) return null; + return { + ...provider, + videoId, + embedUrl: provider.getEmbedUrl(videoId), + thumbnail: provider.getThumbnail(videoId), + }; + }, [value]); + + // Debounced preview: only render iframe after user stops typing for 800ms + useEffect(() => { + if (previewDebounceRef.current) clearTimeout(previewDebounceRef.current); + + if (!detected) { + setPreviewData(null); + return; + } + + previewDebounceRef.current = setTimeout(() => { + setPreviewData(detected); + }, 800); + + return () => { + if (previewDebounceRef.current) clearTimeout(previewDebounceRef.current); + }; + }, [detected?.videoId]); + + const providerColors = { + red: { + badge: 'bg-red-500/10 border-red-500/20 text-red-500', + }, + blue: { + badge: 'bg-blue-400/10 border-blue-400/20 text-blue-400', + }, + }; + + const colors = detected ? providerColors[detected.color] || providerColors.blue : null; + + return ( +
+ + + {/* URL Input */} +
+
+ +
+ onChange(e.target.value)} + placeholder="https://youtube.com/watch?v=... or https://vimeo.com/..." + className="w-full bg-surface-sunken/40 border border-border-subtle rounded-2xl pl-12 pr-6 py-5 text-sm font-bold text-text-primary focus:border-blue-500 focus:bg-surface-sunken outline-none transition-all placeholder:text-text-muted/20 shadow-inner" + /> +
+ + {/* Provider Badge */} + {value && ( +
+ + {detected ? `▶ ${detected.name} Detected` : '▶ Custom Source'} + + {detected && !previewData && ( + + Loading preview... + + )} +
+ )} + + {/* Loading state: provider detected but waiting for debounce */} + {detected && !previewData && ( +
+ +

Loading preview...

+
+ )} + + {/* Live Preview — only after debounce completes */} + {previewData && ( +
+ {/* Video iframe */} +
+