mirror of
https://github.com/AustinKelsay/plebdevs.git
synced 2025-06-03 07:42:03 +00:00
improve course page architecture with custom hooks and components
This commit is contained in:
parent
51cd1e4d97
commit
027bf28e2f
@ -19,7 +19,7 @@ import { ProgressSpinner } from 'primereact/progressspinner';
|
||||
import { Toast } from 'primereact/toast';
|
||||
|
||||
// Import the desktop and mobile components
|
||||
import DesktopCourseDetails from './DesktopCourseDetails';
|
||||
import DesktopCourseDetails from '@/components/content/courses/details/DesktopCourseDetails';
|
||||
import MobileCourseDetails from './MobileCourseDetails';
|
||||
|
||||
export default function CourseDetails({
|
82
src/components/content/courses/tabs/CourseContent.js
Normal file
82
src/components/content/courses/tabs/CourseContent.js
Normal file
@ -0,0 +1,82 @@
|
||||
import React from 'react';
|
||||
import VideoLesson from '@/components/content/courses/lessons/VideoLesson';
|
||||
import DocumentLesson from '@/components/content/courses/lessons/DocumentLesson';
|
||||
import CombinedLesson from '@/components/content/courses/lessons/CombinedLesson';
|
||||
import MarkdownDisplay from '@/components/markdown/MarkdownDisplay';
|
||||
|
||||
/**
|
||||
* Component to display course content including lessons
|
||||
*/
|
||||
const CourseContent = ({
|
||||
lessons,
|
||||
activeIndex,
|
||||
course,
|
||||
paidCourse,
|
||||
decryptedLessonIds,
|
||||
setCompleted
|
||||
}) => {
|
||||
const renderLesson = (lesson) => {
|
||||
if (!lesson) return null;
|
||||
|
||||
// Check if this specific lesson is decrypted
|
||||
const lessonDecrypted = !paidCourse || decryptedLessonIds[lesson.id] || false;
|
||||
|
||||
if (lesson.topics?.includes('video') && lesson.topics?.includes('document')) {
|
||||
return (
|
||||
<CombinedLesson
|
||||
lesson={lesson}
|
||||
course={course}
|
||||
decryptionPerformed={lessonDecrypted}
|
||||
isPaid={paidCourse}
|
||||
setCompleted={setCompleted}
|
||||
/>
|
||||
);
|
||||
} else if (lesson.type === 'video' && !lesson.topics?.includes('document')) {
|
||||
return (
|
||||
<VideoLesson
|
||||
lesson={lesson}
|
||||
course={course}
|
||||
decryptionPerformed={lessonDecrypted}
|
||||
isPaid={paidCourse}
|
||||
setCompleted={setCompleted}
|
||||
/>
|
||||
);
|
||||
} else if (lesson.type === 'document' && !lesson.topics?.includes('video')) {
|
||||
return (
|
||||
<DocumentLesson
|
||||
lesson={lesson}
|
||||
course={course}
|
||||
decryptionPerformed={lessonDecrypted}
|
||||
isPaid={paidCourse}
|
||||
setCompleted={setCompleted}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{lessons.length > 0 && lessons[activeIndex] ? (
|
||||
<div className="bg-gray-800 rounded-lg shadow-sm overflow-hidden">
|
||||
<div key={`lesson-${lessons[activeIndex].id}`}>
|
||||
{renderLesson(lessons[activeIndex])}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center bg-gray-800 rounded-lg p-8">
|
||||
<p>Select a lesson from the sidebar to begin learning.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{course?.content && (
|
||||
<div className="mt-8 bg-gray-800 rounded-lg shadow-sm">
|
||||
<MarkdownDisplay content={course.content} className="p-4 rounded-lg" />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default CourseContent;
|
58
src/components/content/courses/tabs/CourseOverview.js
Normal file
58
src/components/content/courses/tabs/CourseOverview.js
Normal file
@ -0,0 +1,58 @@
|
||||
import React from 'react';
|
||||
import { Tag } from 'primereact/tag';
|
||||
import CourseDetails from '../details/CourseDetails';
|
||||
|
||||
/**
|
||||
* Component to display course overview with details
|
||||
*/
|
||||
const CourseOverview = ({
|
||||
course,
|
||||
paidCourse,
|
||||
lessons,
|
||||
decryptionPerformed,
|
||||
handlePaymentSuccess,
|
||||
handlePaymentError,
|
||||
isMobileView,
|
||||
completedLessons
|
||||
}) => {
|
||||
// Determine if course is completed
|
||||
const isCompleted = completedLessons.length > 0;
|
||||
|
||||
return (
|
||||
<div className={`bg-gray-800 rounded-lg border border-gray-800 shadow-md ${isMobileView ? 'p-4' : 'p-6'}`}>
|
||||
{isMobileView && course && (
|
||||
<div className="mb-2">
|
||||
{/* Completed tag above image in mobile view */}
|
||||
{isCompleted && (
|
||||
<div className="mb-2">
|
||||
<Tag severity="success" value="Completed" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Course image */}
|
||||
{course.image && (
|
||||
<div className="w-full h-48 relative rounded-lg overflow-hidden mb-3">
|
||||
<img
|
||||
src={course.image}
|
||||
alt={course.title}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<CourseDetails
|
||||
processedEvent={course}
|
||||
paidCourse={paidCourse}
|
||||
lessons={lessons}
|
||||
decryptionPerformed={decryptionPerformed}
|
||||
handlePaymentSuccess={handlePaymentSuccess}
|
||||
handlePaymentError={handlePaymentError}
|
||||
isMobileView={isMobileView}
|
||||
showCompletedTag={!isMobileView}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CourseOverview;
|
32
src/components/content/courses/tabs/CourseQA.js
Normal file
32
src/components/content/courses/tabs/CourseQA.js
Normal file
@ -0,0 +1,32 @@
|
||||
import React from 'react';
|
||||
import ZapThreadsWrapper from '@/components/ZapThreadsWrapper';
|
||||
|
||||
/**
|
||||
* Component to display course comments and Q&A section
|
||||
*/
|
||||
const CourseQA = ({ nAddress, isAuthorized, nsec, npub }) => {
|
||||
return (
|
||||
<div className="rounded-lg p-8 mt-4 bg-gray-800 max-mob:px-4">
|
||||
<h2 className="text-xl font-bold mb-4">Comments</h2>
|
||||
{nAddress !== null && isAuthorized ? (
|
||||
<div className="px-4 max-mob:px-0">
|
||||
<ZapThreadsWrapper
|
||||
anchor={nAddress}
|
||||
user={nsec || npub || null}
|
||||
relays="wss://nos.lol/, wss://relay.damus.io/, wss://relay.snort.social/, wss://relay.nostr.band/, wss://relay.primal.net/, wss://nostrue.com/, wss://purplerelay.com/, wss://relay.devs.tools/"
|
||||
disable="zaps"
|
||||
isAuthorized={isAuthorized}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center p-4 mx-4 bg-gray-800/50 rounded-lg">
|
||||
<p className="text-gray-400">
|
||||
Comments are only available to content purchasers, subscribers, and the content creator.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CourseQA;
|
@ -8,7 +8,7 @@ import { useSession } from 'next-auth/react';
|
||||
import 'primereact/resources/primereact.min.css';
|
||||
import 'primeicons/primeicons.css';
|
||||
import useWindowWidth from '@/hooks/useWindowWidth';
|
||||
import CourseHeader from '../content/courses/CourseHeader';
|
||||
import CourseHeader from '../content/courses/layout/CourseHeader';
|
||||
import { useNDKContext } from '@/context/NDKContext';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import { parseCourseEvent } from '@/utils/nostr';
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { parseCourseEvent } from '@/utils/nostr';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import { useToast } from '../useToast';
|
||||
import { parseCourseEvent } from '@/utils/nostr';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
|
||||
/**
|
||||
* Hook to fetch and manage course data
|
||||
@ -21,7 +21,8 @@ const useCourseData = (ndk, fetchAuthor, router) => {
|
||||
if (!router.isReady) return;
|
||||
|
||||
const { slug } = router.query;
|
||||
|
||||
let id;
|
||||
|
||||
const fetchCourseId = async () => {
|
||||
if (slug.includes('naddr')) {
|
||||
const { data } = nip19.decode(slug);
|
||||
@ -54,7 +55,7 @@ const useCourseData = (ndk, fetchAuthor, router) => {
|
||||
|
||||
const initializeCourse = async () => {
|
||||
setLoading(true);
|
||||
const id = await fetchCourseId();
|
||||
id = await fetchCourseId();
|
||||
if (!id) {
|
||||
setLoading(false);
|
||||
return;
|
||||
|
142
src/hooks/courses/useCourseNavigation.js
Normal file
142
src/hooks/courses/useCourseNavigation.js
Normal file
@ -0,0 +1,142 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
|
||||
/**
|
||||
* Hook to manage course navigation and tab logic
|
||||
* @param {Object} router - Next.js router instance
|
||||
* @param {Boolean} isMobileView - Whether the current view is mobile
|
||||
* @returns {Object} Navigation state and functions
|
||||
*/
|
||||
const useCourseNavigation = (router, isMobileView) => {
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const [sidebarVisible, setSidebarVisible] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState('overview'); // Default to overview tab
|
||||
|
||||
// Memoized function to get the tab map based on view mode
|
||||
const tabMap = useMemo(() => {
|
||||
const baseTabMap = ['overview', 'content', 'qa'];
|
||||
if (isMobileView) {
|
||||
const mobileTabMap = [...baseTabMap];
|
||||
mobileTabMap.splice(2, 0, 'lessons');
|
||||
return mobileTabMap;
|
||||
}
|
||||
return baseTabMap;
|
||||
}, [isMobileView]);
|
||||
|
||||
// Initialize navigation state based on router
|
||||
useEffect(() => {
|
||||
if (router.isReady) {
|
||||
const { active } = router.query;
|
||||
if (active !== undefined) {
|
||||
setActiveIndex(parseInt(active, 10));
|
||||
// If we have an active lesson, switch to content tab
|
||||
setActiveTab('content');
|
||||
} else {
|
||||
setActiveIndex(0);
|
||||
// Default to overview tab when no active parameter
|
||||
setActiveTab('overview');
|
||||
}
|
||||
|
||||
// Auto-open sidebar on desktop, close on mobile
|
||||
setSidebarVisible(!isMobileView);
|
||||
}
|
||||
}, [router.isReady, router.query, isMobileView]);
|
||||
|
||||
// Function to handle lesson selection
|
||||
const handleLessonSelect = (index) => {
|
||||
setActiveIndex(index);
|
||||
router.push(`/course/${router.query.slug}?active=${index}`, undefined, { shallow: true });
|
||||
|
||||
// On mobile, switch to content tab after selection
|
||||
if (isMobileView) {
|
||||
setActiveTab('content');
|
||||
setSidebarVisible(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Function to toggle tab
|
||||
const toggleTab = (index) => {
|
||||
const tabName = tabMap[index];
|
||||
setActiveTab(tabName);
|
||||
|
||||
// Only show/hide sidebar on mobile - desktop keeps sidebar visible
|
||||
if (isMobileView) {
|
||||
setSidebarVisible(tabName === 'lessons');
|
||||
}
|
||||
};
|
||||
|
||||
// Function to toggle sidebar visibility
|
||||
const toggleSidebar = () => {
|
||||
setSidebarVisible(!sidebarVisible);
|
||||
};
|
||||
|
||||
// Map active tab name back to index for MenuTab
|
||||
const getActiveTabIndex = () => {
|
||||
return tabMap.indexOf(activeTab);
|
||||
};
|
||||
|
||||
// Create tab items for MenuTab
|
||||
const getTabItems = () => {
|
||||
const items = [
|
||||
{
|
||||
label: 'Overview',
|
||||
icon: 'pi pi-home',
|
||||
},
|
||||
{
|
||||
label: 'Content',
|
||||
icon: 'pi pi-book',
|
||||
}
|
||||
];
|
||||
|
||||
// Add lessons tab only on mobile
|
||||
if (isMobileView) {
|
||||
items.push({
|
||||
label: 'Lessons',
|
||||
icon: 'pi pi-list',
|
||||
});
|
||||
}
|
||||
|
||||
items.push({
|
||||
label: 'Comments',
|
||||
icon: 'pi pi-comments',
|
||||
});
|
||||
|
||||
return items;
|
||||
};
|
||||
|
||||
// Add keyboard navigation support for tabs
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.key === 'ArrowRight') {
|
||||
const currentIndex = getActiveTabIndex();
|
||||
const nextIndex = (currentIndex + 1) % tabMap.length;
|
||||
toggleTab(nextIndex);
|
||||
} else if (e.key === 'ArrowLeft') {
|
||||
const currentIndex = getActiveTabIndex();
|
||||
const prevIndex = (currentIndex - 1 + tabMap.length) % tabMap.length;
|
||||
toggleTab(prevIndex);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [activeTab, tabMap]);
|
||||
|
||||
return {
|
||||
activeIndex,
|
||||
setActiveIndex,
|
||||
activeTab,
|
||||
setActiveTab,
|
||||
sidebarVisible,
|
||||
setSidebarVisible,
|
||||
handleLessonSelect,
|
||||
toggleTab,
|
||||
toggleSidebar,
|
||||
getActiveTabIndex,
|
||||
getTabItems,
|
||||
tabMap
|
||||
};
|
||||
};
|
||||
|
||||
export default useCourseNavigation;
|
@ -30,7 +30,7 @@ const useLessons = (ndk, fetchAuthor, lessonIds, pubkey) => {
|
||||
const events = await ndk.fetchEvents(filter);
|
||||
const newLessons = [];
|
||||
|
||||
// Process events
|
||||
// Process events (no need to check for duplicates here)
|
||||
for (const event of events) {
|
||||
const author = await fetchAuthor(event.pubkey);
|
||||
const parsedLesson = { ...parseEvent(event), author };
|
||||
@ -47,7 +47,7 @@ const useLessons = (ndk, fetchAuthor, lessonIds, pubkey) => {
|
||||
}
|
||||
}, [lessonIds, ndk, fetchAuthor, pubkey]);
|
||||
|
||||
// Deduplicate lessons
|
||||
// Keep this deduplication logic using Map
|
||||
useEffect(() => {
|
||||
const newUniqueLessons = Array.from(
|
||||
new Map(lessons.map(lesson => [lesson.id, lesson])).values()
|
||||
|
@ -2,8 +2,8 @@ import React, { useEffect, useState, useCallback } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import axios from 'axios';
|
||||
import { parseEvent, findKind0Fields } from '@/utils/nostr';
|
||||
import DraftCourseDetails from '@/components/content/courses/DraftCourseDetails';
|
||||
import DraftCourseLesson from '@/components/content/courses/DraftCourseLesson';
|
||||
import DraftCourseDetails from '@/components/content/courses/details/DraftCourseDetails';
|
||||
import DraftCourseLesson from '@/components/content/courses/details/DraftCourseLesson';
|
||||
import { useNDKContext } from '@/context/NDKContext';
|
||||
import { useSession } from 'next-auth/react';
|
||||
import { useIsAdmin } from '@/hooks/useIsAdmin';
|
||||
|
@ -1,168 +1,55 @@
|
||||
import React, { useEffect, useState, useCallback, useMemo, useRef } from 'react';
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { parseCourseEvent, parseEvent, findKind0Fields } from '@/utils/nostr';
|
||||
import CourseDetails from '@/components/content/courses/CourseDetails';
|
||||
import VideoLesson from '@/components/content/courses/VideoLesson';
|
||||
import DocumentLesson from '@/components/content/courses/DocumentLesson';
|
||||
import CombinedLesson from '@/components/content/courses/CombinedLesson';
|
||||
import CourseSidebar from '@/components/content/courses/CourseSidebar';
|
||||
import { findKind0Fields } from '@/utils/nostr';
|
||||
import { useNDKContext } from '@/context/NDKContext';
|
||||
import { useSession } from 'next-auth/react';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { ProgressSpinner } from 'primereact/progressspinner';
|
||||
import { useDecryptContent } from '@/hooks/encryption/useDecryptContent';
|
||||
|
||||
// Hooks
|
||||
import useCourseDecryption from '@/hooks/encryption/useCourseDecryption';
|
||||
import ZapThreadsWrapper from '@/components/ZapThreadsWrapper';
|
||||
import appConfig from '@/config/appConfig';
|
||||
import useCourseData from '@/hooks/courses/useCourseData';
|
||||
import useLessons from '@/hooks/courses/useLessons';
|
||||
import useCourseNavigation from '@/hooks/courses/useCourseNavigation';
|
||||
import useWindowWidth from '@/hooks/useWindowWidth';
|
||||
|
||||
// Components
|
||||
import CourseSidebar from '@/components/content/courses/layout/CourseSidebar';
|
||||
import CourseContent from '@/components/content/courses/tabs/CourseContent';
|
||||
import CourseQA from '@/components/content/courses/tabs/CourseQA';
|
||||
import CourseOverview from '@/components/content/courses/tabs/CourseOverview';
|
||||
import MenuTab from '@/components/menutab/MenuTab';
|
||||
import { Tag } from 'primereact/tag';
|
||||
import MarkdownDisplay from '@/components/markdown/MarkdownDisplay';
|
||||
|
||||
const useCourseData = (ndk, fetchAuthor, router) => {
|
||||
const [course, setCourse] = useState(null);
|
||||
const [lessonIds, setLessonIds] = useState([]);
|
||||
const [paidCourse, setPaidCourse] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const { showToast } = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
if (!router.isReady) return;
|
||||
|
||||
const { slug } = router.query;
|
||||
let id;
|
||||
|
||||
const fetchCourseId = async () => {
|
||||
if (slug.includes('naddr')) {
|
||||
const { data } = nip19.decode(slug);
|
||||
if (!data?.identifier) {
|
||||
showToast('error', 'Error', 'Resource not found');
|
||||
return null;
|
||||
}
|
||||
return data.identifier;
|
||||
} else {
|
||||
return slug;
|
||||
}
|
||||
};
|
||||
|
||||
const fetchCourse = async courseId => {
|
||||
try {
|
||||
await ndk.connect();
|
||||
const event = await ndk.fetchEvent({ '#d': [courseId] });
|
||||
if (!event) return null;
|
||||
|
||||
const author = await fetchAuthor(event.pubkey);
|
||||
const lessonIds = event.tags.filter(tag => tag[0] === 'a').map(tag => tag[1].split(':')[2]);
|
||||
|
||||
const parsedCourse = { ...parseCourseEvent(event), author };
|
||||
return { parsedCourse, lessonIds };
|
||||
} catch (error) {
|
||||
console.error('Error fetching event:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const initializeCourse = async () => {
|
||||
setLoading(true);
|
||||
id = await fetchCourseId();
|
||||
if (!id) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const courseData = await fetchCourse(id);
|
||||
if (courseData) {
|
||||
const { parsedCourse, lessonIds } = courseData;
|
||||
setCourse(parsedCourse);
|
||||
setLessonIds(lessonIds);
|
||||
setPaidCourse(parsedCourse.price && parsedCourse.price > 0);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
initializeCourse();
|
||||
}, [router.isReady, router.query, ndk, fetchAuthor, showToast]);
|
||||
|
||||
return { course, lessonIds, paidCourse, loading };
|
||||
};
|
||||
|
||||
const useLessons = (ndk, fetchAuthor, lessonIds, pubkey) => {
|
||||
const [lessons, setLessons] = useState([]);
|
||||
const [uniqueLessons, setUniqueLessons] = useState([]);
|
||||
const { showToast } = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
if (lessonIds.length > 0 && pubkey) {
|
||||
const fetchLessons = async () => {
|
||||
try {
|
||||
await ndk.connect();
|
||||
|
||||
// Create a single filter with all lesson IDs to avoid multiple calls
|
||||
const filter = {
|
||||
'#d': lessonIds,
|
||||
kinds: [30023, 30402],
|
||||
authors: [pubkey],
|
||||
};
|
||||
|
||||
const events = await ndk.fetchEvents(filter);
|
||||
const newLessons = [];
|
||||
|
||||
// Process events (no need to check for duplicates here)
|
||||
for (const event of events) {
|
||||
const author = await fetchAuthor(event.pubkey);
|
||||
const parsedLesson = { ...parseEvent(event), author };
|
||||
newLessons.push(parsedLesson);
|
||||
}
|
||||
|
||||
setLessons(newLessons);
|
||||
} catch (error) {
|
||||
console.error('Error fetching events:', error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchLessons();
|
||||
}
|
||||
}, [lessonIds, ndk, fetchAuthor, pubkey]);
|
||||
|
||||
// Keep this deduplication logic using Map
|
||||
useEffect(() => {
|
||||
const newUniqueLessons = Array.from(
|
||||
new Map(lessons.map(lesson => [lesson.id, lesson])).values()
|
||||
);
|
||||
setUniqueLessons(newUniqueLessons);
|
||||
}, [lessons]);
|
||||
|
||||
return { lessons, uniqueLessons, setLessons };
|
||||
};
|
||||
// Config
|
||||
import appConfig from '@/config/appConfig';
|
||||
|
||||
const Course = () => {
|
||||
const router = useRouter();
|
||||
const { ndk, addSigner } = useNDKContext();
|
||||
const { data: session, update } = useSession();
|
||||
const { showToast } = useToast();
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const [completedLessons, setCompletedLessons] = useState([]);
|
||||
const [nAddresses, setNAddresses] = useState({});
|
||||
const [nsec, setNsec] = useState(null);
|
||||
const [npub, setNpub] = useState(null);
|
||||
const [sidebarVisible, setSidebarVisible] = useState(false);
|
||||
const [nAddress, setNAddress] = useState(null);
|
||||
const windowWidth = useWindowWidth();
|
||||
const isMobileView = windowWidth <= 968;
|
||||
const [activeTab, setActiveTab] = useState('overview'); // Default to overview tab
|
||||
const navbarHeight = 60; // Match the height from Navbar component
|
||||
|
||||
// Memoized function to get the tab map based on view mode
|
||||
const getTabMap = useMemo(() => {
|
||||
const baseTabMap = ['overview', 'content', 'qa'];
|
||||
if (isMobileView) {
|
||||
const mobileTabMap = [...baseTabMap];
|
||||
mobileTabMap.splice(2, 0, 'lessons');
|
||||
return mobileTabMap;
|
||||
}
|
||||
return baseTabMap;
|
||||
}, [isMobileView]);
|
||||
// Use our navigation hook
|
||||
const {
|
||||
activeIndex,
|
||||
activeTab,
|
||||
sidebarVisible,
|
||||
setSidebarVisible,
|
||||
handleLessonSelect,
|
||||
toggleTab,
|
||||
toggleSidebar,
|
||||
getActiveTabIndex,
|
||||
getTabItems,
|
||||
} = useCourseNavigation(router, isMobileView);
|
||||
|
||||
useEffect(() => {
|
||||
if (router.isReady && router.query.slug) {
|
||||
@ -179,24 +66,6 @@ const Course = () => {
|
||||
}
|
||||
}, [router.isReady, router.query.slug, showToast, router]);
|
||||
|
||||
useEffect(() => {
|
||||
if (router.isReady) {
|
||||
const { active } = router.query;
|
||||
if (active !== undefined) {
|
||||
setActiveIndex(parseInt(active, 10));
|
||||
// If we have an active lesson, switch to content tab
|
||||
setActiveTab('content');
|
||||
} else {
|
||||
setActiveIndex(0);
|
||||
// Default to overview tab when no active parameter
|
||||
setActiveTab('overview');
|
||||
}
|
||||
|
||||
// Auto-open sidebar on desktop, close on mobile
|
||||
setSidebarVisible(!isMobileView);
|
||||
}
|
||||
}, [router.isReady, router.query, isMobileView]);
|
||||
|
||||
const setCompleted = useCallback(lessonId => {
|
||||
setCompletedLessons(prev => [...prev, lessonId]);
|
||||
}, []);
|
||||
@ -268,18 +137,7 @@ const Course = () => {
|
||||
session?.user?.role?.subscribed ||
|
||||
session?.user?.pubkey === course?.pubkey ||
|
||||
!paidCourse ||
|
||||
session?.user?.purchased?.some(purchase => purchase.courseId === course?.d)
|
||||
|
||||
const handleLessonSelect = index => {
|
||||
setActiveIndex(index);
|
||||
router.push(`/course/${router.query.slug}?active=${index}`, undefined, { shallow: true });
|
||||
|
||||
// On mobile, switch to content tab after selection
|
||||
if (isMobileView) {
|
||||
setActiveTab('content');
|
||||
setSidebarVisible(false);
|
||||
}
|
||||
};
|
||||
session?.user?.purchased?.some(purchase => purchase.courseId === course?.d);
|
||||
|
||||
const handlePaymentSuccess = async response => {
|
||||
if (response && response?.preimage) {
|
||||
@ -298,141 +156,6 @@ const Course = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const toggleTab = (index) => {
|
||||
const tabName = getTabMap[index];
|
||||
setActiveTab(tabName);
|
||||
|
||||
// Only show/hide sidebar on mobile - desktop keeps sidebar visible
|
||||
if (isMobileView) {
|
||||
setSidebarVisible(tabName === 'lessons');
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleSidebar = () => {
|
||||
setSidebarVisible(!sidebarVisible);
|
||||
};
|
||||
|
||||
// Map active tab name back to index for MenuTab
|
||||
const getActiveTabIndex = () => {
|
||||
return getTabMap.indexOf(activeTab);
|
||||
};
|
||||
|
||||
// Create tab items for MenuTab
|
||||
const getTabItems = () => {
|
||||
const items = [
|
||||
{
|
||||
label: 'Overview',
|
||||
icon: 'pi pi-home',
|
||||
},
|
||||
{
|
||||
label: 'Content',
|
||||
icon: 'pi pi-book',
|
||||
}
|
||||
];
|
||||
|
||||
// Add lessons tab only on mobile
|
||||
if (isMobileView) {
|
||||
items.push({
|
||||
label: 'Lessons',
|
||||
icon: 'pi pi-list',
|
||||
});
|
||||
}
|
||||
|
||||
items.push({
|
||||
label: 'Comments',
|
||||
icon: 'pi pi-comments',
|
||||
});
|
||||
|
||||
return items;
|
||||
};
|
||||
|
||||
// Add keyboard navigation support for tabs
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.key === 'ArrowRight') {
|
||||
const currentIndex = getActiveTabIndex();
|
||||
const nextIndex = (currentIndex + 1) % getTabMap.length;
|
||||
toggleTab(nextIndex);
|
||||
} else if (e.key === 'ArrowLeft') {
|
||||
const currentIndex = getActiveTabIndex();
|
||||
const prevIndex = (currentIndex - 1 + getTabMap.length) % getTabMap.length;
|
||||
toggleTab(prevIndex);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [activeTab, getTabMap, toggleTab]);
|
||||
|
||||
// Render the QA section (empty for now)
|
||||
const renderQASection = () => {
|
||||
return (
|
||||
<div className="rounded-lg p-8 mt-4 bg-gray-800 max-mob:px-4">
|
||||
<h2 className="text-xl font-bold mb-4">Comments</h2>
|
||||
{nAddress !== null && isAuthorized ? (
|
||||
<div className="px-4 max-mob:px-0">
|
||||
<ZapThreadsWrapper
|
||||
anchor={nAddress}
|
||||
user={nsec || npub || null}
|
||||
relays="wss://nos.lol/, wss://relay.damus.io/, wss://relay.snort.social/, wss://relay.nostr.band/, wss://relay.primal.net/, wss://nostrue.com/, wss://purplerelay.com/, wss://relay.devs.tools/"
|
||||
disable="zaps"
|
||||
isAuthorized={isAuthorized}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center p-4 mx-4 bg-gray-800/50 rounded-lg">
|
||||
<p className="text-gray-400">
|
||||
Comments are only available to content purchasers, subscribers, and the content creator.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
// Render Course Overview section
|
||||
const renderOverviewSection = () => {
|
||||
// Get isCompleted status for use in the component
|
||||
const isCompleted = completedLessons.length > 0;
|
||||
|
||||
return (
|
||||
<div className={`bg-gray-800 rounded-lg border border-gray-800 shadow-md ${isMobileView ? 'p-4' : 'p-6'}`}>
|
||||
{isMobileView && course && (
|
||||
<div className="mb-2">
|
||||
{/* Completed tag above image in mobile view */}
|
||||
{isCompleted && (
|
||||
<div className="mb-2">
|
||||
<Tag severity="success" value="Completed" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Course image */}
|
||||
{course.image && (
|
||||
<div className="w-full h-48 relative rounded-lg overflow-hidden mb-3">
|
||||
<img
|
||||
src={course.image}
|
||||
alt={course.title}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<CourseDetails
|
||||
processedEvent={course}
|
||||
paidCourse={paidCourse}
|
||||
lessons={uniqueLessons}
|
||||
decryptionPerformed={decryptionPerformed}
|
||||
handlePaymentSuccess={handlePaymentSuccess}
|
||||
handlePaymentError={handlePaymentError}
|
||||
isMobileView={isMobileView}
|
||||
showCompletedTag={!isMobileView}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
if (courseLoading || decryptionLoading) {
|
||||
return (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
@ -441,45 +164,6 @@ const Course = () => {
|
||||
);
|
||||
}
|
||||
|
||||
const renderLesson = lesson => {
|
||||
if (!lesson) return null;
|
||||
|
||||
// Check if this specific lesson is decrypted
|
||||
const lessonDecrypted = !paidCourse || decryptedLessonIds[lesson.id] || false;
|
||||
|
||||
if (lesson.topics?.includes('video') && lesson.topics?.includes('document')) {
|
||||
return (
|
||||
<CombinedLesson
|
||||
lesson={lesson}
|
||||
course={course}
|
||||
decryptionPerformed={lessonDecrypted}
|
||||
isPaid={paidCourse}
|
||||
setCompleted={setCompleted}
|
||||
/>
|
||||
);
|
||||
} else if (lesson.type === 'video' && !lesson.topics?.includes('document')) {
|
||||
return (
|
||||
<VideoLesson
|
||||
lesson={lesson}
|
||||
course={course}
|
||||
decryptionPerformed={lessonDecrypted}
|
||||
isPaid={paidCourse}
|
||||
setCompleted={setCompleted}
|
||||
/>
|
||||
);
|
||||
} else if (lesson.type === 'document' && !lesson.topics?.includes('video')) {
|
||||
return (
|
||||
<DocumentLesson
|
||||
lesson={lesson}
|
||||
course={course}
|
||||
decryptionPerformed={lessonDecrypted}
|
||||
isPaid={paidCourse}
|
||||
setCompleted={setCompleted}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mx-auto px-8 max-mob:px-0 mb-12 mt-2">
|
||||
@ -494,45 +178,50 @@ const Course = () => {
|
||||
activeIndex={getActiveTabIndex()}
|
||||
onTabChange={(index) => toggleTab(index)}
|
||||
sidebarVisible={sidebarVisible}
|
||||
onToggleSidebar={handleToggleSidebar}
|
||||
onToggleSidebar={toggleSidebar}
|
||||
isMobileView={isMobileView}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Revised layout structure to prevent content flexing */}
|
||||
{/* Main content area with fixed width */}
|
||||
<div className="relative mt-4">
|
||||
{/* Main content area with fixed width */}
|
||||
<div className={`transition-all duration-500 ease-in-out ${isMobileView ? 'w-full' : 'w-full'}`}
|
||||
style={!isMobileView && sidebarVisible ? {paddingRight: '320px'} : {}}>
|
||||
|
||||
{/* Overview tab content */}
|
||||
<div className={`${activeTab === 'overview' ? 'block' : 'hidden'}`}>
|
||||
{renderOverviewSection()}
|
||||
<CourseOverview
|
||||
course={course}
|
||||
paidCourse={paidCourse}
|
||||
lessons={uniqueLessons}
|
||||
decryptionPerformed={decryptionPerformed}
|
||||
handlePaymentSuccess={handlePaymentSuccess}
|
||||
handlePaymentError={handlePaymentError}
|
||||
isMobileView={isMobileView}
|
||||
completedLessons={completedLessons}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Content tab content */}
|
||||
<div className={`${activeTab === 'content' ? 'block' : 'hidden'}`}>
|
||||
{uniqueLessons.length > 0 && uniqueLessons[activeIndex] ? (
|
||||
<div className="bg-gray-800 rounded-lg shadow-sm overflow-hidden">
|
||||
<div key={`lesson-${uniqueLessons[activeIndex].id}`}>
|
||||
{renderLesson(uniqueLessons[activeIndex])}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center bg-gray-800 rounded-lg p-8">
|
||||
<p>Select a lesson from the sidebar to begin learning.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{course?.content && (
|
||||
<div className="mt-8 bg-gray-800 rounded-lg shadow-sm">
|
||||
<MarkdownDisplay content={course.content} className="p-4 rounded-lg" />
|
||||
</div>
|
||||
)}
|
||||
<CourseContent
|
||||
lessons={uniqueLessons}
|
||||
activeIndex={activeIndex}
|
||||
course={course}
|
||||
paidCourse={paidCourse}
|
||||
decryptedLessonIds={decryptedLessonIds}
|
||||
setCompleted={setCompleted}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* QA tab content */}
|
||||
<div className={`${activeTab === 'qa' ? 'block' : 'hidden'}`}>
|
||||
{renderQASection()}
|
||||
<CourseQA
|
||||
nAddress={nAddress}
|
||||
isAuthorized={isAuthorized}
|
||||
nsec={nsec}
|
||||
npub={npub}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -556,12 +245,7 @@ const Course = () => {
|
||||
<CourseSidebar
|
||||
lessons={uniqueLessons}
|
||||
activeIndex={activeIndex}
|
||||
onLessonSelect={(index) => {
|
||||
handleLessonSelect(index);
|
||||
if (isMobileView) {
|
||||
setActiveTab('content'); // Use the tab name directly
|
||||
}
|
||||
}}
|
||||
onLessonSelect={handleLessonSelect}
|
||||
completedLessons={completedLessons}
|
||||
isMobileView={isMobileView}
|
||||
sidebarVisible={sidebarVisible}
|
||||
@ -577,17 +261,12 @@ const Course = () => {
|
||||
<CourseSidebar
|
||||
lessons={uniqueLessons}
|
||||
activeIndex={activeIndex}
|
||||
onLessonSelect={(index) => {
|
||||
handleLessonSelect(index);
|
||||
if (isMobileView) {
|
||||
setActiveTab('content'); // Use the tab name directly
|
||||
}
|
||||
}}
|
||||
onLessonSelect={handleLessonSelect}
|
||||
completedLessons={completedLessons}
|
||||
isMobileView={isMobileView}
|
||||
onClose={() => {
|
||||
setSidebarVisible(false);
|
||||
setActiveTab('content');
|
||||
toggleTab(getActiveTabIndex());
|
||||
}}
|
||||
sidebarVisible={sidebarVisible}
|
||||
setSidebarVisible={setSidebarVisible}
|
||||
|
Loading…
x
Reference in New Issue
Block a user