mirror of
https://github.com/AustinKelsay/plebdevs.git
synced 2025-06-05 00:32:03 +00:00
Merge pull request #70 from AustinKelsay/bugfix/course-author-view
refactor for recalling /decrypt on lesson change
This commit is contained in:
commit
c73d6eb8bb
@ -167,10 +167,17 @@ const CombinedLesson = ({ lesson, course, decryptionPerformed, isPaid, setComple
|
|||||||
}, [videoCompleted, videoTracking, lesson.id, setCompleted, isVideo]);
|
}, [videoCompleted, videoTracking, lesson.id, setCompleted, isVideo]);
|
||||||
|
|
||||||
const renderContent = () => {
|
const renderContent = () => {
|
||||||
if (isPaid && decryptionPerformed) {
|
if (!lesson?.content) {
|
||||||
|
if (isVideo) {
|
||||||
|
return (
|
||||||
|
<div className="w-full aspect-video rounded-lg flex flex-col items-center justify-center bg-gray-800">
|
||||||
|
<p className="text-center text-gray-400">No content available for this lesson.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<div ref={mdDisplayRef}>
|
<div className="w-full p-8 rounded-lg flex flex-col items-center justify-center bg-gray-800">
|
||||||
<MarkdownDisplay content={lesson.content} className="p-4 rounded-lg w-full" />
|
<p className="text-center text-gray-400">No content available for this lesson.</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -209,15 +216,11 @@ const CombinedLesson = ({ lesson, course, decryptionPerformed, isPaid, setComple
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (lesson?.content) {
|
return (
|
||||||
return (
|
<div ref={mdDisplayRef}>
|
||||||
<div ref={mdDisplayRef}>
|
<MarkdownDisplay content={lesson.content} className="p-4 rounded-lg w-full" />
|
||||||
<MarkdownDisplay content={lesson.content} className="p-4 rounded-lg w-full" />
|
</div>
|
||||||
</div>
|
);
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -36,7 +36,10 @@ const CourseSidebar = ({
|
|||||||
}
|
}
|
||||||
${isMobileView ? 'mb-3' : 'mb-2'}
|
${isMobileView ? 'mb-3' : 'mb-2'}
|
||||||
`}
|
`}
|
||||||
onClick={() => onLessonSelect(index)}
|
onClick={() => {
|
||||||
|
// Force full page refresh to trigger proper decryption
|
||||||
|
window.location.href = `/course/${window.location.pathname.split('/').pop()}?active=${index}`;
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className={`flex items-start p-3 cursor-pointer ${isMobileView ? 'p-4' : 'p-3'}`}>
|
<div className={`flex items-start p-3 cursor-pointer ${isMobileView ? 'p-4' : 'p-3'}`}>
|
||||||
{lesson.image && (
|
{lesson.image && (
|
||||||
|
@ -113,9 +113,14 @@ const DocumentLesson = ({ lesson, course, decryptionPerformed, isPaid, setComple
|
|||||||
}, [isCompleted, lesson.id, setCompleted, isTracking]);
|
}, [isCompleted, lesson.id, setCompleted, isTracking]);
|
||||||
|
|
||||||
const renderContent = () => {
|
const renderContent = () => {
|
||||||
if (isPaid && decryptionPerformed) {
|
if (!lesson?.content) {
|
||||||
return <MarkdownDisplay content={lesson.content} className="p-4 rounded-lg w-full" />;
|
return (
|
||||||
|
<div className="w-full p-8 rounded-lg flex flex-col items-center justify-center bg-gray-800">
|
||||||
|
<p className="text-center text-gray-400">No content available for this lesson.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isPaid && !decryptionPerformed) {
|
if (isPaid && !decryptionPerformed) {
|
||||||
return (
|
return (
|
||||||
<div className="w-full p-8 rounded-lg flex flex-col items-center justify-center">
|
<div className="w-full p-8 rounded-lg flex flex-col items-center justify-center">
|
||||||
@ -128,10 +133,8 @@ const DocumentLesson = ({ lesson, course, decryptionPerformed, isPaid, setComple
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (lesson?.content) {
|
|
||||||
return <MarkdownDisplay content={lesson.content} className="p-4 rounded-lg w-full" />;
|
return <MarkdownDisplay content={lesson.content} className="p-4 rounded-lg w-full" />;
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -163,13 +163,17 @@ const VideoLesson = ({ lesson, course, decryptionPerformed, isPaid, setCompleted
|
|||||||
}, [decryptionPerformed, isPaid, checkDuration]);
|
}, [decryptionPerformed, isPaid, checkDuration]);
|
||||||
|
|
||||||
const renderContent = () => {
|
const renderContent = () => {
|
||||||
if (isPaid && decryptionPerformed) {
|
// Content not available
|
||||||
|
if (!lesson?.content) {
|
||||||
return (
|
return (
|
||||||
<div ref={mdDisplayRef}>
|
<div className="w-full aspect-video rounded-lg flex flex-col items-center justify-center bg-gray-800">
|
||||||
<MarkdownDisplay content={lesson.content} className="p-0 rounded-lg w-full" />
|
<p className="text-center text-gray-400">No content available for this lesson.</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
} else if (isPaid && !decryptionPerformed) {
|
}
|
||||||
|
|
||||||
|
// Paid content that needs to be purchased
|
||||||
|
if (isPaid && !decryptionPerformed) {
|
||||||
return (
|
return (
|
||||||
<div className="w-full aspect-video rounded-lg flex flex-col items-center justify-center relative overflow-hidden">
|
<div className="w-full aspect-video rounded-lg flex flex-col items-center justify-center relative overflow-hidden">
|
||||||
<div
|
<div
|
||||||
@ -189,14 +193,14 @@ const VideoLesson = ({ lesson, course, decryptionPerformed, isPaid, setCompleted
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
} else if (lesson?.content) {
|
|
||||||
return (
|
|
||||||
<div ref={mdDisplayRef}>
|
|
||||||
<MarkdownDisplay content={lesson.content} className="p-0 rounded-lg w-full" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return null;
|
|
||||||
|
// Content is available and decrypted (or free)
|
||||||
|
return (
|
||||||
|
<div ref={mdDisplayRef}>
|
||||||
|
<MarkdownDisplay content={lesson.content} className="p-0 rounded-lg w-full" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -1,30 +1,101 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useRef } from 'react';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
|
||||||
export const useDecryptContent = () => {
|
export const useDecryptContent = () => {
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
|
// Map of in-progress decryption promises, keyed by content hash
|
||||||
const decryptContent = async encryptedContent => {
|
const inProgressMap = useRef(new Map());
|
||||||
setIsLoading(true);
|
const cachedResults = useRef({});
|
||||||
setError(null);
|
|
||||||
|
const decryptContent = async (encryptedContent) => {
|
||||||
try {
|
// Validate input
|
||||||
const response = await axios.post('/api/decrypt', { encryptedContent });
|
if (!encryptedContent) {
|
||||||
|
return null;
|
||||||
if (response.status !== 200) {
|
}
|
||||||
throw new Error('Failed to decrypt content');
|
|
||||||
|
// Use first 20 chars as our cache/lock key
|
||||||
|
const cacheKey = encryptedContent.substring(0, 20);
|
||||||
|
|
||||||
|
// Check if we've already decrypted this content
|
||||||
|
if (cachedResults.current[cacheKey]) {
|
||||||
|
return cachedResults.current[cacheKey];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this specific content is already being decrypted
|
||||||
|
if (inProgressMap.current.has(cacheKey)) {
|
||||||
|
// Return the existing promise for this content
|
||||||
|
try {
|
||||||
|
return await inProgressMap.current.get(cacheKey);
|
||||||
|
} catch (error) {
|
||||||
|
// If the existing promise rejects, we'll try again below
|
||||||
|
if (error.name !== 'AbortError') {
|
||||||
|
console.warn('Previous decryption attempt failed, retrying');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
const decryptedContent = response.data.decryptedContent;
|
|
||||||
setIsLoading(false);
|
// Create abort controller for this request
|
||||||
return decryptedContent;
|
const abortController = new AbortController();
|
||||||
} catch (err) {
|
|
||||||
setError(err.message);
|
// Create a new decryption promise for this content
|
||||||
setIsLoading(false);
|
const decryptPromise = (async () => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const response = await axios.post('/api/decrypt',
|
||||||
|
{ encryptedContent },
|
||||||
|
{ signal: abortController.signal }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.status !== 200) {
|
||||||
|
throw new Error(`Failed to decrypt: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const decryptedContent = response.data.decryptedContent;
|
||||||
|
|
||||||
|
// Cache the successful result
|
||||||
|
cachedResults.current[cacheKey] = decryptedContent;
|
||||||
|
|
||||||
|
return decryptedContent;
|
||||||
|
} catch (error) {
|
||||||
|
// Handle abort errors specifically
|
||||||
|
if (axios.isCancel(error)) {
|
||||||
|
throw new DOMException('Decryption aborted', 'AbortError');
|
||||||
|
}
|
||||||
|
|
||||||
|
setError(error.message || 'Decryption failed');
|
||||||
|
// Re-throw to signal failure to awaiter
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
// Remove this promise from the in-progress map
|
||||||
|
inProgressMap.current.delete(cacheKey);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Store the promise and abort controller in our map
|
||||||
|
const abortablePromise = {
|
||||||
|
promise: decryptPromise,
|
||||||
|
abort: () => abortController.abort()
|
||||||
|
};
|
||||||
|
|
||||||
|
inProgressMap.current.set(cacheKey, decryptPromise);
|
||||||
|
|
||||||
|
// Function to handle timeouts from parent callers
|
||||||
|
decryptPromise.cancel = () => {
|
||||||
|
abortController.abort();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Return the promise
|
||||||
|
try {
|
||||||
|
return await decryptPromise;
|
||||||
|
} catch (error) {
|
||||||
|
// We've already set the error state in the promise
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return { decryptContent, isLoading, error };
|
return { decryptContent, isLoading, error };
|
||||||
};
|
};
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import React, { useEffect, useState, useCallback, useMemo } from 'react';
|
import React, { useEffect, useState, useCallback, useMemo, useRef } from 'react';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import { parseCourseEvent, parseEvent, findKind0Fields } from '@/utils/nostr';
|
import { parseCourseEvent, parseEvent, findKind0Fields } from '@/utils/nostr';
|
||||||
import CourseDetails from '@/components/content/courses/CourseDetails';
|
import CourseDetails from '@/components/content/courses/CourseDetails';
|
||||||
@ -135,42 +135,151 @@ const useLessons = (ndk, fetchAuthor, lessonIds, pubkey) => {
|
|||||||
return { lessons, uniqueLessons, setLessons };
|
return { lessons, uniqueLessons, setLessons };
|
||||||
};
|
};
|
||||||
|
|
||||||
const useDecryption = (session, paidCourse, course, lessons, setLessons) => {
|
const useDecryption = (session, paidCourse, course, lessons, setLessons, router) => {
|
||||||
const [decryptionPerformed, setDecryptionPerformed] = useState(false);
|
const [decryptedLessonIds, setDecryptedLessonIds] = useState({});
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(false);
|
||||||
const { decryptContent } = useDecryptContent();
|
const { decryptContent } = useDecryptContent();
|
||||||
|
const processingRef = useRef(false);
|
||||||
|
const lastLessonIdRef = useRef(null);
|
||||||
|
const retryCountRef = useRef({});
|
||||||
|
const MAX_RETRIES = 3;
|
||||||
|
|
||||||
|
// Get the current active lesson
|
||||||
|
const currentLessonIndex = router.query.active ? parseInt(router.query.active, 10) : 0;
|
||||||
|
const currentLesson = lessons.length > 0 ? lessons[currentLessonIndex] : null;
|
||||||
|
const currentLessonId = currentLesson?.id;
|
||||||
|
|
||||||
|
// Check if the current lesson has been decrypted
|
||||||
|
const isCurrentLessonDecrypted =
|
||||||
|
!paidCourse ||
|
||||||
|
(currentLessonId && decryptedLessonIds[currentLessonId]);
|
||||||
|
|
||||||
|
// Check user access
|
||||||
|
const hasAccess = useMemo(() => {
|
||||||
|
if (!session?.user || !paidCourse || !course) return false;
|
||||||
|
|
||||||
|
return (
|
||||||
|
session.user.purchased?.some(purchase => purchase.courseId === course?.d) ||
|
||||||
|
session.user?.role?.subscribed ||
|
||||||
|
session.user?.pubkey === course?.pubkey
|
||||||
|
);
|
||||||
|
}, [session, paidCourse, course]);
|
||||||
|
|
||||||
|
// Reset retry count when lesson changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const decrypt = async () => {
|
if (currentLessonId && lastLessonIdRef.current !== currentLessonId) {
|
||||||
if (session?.user && paidCourse && !decryptionPerformed) {
|
retryCountRef.current[currentLessonId] = 0;
|
||||||
setLoading(true);
|
}
|
||||||
const canAccess =
|
}, [currentLessonId]);
|
||||||
session.user.purchased?.some(purchase => purchase.courseId === course?.d) ||
|
|
||||||
session.user?.role?.subscribed ||
|
// Simplified decrypt function
|
||||||
session.user?.pubkey === course?.pubkey;
|
const decryptCurrentLesson = useCallback(async () => {
|
||||||
|
if (!currentLesson || !hasAccess || !paidCourse) return;
|
||||||
if (canAccess && lessons.length > 0) {
|
if (processingRef.current) return;
|
||||||
try {
|
if (decryptedLessonIds[currentLesson.id]) return;
|
||||||
const decryptedLessons = await Promise.all(
|
if (!currentLesson.content) return;
|
||||||
lessons.map(async lesson => {
|
|
||||||
const decryptedContent = await decryptContent(lesson.content);
|
// Check retry count
|
||||||
return { ...lesson, content: decryptedContent };
|
if (!retryCountRef.current[currentLesson.id]) {
|
||||||
})
|
retryCountRef.current[currentLesson.id] = 0;
|
||||||
);
|
}
|
||||||
setLessons(decryptedLessons);
|
|
||||||
setDecryptionPerformed(true);
|
// Limit maximum retries
|
||||||
} catch (error) {
|
if (retryCountRef.current[currentLesson.id] >= MAX_RETRIES) {
|
||||||
console.error('Error decrypting lessons:', error);
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Increment retry count
|
||||||
|
retryCountRef.current[currentLesson.id]++;
|
||||||
|
|
||||||
|
try {
|
||||||
|
processingRef.current = true;
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
// Start the decryption process
|
||||||
|
const decryptionPromise = decryptContent(currentLesson.content);
|
||||||
|
|
||||||
|
// Add safety timeout to prevent infinite processing
|
||||||
|
let timeoutId;
|
||||||
|
const timeoutPromise = new Promise((_, reject) => {
|
||||||
|
timeoutId = setTimeout(() => {
|
||||||
|
// Cancel the in-flight request when timeout occurs
|
||||||
|
if (decryptionPromise.cancel) {
|
||||||
|
decryptionPromise.cancel();
|
||||||
}
|
}
|
||||||
}
|
reject(new Error('Decryption timeout'));
|
||||||
setLoading(false);
|
}, 10000);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Use a separate try-catch for the race
|
||||||
|
let decryptedContent;
|
||||||
|
try {
|
||||||
|
// Race between decryption and timeout
|
||||||
|
decryptedContent = await Promise.race([
|
||||||
|
decryptionPromise,
|
||||||
|
timeoutPromise
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Clear the timeout if decryption wins
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
} catch (error) {
|
||||||
|
// If timeout or network error, schedule a retry
|
||||||
|
setTimeout(() => {
|
||||||
|
processingRef.current = false;
|
||||||
|
decryptCurrentLesson();
|
||||||
|
}, 5000);
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!decryptedContent) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the lessons array with decrypted content
|
||||||
|
const updatedLessons = lessons.map(lesson =>
|
||||||
|
lesson.id === currentLesson.id
|
||||||
|
? { ...lesson, content: decryptedContent }
|
||||||
|
: lesson
|
||||||
|
);
|
||||||
|
|
||||||
|
setLessons(updatedLessons);
|
||||||
|
|
||||||
|
// Mark this lesson as decrypted
|
||||||
|
setDecryptedLessonIds(prev => ({
|
||||||
|
...prev,
|
||||||
|
[currentLesson.id]: true
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Reset retry counter on success
|
||||||
|
retryCountRef.current[currentLesson.id] = 0;
|
||||||
|
} catch (error) {
|
||||||
|
// Silent error handling to prevent UI disruption
|
||||||
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
};
|
processingRef.current = false;
|
||||||
decrypt();
|
}
|
||||||
}, [session, paidCourse, course, lessons, decryptionPerformed, setLessons]);
|
}, [currentLesson, hasAccess, paidCourse, decryptContent, lessons, setLessons, decryptedLessonIds]);
|
||||||
|
|
||||||
return { decryptionPerformed, loading };
|
// Run decryption when lesson changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!currentLessonId) return;
|
||||||
|
|
||||||
|
// Skip if the lesson hasn't changed, unless it failed decryption previously
|
||||||
|
if (lastLessonIdRef.current === currentLessonId && decryptedLessonIds[currentLessonId]) return;
|
||||||
|
|
||||||
|
// Update the last processed lesson id
|
||||||
|
lastLessonIdRef.current = currentLessonId;
|
||||||
|
|
||||||
|
if (hasAccess && paidCourse && !decryptedLessonIds[currentLessonId]) {
|
||||||
|
decryptCurrentLesson();
|
||||||
|
}
|
||||||
|
}, [currentLessonId, hasAccess, paidCourse, decryptedLessonIds, decryptCurrentLesson]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
decryptionPerformed: isCurrentLessonDecrypted,
|
||||||
|
loading,
|
||||||
|
decryptedLessonIds
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const Course = () => {
|
const Course = () => {
|
||||||
@ -262,12 +371,13 @@ const Course = () => {
|
|||||||
course?.pubkey
|
course?.pubkey
|
||||||
);
|
);
|
||||||
|
|
||||||
const { decryptionPerformed, loading: decryptionLoading } = useDecryption(
|
const { decryptionPerformed, loading: decryptionLoading, decryptedLessonIds } = useDecryption(
|
||||||
session,
|
session,
|
||||||
paidCourse,
|
paidCourse,
|
||||||
course,
|
course,
|
||||||
lessons,
|
lessons,
|
||||||
setLessons
|
setLessons,
|
||||||
|
router
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -478,23 +588,27 @@ const Course = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const renderLesson = lesson => {
|
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')) {
|
if (lesson.topics?.includes('video') && lesson.topics?.includes('document')) {
|
||||||
return (
|
return (
|
||||||
<CombinedLesson
|
<CombinedLesson
|
||||||
lesson={lesson}
|
lesson={lesson}
|
||||||
course={course}
|
course={course}
|
||||||
decryptionPerformed={decryptionPerformed}
|
decryptionPerformed={lessonDecrypted}
|
||||||
isPaid={paidCourse}
|
isPaid={paidCourse}
|
||||||
setCompleted={setCompleted}
|
setCompleted={setCompleted}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
);
|
);
|
||||||
} else if (lesson.type === 'video' && !lesson.topics?.includes('document')) {
|
} else if (lesson.type === 'video' && !lesson.topics?.includes('document')) {
|
||||||
return (
|
return (
|
||||||
<VideoLesson
|
<VideoLesson
|
||||||
lesson={lesson}
|
lesson={lesson}
|
||||||
course={course}
|
course={course}
|
||||||
decryptionPerformed={decryptionPerformed}
|
decryptionPerformed={lessonDecrypted}
|
||||||
isPaid={paidCourse}
|
isPaid={paidCourse}
|
||||||
setCompleted={setCompleted}
|
setCompleted={setCompleted}
|
||||||
/>
|
/>
|
||||||
@ -504,7 +618,7 @@ const Course = () => {
|
|||||||
<DocumentLesson
|
<DocumentLesson
|
||||||
lesson={lesson}
|
lesson={lesson}
|
||||||
course={course}
|
course={course}
|
||||||
decryptionPerformed={decryptionPerformed}
|
decryptionPerformed={lessonDecrypted}
|
||||||
isPaid={paidCourse}
|
isPaid={paidCourse}
|
||||||
setCompleted={setCompleted}
|
setCompleted={setCompleted}
|
||||||
/>
|
/>
|
||||||
@ -545,7 +659,9 @@ const Course = () => {
|
|||||||
<div className={`${activeTab === 'content' ? 'block' : 'hidden'}`}>
|
<div className={`${activeTab === 'content' ? 'block' : 'hidden'}`}>
|
||||||
{uniqueLessons.length > 0 && uniqueLessons[activeIndex] ? (
|
{uniqueLessons.length > 0 && uniqueLessons[activeIndex] ? (
|
||||||
<div className="bg-gray-800 rounded-lg shadow-sm overflow-hidden">
|
<div className="bg-gray-800 rounded-lg shadow-sm overflow-hidden">
|
||||||
{renderLesson(uniqueLessons[activeIndex])}
|
<div key={`lesson-${uniqueLessons[activeIndex].id}`}>
|
||||||
|
{renderLesson(uniqueLessons[activeIndex])}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-center bg-gray-800 rounded-lg p-8">
|
<div className="text-center bg-gray-800 rounded-lg p-8">
|
||||||
|
Loading…
x
Reference in New Issue
Block a user