mirror of
https://github.com/AustinKelsay/plebdevs.git
synced 2025-04-19 19:01:19 +00:00
Implemented document progress tracking based on read time, got video progress tracking working with Youtube embeds
This commit is contained in:
parent
f7bbf93f95
commit
4585ed263c
@ -11,6 +11,7 @@ import { getTotalFromZaps } from "@/utils/lightning";
|
||||
import dynamic from "next/dynamic";
|
||||
import useWindowWidth from "@/hooks/useWindowWidth";
|
||||
import appConfig from "@/config/appConfig";
|
||||
import useTrackDocumentLesson from "@/hooks/tracking/useTrackDocumentLesson";
|
||||
|
||||
const MDDisplay = dynamic(
|
||||
() => import("@uiw/react-markdown-preview"),
|
||||
@ -22,10 +23,19 @@ const MDDisplay = dynamic(
|
||||
const DocumentLesson = ({ lesson, course, decryptionPerformed, isPaid }) => {
|
||||
const [zapAmount, setZapAmount] = useState(0);
|
||||
const [nAddress, setNAddress] = useState(null);
|
||||
const [completed, setCompleted] = useState(false);
|
||||
const { zaps, zapsLoading, zapsError } = useZapsQuery({ event: lesson, type: "lesson" });
|
||||
const { returnImageProxy } = useImageProxy();
|
||||
const windowWidth = useWindowWidth();
|
||||
const isMobileView = windowWidth <= 768;
|
||||
// todo implement real read time needs to be on form
|
||||
const readTime = 30;
|
||||
|
||||
const { isCompleted, isTracking } = useTrackDocumentLesson({
|
||||
lessonId: lesson?.d,
|
||||
courseId: course?.d,
|
||||
readTime: readTime,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!zaps || zapsLoading || zapsError) return;
|
||||
@ -45,6 +55,12 @@ const DocumentLesson = ({ lesson, course, decryptionPerformed, isPaid }) => {
|
||||
}
|
||||
}, [lesson]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isCompleted) {
|
||||
setCompleted(lesson.id);
|
||||
}
|
||||
}, [isCompleted, lesson.id, setCompleted]);
|
||||
|
||||
const renderContent = () => {
|
||||
if (isPaid && decryptionPerformed) {
|
||||
return <MDDisplay className='p-2 rounded-lg w-full' source={lesson.content} />;
|
||||
@ -90,14 +106,14 @@ const DocumentLesson = ({ lesson, course, decryptionPerformed, isPaid }) => {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className='text-xl text-gray-200 mb-4 mt-4'>{lesson.summary && (
|
||||
<div className='text-xl text-gray-200 mb-4 mt-4'>{lesson.summary && (
|
||||
<div className="text-xl mt-4">
|
||||
{lesson.summary.split('\n').map((line, index) => (
|
||||
<p key={index}>{line}</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center'>
|
||||
<Image
|
||||
|
@ -292,11 +292,11 @@ export default function DraftCourseDetails({ processedEvent, draftId, lessons })
|
||||
['summary', draft.summary],
|
||||
['image', draft.image],
|
||||
// todo populate this tag from the config
|
||||
['i', 'youtube:plebdevs', 'V_fvmyJ91m0'],
|
||||
...draft.topics.map(topic => ['t', topic]),
|
||||
['published_at', Math.floor(Date.now() / 1000).toString()],
|
||||
...(draft?.price ? [['price', draft.price.toString()], ['location', `https://plebdevs.com/details/${draft.id}`]] : []),
|
||||
...(draft?.additionalLinks ? draft.additionalLinks.filter(link => link !== 'https://plebdevs.com').map(link => ['r', link]) : []),
|
||||
draft?.price ? null : ['i', 'youtube:plebdevs', 'V_fvmyJ91m0']
|
||||
];
|
||||
|
||||
type = 'video';
|
||||
|
@ -38,17 +38,53 @@ const VideoLesson = ({ lesson, course, decryptionPerformed, isPaid, setCompleted
|
||||
videoPlayed
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const handleYouTubeMessage = (event) => {
|
||||
if (event.origin !== "https://www.youtube.com") return;
|
||||
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
console.log('youtube data', data);
|
||||
if (data.event === "onReady") {
|
||||
event.source.postMessage('{"event":"listening"}', "https://www.youtube.com");
|
||||
} else if (data.event === "infoDelivery" && data?.info && data?.info?.currentTime) {
|
||||
setVideoPlayed(true);
|
||||
setVideoDuration(data.info?.progressState?.duration);
|
||||
event.source.postMessage('{"event":"command","func":"getDuration","args":""}', "https://www.youtube.com");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error parsing YouTube message:", error);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("message", handleYouTubeMessage);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("message", handleYouTubeMessage);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (videoDuration && videoPlayed) {
|
||||
console.log('videoDuration and videoPlayed', videoDuration, videoPlayed);
|
||||
}
|
||||
}, [videoDuration, videoPlayed]);
|
||||
|
||||
useEffect(() => {
|
||||
if (videoPlayed) {
|
||||
console.log('videoPlayed', videoPlayed);
|
||||
}
|
||||
}, [videoPlayed]);
|
||||
|
||||
const checkDuration = useCallback(() => {
|
||||
const videoElement = mdDisplayRef.current?.querySelector('video');
|
||||
const youtubeIframe = mdDisplayRef.current?.querySelector('iframe[src*="youtube.com"]');
|
||||
|
||||
if (videoElement && videoElement.readyState >= 1) {
|
||||
setVideoDuration(Math.round(videoElement.duration));
|
||||
|
||||
// Add event listener for play event
|
||||
videoElement.addEventListener('play', () => {
|
||||
setVideoPlayed(true);
|
||||
});
|
||||
} else if (videoElement) {
|
||||
setTimeout(checkDuration, 100);
|
||||
setVideoPlayed(true);
|
||||
} else if (youtubeIframe) {
|
||||
youtubeIframe.contentWindow.postMessage('{"event":"listening"}', '*');
|
||||
}
|
||||
}, []);
|
||||
|
||||
@ -78,6 +114,10 @@ const VideoLesson = ({ lesson, course, decryptionPerformed, isPaid, setCompleted
|
||||
if (decryptionPerformed && isPaid) {
|
||||
const timer = setTimeout(checkDuration, 500);
|
||||
return () => clearTimeout(timer);
|
||||
} else {
|
||||
// For non-paid content, start checking after 3 seconds
|
||||
const timer = setTimeout(checkDuration, 3000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [decryptionPerformed, isPaid, checkDuration]);
|
||||
|
||||
@ -109,7 +149,11 @@ const VideoLesson = ({ lesson, course, decryptionPerformed, isPaid, setCompleted
|
||||
</div>
|
||||
);
|
||||
} else if (lesson?.content) {
|
||||
return <MDDisplay className='p-0 rounded-lg w-full' source={lesson.content} />;
|
||||
return (
|
||||
<div ref={mdDisplayRef}>
|
||||
<MDDisplay className='p-0 rounded-lg w-full' source={lesson.content} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@ -129,14 +173,14 @@ const VideoLesson = ({ lesson, course, decryptionPerformed, isPaid, setCompleted
|
||||
)}
|
||||
</div>
|
||||
<div className='flex flex-row items-center justify-between w-full'>
|
||||
<p className='text-xl mt-4 text-gray-200'>{lesson.summary && (
|
||||
<div className='text-xl mt-4 text-gray-200'>{lesson.summary && (
|
||||
<div className="text-xl mt-4">
|
||||
{lesson.summary.split('\n').map((line, index) => (
|
||||
<p key={index}>{line}</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<ZapDisplay
|
||||
zapAmount={zapAmount}
|
||||
event={lesson}
|
||||
|
@ -12,6 +12,7 @@ import 'primeicons/primeicons.css';
|
||||
import { Tooltip } from 'primereact/tooltip';
|
||||
import 'primereact/resources/primereact.min.css';
|
||||
|
||||
// todo need to handle case where published video is being edited and not just draft
|
||||
const VideoForm = ({ draft = null }) => {
|
||||
const [title, setTitle] = useState(draft?.title || '');
|
||||
const [summary, setSummary] = useState(draft?.summary || '');
|
||||
@ -53,7 +54,7 @@ const VideoForm = ({ draft = null }) => {
|
||||
// Check if it's a YouTube video
|
||||
if (videoUrl.includes('youtube.com') || videoUrl.includes('youtu.be')) {
|
||||
const videoId = videoUrl.split('v=')[1] || videoUrl.split('/').pop();
|
||||
embedCode = `<div style="position:relative;padding-bottom:56.25%;height:0;overflow:hidden;max-width:100%;"><iframe src="https://www.youtube.com/embed/${videoId}" style="position:absolute;top:0;left:0;width:100%;height:100%;border:0;" allowfullscreen></iframe></div>`;
|
||||
embedCode = `<div style="position:relative;padding-bottom:56.25%;height:0;overflow:hidden;max-width:100%;"><iframe src="https://www.youtube.com/embed/${videoId}?enablejsapi=1" style="position:absolute;top:0;left:0;width:100%;height:100%;border:0;" allowfullscreen></iframe></div>`;
|
||||
}
|
||||
// Check if it's a Vimeo video
|
||||
else if (videoUrl.includes('vimeo.com')) {
|
||||
|
@ -50,7 +50,7 @@ const EmbeddedVideoForm = ({ draft = null, onSave, isPaid }) => {
|
||||
// Check if it's a YouTube video
|
||||
if (videoUrl.includes('youtube.com') || videoUrl.includes('youtu.be')) {
|
||||
const videoId = videoUrl.split('v=')[1] || videoUrl.split('/').pop();
|
||||
embedCode = `<div style="position:relative;padding-bottom:56.25%;height:0;overflow:hidden;max-width:100%;"><iframe src="https://www.youtube.com/embed/${videoId}" style="position:absolute;top:0;left:0;width:100%;height:100%;border:0;" allowfullscreen></iframe></div>`;
|
||||
embedCode = `<div style="position:relative;padding-bottom:56.25%;height:0;overflow:hidden;max-width:100%;"><iframe src="https://www.youtube.com/embed/${videoId}?enablejsapi=1" style="position:absolute;top:0;left:0;width:100%;height:100%;border:0;" allowfullscreen></iframe></div>`;
|
||||
}
|
||||
// Check if it's a Vimeo video
|
||||
else if (videoUrl.includes('vimeo.com')) {
|
||||
|
105
src/hooks/tracking/useTrackDocumentLesson.js
Normal file
105
src/hooks/tracking/useTrackDocumentLesson.js
Normal file
@ -0,0 +1,105 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useSession } from 'next-auth/react';
|
||||
import axios from 'axios';
|
||||
|
||||
const useTrackDocumentLesson = ({ lessonId, courseId, readTime }) => {
|
||||
const [isCompleted, setIsCompleted] = useState(false);
|
||||
const [timeSpent, setTimeSpent] = useState(0);
|
||||
const [isTracking, setIsTracking] = useState(false);
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
const timerRef = useRef(null);
|
||||
const { data: session } = useSession();
|
||||
const completedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (session?.user?.role?.admin) {
|
||||
setIsAdmin(true);
|
||||
setIsCompleted(true); // Automatically mark as completed for admins
|
||||
}
|
||||
}, [session]);
|
||||
|
||||
const checkOrCreateUserLesson = useCallback(async () => {
|
||||
if (!session?.user) return false;
|
||||
try {
|
||||
const response = await axios.get(`/api/users/${session.user.id}/lessons/${lessonId}?courseId=${courseId}`);
|
||||
if (response.status === 200 && response?.data) {
|
||||
if (response?.data?.completed) {
|
||||
setIsCompleted(true);
|
||||
completedRef.current = true;
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
} else if (response.status === 204) {
|
||||
await axios.post(`/api/users/${session.user.id}/lessons?courseId=${courseId}`, {
|
||||
resourceId: lessonId,
|
||||
opened: true,
|
||||
openedAt: new Date().toISOString(),
|
||||
});
|
||||
return false;
|
||||
} else {
|
||||
console.error('Error checking or creating UserLesson:', response.statusText);
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking or creating UserLesson:', error);
|
||||
return false;
|
||||
}
|
||||
}, [session, lessonId, courseId]);
|
||||
|
||||
const markLessonAsCompleted = useCallback(async () => {
|
||||
if (!session?.user || completedRef.current) return;
|
||||
completedRef.current = true;
|
||||
|
||||
try {
|
||||
const response = await axios.put(`/api/users/${session.user.id}/lessons/${lessonId}?courseId=${courseId}`, {
|
||||
completed: true,
|
||||
completedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
if (response.status === 200) {
|
||||
setIsCompleted(true);
|
||||
setIsTracking(false);
|
||||
} else {
|
||||
console.error('Failed to mark lesson as completed:', response.statusText);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error marking lesson as completed:', error);
|
||||
}
|
||||
}, [lessonId, courseId, session]);
|
||||
|
||||
useEffect(() => {
|
||||
const initializeTracking = async () => {
|
||||
if (isAdmin) return; // Skip tracking for admin users
|
||||
|
||||
const alreadyCompleted = await checkOrCreateUserLesson();
|
||||
if (!alreadyCompleted && !completedRef.current) {
|
||||
setIsTracking(true);
|
||||
timerRef.current = setInterval(() => {
|
||||
setTimeSpent(prevTime => prevTime + 1);
|
||||
}, 1000);
|
||||
}
|
||||
};
|
||||
|
||||
initializeTracking();
|
||||
|
||||
return () => {
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current);
|
||||
}
|
||||
};
|
||||
}, [lessonId, checkOrCreateUserLesson, isAdmin]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isAdmin) return; // Skip tracking for admin users
|
||||
|
||||
// Mark lesson as completed after readTime seconds
|
||||
if (timeSpent >= readTime && !completedRef.current) {
|
||||
markLessonAsCompleted();
|
||||
}
|
||||
}, [timeSpent, markLessonAsCompleted, readTime, isAdmin]);
|
||||
|
||||
return { isCompleted, isTracking };
|
||||
};
|
||||
|
||||
export default useTrackDocumentLesson;
|
@ -6,10 +6,18 @@ const useTrackVideoLesson = ({lessonId, videoDuration, courseId, videoPlayed}) =
|
||||
const [isCompleted, setIsCompleted] = useState(false);
|
||||
const [timeSpent, setTimeSpent] = useState(0);
|
||||
const [isTracking, setIsTracking] = useState(false);
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
const timerRef = useRef(null);
|
||||
const { data: session } = useSession();
|
||||
const completedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (session?.user?.role?.admin) {
|
||||
setIsAdmin(true);
|
||||
setIsCompleted(true); // Automatically mark as completed for admins
|
||||
}
|
||||
}, [session]);
|
||||
|
||||
// Check if the lesson is already completed or create a new UserLesson record
|
||||
const checkOrCreateUserLesson = useCallback(async () => {
|
||||
if (!session?.user) return false;
|
||||
@ -68,6 +76,9 @@ const useTrackVideoLesson = ({lessonId, videoDuration, courseId, videoPlayed}) =
|
||||
|
||||
useEffect(() => {
|
||||
const initializeTracking = async () => {
|
||||
console.log('initializeTracking', videoDuration, !completedRef.current, videoPlayed);
|
||||
if (isAdmin) return; // Skip tracking for admin users
|
||||
|
||||
const alreadyCompleted = await checkOrCreateUserLesson();
|
||||
// Case 3: Start tracking if the lesson is not completed, video duration is available, and video has been played
|
||||
if (!alreadyCompleted && videoDuration && !completedRef.current && videoPlayed) {
|
||||
@ -87,14 +98,16 @@ const useTrackVideoLesson = ({lessonId, videoDuration, courseId, videoPlayed}) =
|
||||
clearInterval(timerRef.current);
|
||||
}
|
||||
};
|
||||
}, [lessonId, videoDuration, checkOrCreateUserLesson, videoPlayed]);
|
||||
}, [lessonId, videoDuration, checkOrCreateUserLesson, videoPlayed, isAdmin]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isAdmin) return; // Skip tracking for admin users
|
||||
|
||||
// Case 4: Mark lesson as completed when 90% of the video is watched
|
||||
if (videoDuration && timeSpent >= Math.round(videoDuration * 0.9) && !completedRef.current) {
|
||||
markLessonAsCompleted();
|
||||
}
|
||||
}, [timeSpent, videoDuration, markLessonAsCompleted]);
|
||||
}, [timeSpent, videoDuration, markLessonAsCompleted, isAdmin]);
|
||||
|
||||
return { isCompleted, isTracking };
|
||||
};
|
||||
|
@ -106,6 +106,7 @@ export const authOptions = {
|
||||
}
|
||||
}
|
||||
|
||||
// todo this does not work on first login only the second time
|
||||
if (user && appConfig.authorPubkeys.includes(user?.pubkey) && !user?.role) {
|
||||
// create a new author role for this user
|
||||
const role = await createRole({
|
||||
|
Loading…
x
Reference in New Issue
Block a user