From 67ea5f523e413243797a750079a64818bf2c98f9 Mon Sep 17 00:00:00 2001 From: austinkelsay Date: Tue, 3 Dec 2024 12:48:17 -0600 Subject: [PATCH] Allow simple edits of published courses, add cascade delete to userlessons to make this course update cleaner --- .../migration.sql | 5 + prisma/schema.prisma | 2 +- .../content/courses/CourseDetails.js | 2 +- .../forms/course/PublishedCourseForm.js | 227 ++++++++++++++++++ src/db/models/courseModels.js | 4 +- src/pages/course/[slug]/edit.js | 69 ++++++ src/utils/nostr.js | 3 + 7 files changed, 308 insertions(+), 4 deletions(-) create mode 100644 prisma/migrations/20241203184559_cascade_delete_user_lessons/migration.sql create mode 100644 src/components/forms/course/PublishedCourseForm.js create mode 100644 src/pages/course/[slug]/edit.js diff --git a/prisma/migrations/20241203184559_cascade_delete_user_lessons/migration.sql b/prisma/migrations/20241203184559_cascade_delete_user_lessons/migration.sql new file mode 100644 index 0000000..3d6b23c --- /dev/null +++ b/prisma/migrations/20241203184559_cascade_delete_user_lessons/migration.sql @@ -0,0 +1,5 @@ +-- DropForeignKey +ALTER TABLE "UserLesson" DROP CONSTRAINT "UserLesson_lessonId_fkey"; + +-- AddForeignKey +ALTER TABLE "UserLesson" ADD CONSTRAINT "UserLesson_lessonId_fkey" FOREIGN KEY ("lessonId") REFERENCES "Lesson"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index faa87eb..855467b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -179,7 +179,7 @@ model UserLesson { userId String user User @relation(fields: [userId], references: [id]) lessonId String - lesson Lesson @relation(fields: [lessonId], references: [id]) + lesson Lesson @relation(fields: [lessonId], references: [id], onDelete: Cascade) opened Boolean @default(false) completed Boolean @default(false) openedAt DateTime? diff --git a/src/components/content/courses/CourseDetails.js b/src/components/content/courses/CourseDetails.js index fee1b5c..ddb4210 100644 --- a/src/components/content/courses/CourseDetails.js +++ b/src/components/content/courses/CourseDetails.js @@ -203,7 +203,7 @@ export default function CourseDetails({ processedEvent, paidCourse, lessons, dec {renderPaymentMessage()} {processedEvent?.pubkey === session?.user?.pubkey ? (
- router.push(`/details/${processedEvent.id}/edit`)} label="Edit" severity='warning' outlined /> + router.push(`/course/${processedEvent.d}/edit`)} label="Edit" severity='warning' outlined /> window.open(`https://nostr.band/${nAddress}`, '_blank')} tooltip={isMobileView ? null : "View Nostr Event"} tooltipOptions={{ position: paidCourse ? 'left' : 'right' }} />
diff --git a/src/components/forms/course/PublishedCourseForm.js b/src/components/forms/course/PublishedCourseForm.js new file mode 100644 index 0000000..4346a5e --- /dev/null +++ b/src/components/forms/course/PublishedCourseForm.js @@ -0,0 +1,227 @@ +import React, { useState, useEffect } from 'react'; +import { InputText } from 'primereact/inputtext'; +import { InputTextarea } from 'primereact/inputtextarea'; +import { InputNumber } from 'primereact/inputnumber'; +import { InputSwitch } from 'primereact/inputswitch'; +import GenericButton from '@/components/buttons/GenericButton'; +import LessonSelector from '@/components/forms/course/LessonSelector'; +import { parseEvent } from '@/utils/nostr'; +import { useRouter } from 'next/router'; +import { useNDKContext } from '@/context/NDKContext'; +import { useDraftsQuery } from '@/hooks/apiQueries/useDraftsQuery'; +import { useDocuments } from '@/hooks/nostr/useDocuments'; +import { useVideos } from '@/hooks/nostr/useVideos'; +import { useToast } from '@/hooks/useToast'; +import { NDKEvent } from '@nostr-dev-kit/ndk'; +import axios from 'axios'; +import dynamic from 'next/dynamic'; + +const PublishedCourseForm = ({ course }) => { + const [title, setTitle] = useState(course?.name || ''); + const [summary, setSummary] = useState(course?.description || ''); + const [content, setContent] = useState(course?.content || ''); + const [isPaidCourse, setIsPaidCourse] = useState(course?.price ? true : false); + const [price, setPrice] = useState(course?.price || 0); + const [coverImage, setCoverImage] = useState(course?.image || ''); + const [topics, setTopics] = useState(course?.topics || ['']); + const [lessons, setLessons] = useState([]); + const [lessonIds, setLessonIds] = useState([]); + const [allContent, setAllContent] = useState([]); + const [loading, setLoading] = useState(false); + + const router = useRouter(); + const { ndk, addSigner } = useNDKContext(); + const { showToast } = useToast(); + const { documents, documentsLoading, documentsError } = useDocuments(); + const { videos, videosLoading, videosError } = useVideos(); + const { drafts, draftsLoading, draftsError } = useDraftsQuery(); + + useEffect(() => { + if (!documentsLoading && !videosLoading && !draftsLoading) { + let combinedContent = []; + if (documents) { + combinedContent = [...combinedContent, ...documents]; + } + if (videos) { + combinedContent = [...combinedContent, ...videos]; + } + if (drafts) { + combinedContent = [...combinedContent, ...drafts]; + } + setAllContent(combinedContent); + } + }, [documents, videos, drafts, documentsLoading, videosLoading, draftsLoading]); + + useEffect(() => { + if (course) { + const aTags = course.tags.filter(tag => tag[0] === 'a'); + setLessonIds(aTags.map(tag => tag[1].split(':')[2])); + } + }, [course]); + + useEffect(() => { + if (lessonIds.length > 0 && allContent.length > 0) { + // get all dtags from allContent + const dTags = allContent.map(content => content?.tags?.find(tag => tag[0] === 'd')?.[1]); + // filter lessonIds to only include dTags and grab those full objects from allContent and parse them + const lessons = lessonIds.filter(id => dTags.includes(id)).map(id => parseEvent(allContent.find(content => content?.tags?.find(tag => tag[0] === 'd')?.[1] === id))); + setLessons(lessons); + } + }, [lessonIds, allContent]); + + const handleSubmit = async (e) => { + e.preventDefault(); + setLoading(true); + + try { + if (!ndk.signer) { + await addSigner(); + } + console.log('lessons', lessons); + + const event = new NDKEvent(ndk); + event.kind = course.kind; + event.content = content; + event.tags = [ + ['d', course.d], + ['name', title], + ['about', summary], + ['image', coverImage], + ...topics.filter(t => t.trim()).map(topic => ['t', topic.toLowerCase()]), + ['published_at', Math.floor(Date.now() / 1000).toString()], + ...(isPaidCourse ? [['price', price.toString()]] : []), + // Preserve existing lesson references + ...lessons.map(lesson => ['a', `${lesson.kind}:${lesson.pubkey}:${lesson.d}`]) + ]; + + await ndk.publish(event); + + // Update course in database + await axios.put(`/api/courses/${course.d}`, { + price: isPaidCourse ? price : 0, + lessons: lessons.map(lesson => ({ + resourceId: lesson.d, + draftId: null, + index: lessons.indexOf(lesson) + })) + }); + + showToast('success', 'Success', 'Course updated successfully'); + router.push(`/course/${course.d}`); + } catch (error) { + console.error('Error updating course:', error); + showToast('error', 'Error', 'Failed to update course'); + } finally { + setLoading(false); + } + }; + + const handleTopicChange = (index, value) => { + const updatedTopics = topics.map((topic, i) => i === index ? value : topic); + setTopics(updatedTopics); + }; + + const addTopic = (e) => { + e.preventDefault(); + setTopics([...topics, '']); + }; + + const removeTopic = (e, index) => { + e.preventDefault(); + const updatedTopics = topics.filter((_, i) => i !== index); + setTopics(updatedTopics); + }; + + return ( +
+
+ setTitle(e.target.value)} + placeholder="Title" + /> +
+ +
+ setSummary(e.target.value)} + placeholder="Summary" + rows={5} + /> +
+ +
+ setCoverImage(e.target.value)} + placeholder="Cover Image URL" + /> +
+ +
+

Paid Course

+ setIsPaidResource(e.value)} + /> + {isPaidCourse && ( +
+ setPrice(e.value)} + placeholder="Price (sats)" + min={1} + /> +
+ )} +
+ + + +
+ {topics.map((topic, index) => ( +
+ handleTopicChange(index, e.target.value)} + placeholder={`Topic #${index + 1}`} + className="w-full" + /> + {index > 0 && ( + removeTopic(e, index)} + /> + )} +
+ ))} + +
+ +
+ +
+ + ); +}; + +export default PublishedCourseForm; \ No newline at end of file diff --git a/src/db/models/courseModels.js b/src/db/models/courseModels.js index c2039d9..9953ae4 100644 --- a/src/db/models/courseModels.js +++ b/src/db/models/courseModels.js @@ -62,8 +62,8 @@ export const updateCourse = async (id, data) => { lessons: { deleteMany: {}, create: lessons.map((lesson, index) => ({ - resourceId: lesson.resourceId, - draftId: lesson.draftId, + resourceId: lesson.resourceId || lesson.d, + draftId: lesson.draftId || null, index: index })) } diff --git a/src/pages/course/[slug]/edit.js b/src/pages/course/[slug]/edit.js new file mode 100644 index 0000000..b0b0ff9 --- /dev/null +++ b/src/pages/course/[slug]/edit.js @@ -0,0 +1,69 @@ +import React, { useState, useEffect } from "react"; +import { useRouter } from "next/router"; +import { useNDKContext } from "@/context/NDKContext"; +import { useSession } from 'next-auth/react'; +import { parseCourseEvent } from "@/utils/nostr"; +import { ProgressSpinner } from 'primereact/progressspinner'; +import PublishedCourseForm from "@/components/forms/course/PublishedCourseForm"; +import { useToast } from "@/hooks/useToast"; + +const EditCourse = () => { + const [course, setCourse] = useState(null); + const [loading, setLoading] = useState(true); + const router = useRouter(); + const { ndk } = useNDKContext(); + const { data: session } = useSession(); + const { showToast } = useToast(); + + useEffect(() => { + if (!router.isReady || !session) return; + + const fetchCourse = async () => { + try { + const { slug } = router.query; + await ndk.connect(); + const event = await ndk.fetchEvent({ "#d": [slug] }); + + if (!event) { + showToast('error', 'Error', 'Course not found'); + router.push('/dashboard'); + return; + } + + // Check if user is the author + if (event.pubkey !== session.user.pubkey) { + showToast('error', 'Error', 'Unauthorized'); + router.push('/dashboard'); + return; + } + + const parsedCourse = parseCourseEvent(event); + setCourse(parsedCourse); + } catch (error) { + console.error('Error fetching course:', error); + showToast('error', 'Error', 'Failed to fetch course'); + } finally { + setLoading(false); + } + }; + + fetchCourse(); + }, [router.isReady, router.query, ndk, session, showToast, router]); + + if (loading) { + return
; + } + + if (!course) { + return null; + } + + return ( +
+

Edit Course

+ +
+ ); +}; + +export default EditCourse; \ No newline at end of file diff --git a/src/utils/nostr.js b/src/utils/nostr.js index 4c23cdd..680a7f8 100644 --- a/src/utils/nostr.js +++ b/src/utils/nostr.js @@ -163,6 +163,9 @@ export const parseCourseEvent = (event) => { case 'description': eventData.description = tag[1]; break; + case 'about': + eventData.description = tag[1]; + break; case 'image': eventData.image = tag[1]; break;