mirror of
https://github.com/AustinKelsay/plebdevs.git
synced 2025-06-05 00:32: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 { Toast } from 'primereact/toast';
|
||||||
|
|
||||||
// Import the desktop and mobile components
|
// Import the desktop and mobile components
|
||||||
import DesktopCourseDetails from './DesktopCourseDetails';
|
import DesktopCourseDetails from '@/components/content/courses/details/DesktopCourseDetails';
|
||||||
import MobileCourseDetails from './MobileCourseDetails';
|
import MobileCourseDetails from './MobileCourseDetails';
|
||||||
|
|
||||||
export default function CourseDetails({
|
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 'primereact/resources/primereact.min.css';
|
||||||
import 'primeicons/primeicons.css';
|
import 'primeicons/primeicons.css';
|
||||||
import useWindowWidth from '@/hooks/useWindowWidth';
|
import useWindowWidth from '@/hooks/useWindowWidth';
|
||||||
import CourseHeader from '../content/courses/CourseHeader';
|
import CourseHeader from '../content/courses/layout/CourseHeader';
|
||||||
import { useNDKContext } from '@/context/NDKContext';
|
import { useNDKContext } from '@/context/NDKContext';
|
||||||
import { nip19 } from 'nostr-tools';
|
import { nip19 } from 'nostr-tools';
|
||||||
import { parseCourseEvent } from '@/utils/nostr';
|
import { parseCourseEvent } from '@/utils/nostr';
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { parseCourseEvent } from '@/utils/nostr';
|
|
||||||
import { nip19 } from 'nostr-tools';
|
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
|
* Hook to fetch and manage course data
|
||||||
@ -21,7 +21,8 @@ const useCourseData = (ndk, fetchAuthor, router) => {
|
|||||||
if (!router.isReady) return;
|
if (!router.isReady) return;
|
||||||
|
|
||||||
const { slug } = router.query;
|
const { slug } = router.query;
|
||||||
|
let id;
|
||||||
|
|
||||||
const fetchCourseId = async () => {
|
const fetchCourseId = async () => {
|
||||||
if (slug.includes('naddr')) {
|
if (slug.includes('naddr')) {
|
||||||
const { data } = nip19.decode(slug);
|
const { data } = nip19.decode(slug);
|
||||||
@ -54,7 +55,7 @@ const useCourseData = (ndk, fetchAuthor, router) => {
|
|||||||
|
|
||||||
const initializeCourse = async () => {
|
const initializeCourse = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const id = await fetchCourseId();
|
id = await fetchCourseId();
|
||||||
if (!id) {
|
if (!id) {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
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 events = await ndk.fetchEvents(filter);
|
||||||
const newLessons = [];
|
const newLessons = [];
|
||||||
|
|
||||||
// Process events
|
// Process events (no need to check for duplicates here)
|
||||||
for (const event of events) {
|
for (const event of events) {
|
||||||
const author = await fetchAuthor(event.pubkey);
|
const author = await fetchAuthor(event.pubkey);
|
||||||
const parsedLesson = { ...parseEvent(event), author };
|
const parsedLesson = { ...parseEvent(event), author };
|
||||||
@ -47,7 +47,7 @@ const useLessons = (ndk, fetchAuthor, lessonIds, pubkey) => {
|
|||||||
}
|
}
|
||||||
}, [lessonIds, ndk, fetchAuthor, pubkey]);
|
}, [lessonIds, ndk, fetchAuthor, pubkey]);
|
||||||
|
|
||||||
// Deduplicate lessons
|
// Keep this deduplication logic using Map
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const newUniqueLessons = Array.from(
|
const newUniqueLessons = Array.from(
|
||||||
new Map(lessons.map(lesson => [lesson.id, lesson])).values()
|
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 { useRouter } from 'next/router';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { parseEvent, findKind0Fields } from '@/utils/nostr';
|
import { parseEvent, findKind0Fields } from '@/utils/nostr';
|
||||||
import DraftCourseDetails from '@/components/content/courses/DraftCourseDetails';
|
import DraftCourseDetails from '@/components/content/courses/details/DraftCourseDetails';
|
||||||
import DraftCourseLesson from '@/components/content/courses/DraftCourseLesson';
|
import DraftCourseLesson from '@/components/content/courses/details/DraftCourseLesson';
|
||||||
import { useNDKContext } from '@/context/NDKContext';
|
import { useNDKContext } from '@/context/NDKContext';
|
||||||
import { useSession } from 'next-auth/react';
|
import { useSession } from 'next-auth/react';
|
||||||
import { useIsAdmin } from '@/hooks/useIsAdmin';
|
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 { useRouter } from 'next/router';
|
||||||
import { parseCourseEvent, parseEvent, findKind0Fields } from '@/utils/nostr';
|
import { 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 { useNDKContext } from '@/context/NDKContext';
|
import { useNDKContext } from '@/context/NDKContext';
|
||||||
import { useSession } from 'next-auth/react';
|
import { useSession } from 'next-auth/react';
|
||||||
import { nip19 } from 'nostr-tools';
|
import { nip19 } from 'nostr-tools';
|
||||||
import { useToast } from '@/hooks/useToast';
|
import { useToast } from '@/hooks/useToast';
|
||||||
import { ProgressSpinner } from 'primereact/progressspinner';
|
import { ProgressSpinner } from 'primereact/progressspinner';
|
||||||
import { useDecryptContent } from '@/hooks/encryption/useDecryptContent';
|
|
||||||
|
// Hooks
|
||||||
import useCourseDecryption from '@/hooks/encryption/useCourseDecryption';
|
import useCourseDecryption from '@/hooks/encryption/useCourseDecryption';
|
||||||
import ZapThreadsWrapper from '@/components/ZapThreadsWrapper';
|
import useCourseData from '@/hooks/courses/useCourseData';
|
||||||
import appConfig from '@/config/appConfig';
|
import useLessons from '@/hooks/courses/useLessons';
|
||||||
|
import useCourseNavigation from '@/hooks/courses/useCourseNavigation';
|
||||||
import useWindowWidth from '@/hooks/useWindowWidth';
|
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 MenuTab from '@/components/menutab/MenuTab';
|
||||||
import { Tag } from 'primereact/tag';
|
|
||||||
import MarkdownDisplay from '@/components/markdown/MarkdownDisplay';
|
|
||||||
|
|
||||||
const useCourseData = (ndk, fetchAuthor, router) => {
|
// Config
|
||||||
const [course, setCourse] = useState(null);
|
import appConfig from '@/config/appConfig';
|
||||||
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 };
|
|
||||||
};
|
|
||||||
|
|
||||||
const Course = () => {
|
const Course = () => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { ndk, addSigner } = useNDKContext();
|
const { ndk, addSigner } = useNDKContext();
|
||||||
const { data: session, update } = useSession();
|
const { data: session, update } = useSession();
|
||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
const [activeIndex, setActiveIndex] = useState(0);
|
|
||||||
const [completedLessons, setCompletedLessons] = useState([]);
|
const [completedLessons, setCompletedLessons] = useState([]);
|
||||||
const [nAddresses, setNAddresses] = useState({});
|
const [nAddresses, setNAddresses] = useState({});
|
||||||
const [nsec, setNsec] = useState(null);
|
const [nsec, setNsec] = useState(null);
|
||||||
const [npub, setNpub] = useState(null);
|
const [npub, setNpub] = useState(null);
|
||||||
const [sidebarVisible, setSidebarVisible] = useState(false);
|
|
||||||
const [nAddress, setNAddress] = useState(null);
|
const [nAddress, setNAddress] = useState(null);
|
||||||
const windowWidth = useWindowWidth();
|
const windowWidth = useWindowWidth();
|
||||||
const isMobileView = windowWidth <= 968;
|
const isMobileView = windowWidth <= 968;
|
||||||
const [activeTab, setActiveTab] = useState('overview'); // Default to overview tab
|
|
||||||
const navbarHeight = 60; // Match the height from Navbar component
|
const navbarHeight = 60; // Match the height from Navbar component
|
||||||
|
|
||||||
// Memoized function to get the tab map based on view mode
|
// Use our navigation hook
|
||||||
const getTabMap = useMemo(() => {
|
const {
|
||||||
const baseTabMap = ['overview', 'content', 'qa'];
|
activeIndex,
|
||||||
if (isMobileView) {
|
activeTab,
|
||||||
const mobileTabMap = [...baseTabMap];
|
sidebarVisible,
|
||||||
mobileTabMap.splice(2, 0, 'lessons');
|
setSidebarVisible,
|
||||||
return mobileTabMap;
|
handleLessonSelect,
|
||||||
}
|
toggleTab,
|
||||||
return baseTabMap;
|
toggleSidebar,
|
||||||
}, [isMobileView]);
|
getActiveTabIndex,
|
||||||
|
getTabItems,
|
||||||
|
} = useCourseNavigation(router, isMobileView);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (router.isReady && router.query.slug) {
|
if (router.isReady && router.query.slug) {
|
||||||
@ -179,24 +66,6 @@ const Course = () => {
|
|||||||
}
|
}
|
||||||
}, [router.isReady, router.query.slug, showToast, router]);
|
}, [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 => {
|
const setCompleted = useCallback(lessonId => {
|
||||||
setCompletedLessons(prev => [...prev, lessonId]);
|
setCompletedLessons(prev => [...prev, lessonId]);
|
||||||
}, []);
|
}, []);
|
||||||
@ -268,18 +137,7 @@ const Course = () => {
|
|||||||
session?.user?.role?.subscribed ||
|
session?.user?.role?.subscribed ||
|
||||||
session?.user?.pubkey === course?.pubkey ||
|
session?.user?.pubkey === course?.pubkey ||
|
||||||
!paidCourse ||
|
!paidCourse ||
|
||||||
session?.user?.purchased?.some(purchase => purchase.courseId === course?.d)
|
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);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePaymentSuccess = async response => {
|
const handlePaymentSuccess = async response => {
|
||||||
if (response && response?.preimage) {
|
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) {
|
if (courseLoading || decryptionLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-full flex items-center justify-center">
|
<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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="mx-auto px-8 max-mob:px-0 mb-12 mt-2">
|
<div className="mx-auto px-8 max-mob:px-0 mb-12 mt-2">
|
||||||
@ -494,45 +178,50 @@ const Course = () => {
|
|||||||
activeIndex={getActiveTabIndex()}
|
activeIndex={getActiveTabIndex()}
|
||||||
onTabChange={(index) => toggleTab(index)}
|
onTabChange={(index) => toggleTab(index)}
|
||||||
sidebarVisible={sidebarVisible}
|
sidebarVisible={sidebarVisible}
|
||||||
onToggleSidebar={handleToggleSidebar}
|
onToggleSidebar={toggleSidebar}
|
||||||
isMobileView={isMobileView}
|
isMobileView={isMobileView}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Revised layout structure to prevent content flexing */}
|
{/* Main content area with fixed width */}
|
||||||
<div className="relative mt-4">
|
<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'}`}
|
<div className={`transition-all duration-500 ease-in-out ${isMobileView ? 'w-full' : 'w-full'}`}
|
||||||
style={!isMobileView && sidebarVisible ? {paddingRight: '320px'} : {}}>
|
style={!isMobileView && sidebarVisible ? {paddingRight: '320px'} : {}}>
|
||||||
|
|
||||||
{/* Overview tab content */}
|
{/* Overview tab content */}
|
||||||
<div className={`${activeTab === 'overview' ? 'block' : 'hidden'}`}>
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* Content tab content */}
|
{/* Content tab content */}
|
||||||
<div className={`${activeTab === 'content' ? 'block' : 'hidden'}`}>
|
<div className={`${activeTab === 'content' ? 'block' : 'hidden'}`}>
|
||||||
{uniqueLessons.length > 0 && uniqueLessons[activeIndex] ? (
|
<CourseContent
|
||||||
<div className="bg-gray-800 rounded-lg shadow-sm overflow-hidden">
|
lessons={uniqueLessons}
|
||||||
<div key={`lesson-${uniqueLessons[activeIndex].id}`}>
|
activeIndex={activeIndex}
|
||||||
{renderLesson(uniqueLessons[activeIndex])}
|
course={course}
|
||||||
</div>
|
paidCourse={paidCourse}
|
||||||
</div>
|
decryptedLessonIds={decryptedLessonIds}
|
||||||
) : (
|
setCompleted={setCompleted}
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* QA tab content */}
|
{/* QA tab content */}
|
||||||
<div className={`${activeTab === 'qa' ? 'block' : 'hidden'}`}>
|
<div className={`${activeTab === 'qa' ? 'block' : 'hidden'}`}>
|
||||||
{renderQASection()}
|
<CourseQA
|
||||||
|
nAddress={nAddress}
|
||||||
|
isAuthorized={isAuthorized}
|
||||||
|
nsec={nsec}
|
||||||
|
npub={npub}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -556,12 +245,7 @@ const Course = () => {
|
|||||||
<CourseSidebar
|
<CourseSidebar
|
||||||
lessons={uniqueLessons}
|
lessons={uniqueLessons}
|
||||||
activeIndex={activeIndex}
|
activeIndex={activeIndex}
|
||||||
onLessonSelect={(index) => {
|
onLessonSelect={handleLessonSelect}
|
||||||
handleLessonSelect(index);
|
|
||||||
if (isMobileView) {
|
|
||||||
setActiveTab('content'); // Use the tab name directly
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
completedLessons={completedLessons}
|
completedLessons={completedLessons}
|
||||||
isMobileView={isMobileView}
|
isMobileView={isMobileView}
|
||||||
sidebarVisible={sidebarVisible}
|
sidebarVisible={sidebarVisible}
|
||||||
@ -577,17 +261,12 @@ const Course = () => {
|
|||||||
<CourseSidebar
|
<CourseSidebar
|
||||||
lessons={uniqueLessons}
|
lessons={uniqueLessons}
|
||||||
activeIndex={activeIndex}
|
activeIndex={activeIndex}
|
||||||
onLessonSelect={(index) => {
|
onLessonSelect={handleLessonSelect}
|
||||||
handleLessonSelect(index);
|
|
||||||
if (isMobileView) {
|
|
||||||
setActiveTab('content'); // Use the tab name directly
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
completedLessons={completedLessons}
|
completedLessons={completedLessons}
|
||||||
isMobileView={isMobileView}
|
isMobileView={isMobileView}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setSidebarVisible(false);
|
setSidebarVisible(false);
|
||||||
setActiveTab('content');
|
toggleTab(getActiveTabIndex());
|
||||||
}}
|
}}
|
||||||
sidebarVisible={sidebarVisible}
|
sidebarVisible={sidebarVisible}
|
||||||
setSidebarVisible={setSidebarVisible}
|
setSidebarVisible={setSidebarVisible}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user