From b953b767852302c7f71ce13a212405f34a7f59bf Mon Sep 17 00:00:00 2001 From: austinkelsay Date: Fri, 9 Aug 2024 14:28:57 -0500 Subject: [PATCH] A lotta good stuff --- .../migration.sql | 35 ++++ prisma/schema.prisma | 14 ++ .../content/lists/ContentListItem.js | 23 ++- src/components/course/CourseDetails.js | 1 + src/components/course/DraftCourseDetails.js | 191 ++++++++++++++++++ src/components/course/DraftCourseLesson.js | 71 +++++++ src/components/forms/CourseForm.js | 168 +++++++-------- src/components/forms/ResourceForm.js | 6 +- src/db/models/courseDraftModels.js | 58 ++++++ src/db/models/resourceModels.js | 18 ++ src/db/models/userModels.js | 1 + src/hooks/apiQueries/useContentIdsQuery.js | 4 +- src/hooks/apiQueries/useDraftsQuery.js | 22 +- .../nostrQueries/content/useCoursesQuery.js | 44 ++-- .../nostrQueries/content/useResourcesQuery.js | 102 ++++------ .../nostrQueries/content/useWorkshopsQuery.js | 39 ++-- src/pages/api/auth/[...nextauth].js | 11 +- src/pages/api/courses/drafts/[slug].js | 56 +++++ src/pages/api/courses/drafts/[slug]/all.js | 20 ++ src/pages/api/courses/drafts/index.js | 18 ++ src/pages/api/users/[slug].js | 2 +- src/pages/auth/signin.js | 3 - src/pages/course/[slug]/draft.js | 90 +++++++++ .../course/{[slug].js => [slug]/index.js} | 0 src/pages/details/[slug]/index.js | 28 +-- src/pages/draft/[slug]/index.js | 2 +- src/utils/nostr.js | 1 - 27 files changed, 792 insertions(+), 236 deletions(-) rename prisma/migrations/{20240325154103_init => 20240809152643_init}/migration.sql (74%) create mode 100644 src/components/course/DraftCourseDetails.js create mode 100644 src/components/course/DraftCourseLesson.js create mode 100644 src/db/models/courseDraftModels.js create mode 100644 src/pages/api/courses/drafts/[slug].js create mode 100644 src/pages/api/courses/drafts/[slug]/all.js create mode 100644 src/pages/api/courses/drafts/index.js create mode 100644 src/pages/course/[slug]/draft.js rename src/pages/course/{[slug].js => [slug]/index.js} (100%) diff --git a/prisma/migrations/20240325154103_init/migration.sql b/prisma/migrations/20240809152643_init/migration.sql similarity index 74% rename from prisma/migrations/20240325154103_init/migration.sql rename to prisma/migrations/20240809152643_init/migration.sql index d8532eb..5c116ab 100644 --- a/prisma/migrations/20240325154103_init/migration.sql +++ b/prisma/migrations/20240809152643_init/migration.sql @@ -74,6 +74,26 @@ CREATE TABLE "Draft" ( CONSTRAINT "Draft_pkey" PRIMARY KEY ("id") ); +-- CreateTable +CREATE TABLE "CourseDraft" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "title" TEXT NOT NULL, + "summary" TEXT NOT NULL, + "image" TEXT, + "price" INTEGER DEFAULT 0, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "CourseDraft_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "_CourseDraftToResource" ( + "A" TEXT NOT NULL, + "B" TEXT NOT NULL +); + -- CreateIndex CREATE UNIQUE INDEX "User_pubkey_key" ON "User"("pubkey"); @@ -86,6 +106,12 @@ CREATE UNIQUE INDEX "Course_noteId_key" ON "Course"("noteId"); -- CreateIndex CREATE UNIQUE INDEX "Resource_noteId_key" ON "Resource"("noteId"); +-- CreateIndex +CREATE UNIQUE INDEX "_CourseDraftToResource_AB_unique" ON "_CourseDraftToResource"("A", "B"); + +-- CreateIndex +CREATE INDEX "_CourseDraftToResource_B_index" ON "_CourseDraftToResource"("B"); + -- AddForeignKey ALTER TABLE "User" ADD CONSTRAINT "User_roleId_fkey" FOREIGN KEY ("roleId") REFERENCES "Role"("id") ON DELETE SET NULL ON UPDATE CASCADE; @@ -109,3 +135,12 @@ ALTER TABLE "Resource" ADD CONSTRAINT "Resource_courseId_fkey" FOREIGN KEY ("cou -- AddForeignKey ALTER TABLE "Draft" ADD CONSTRAINT "Draft_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "CourseDraft" ADD CONSTRAINT "CourseDraft_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_CourseDraftToResource" ADD CONSTRAINT "_CourseDraftToResource_A_fkey" FOREIGN KEY ("A") REFERENCES "CourseDraft"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_CourseDraftToResource" ADD CONSTRAINT "_CourseDraftToResource_B_fkey" FOREIGN KEY ("B") REFERENCES "Resource"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index dea4080..1710cf5 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -16,6 +16,7 @@ model User { courses Course[] // Relation field added for courses created by the user resources Resource[] // Relation field added for resources created by the user drafts Draft[] // Relation field added for drafts created by the user + courseDrafts CourseDraft[] // Relation field added for course drafts created by the user role Role? @relation(fields: [roleId], references: [id]) roleId String? createdAt DateTime @default(now()) @@ -61,6 +62,7 @@ model Resource { courseId String? price Int @default(0) purchases Purchase[] + courseDrafts CourseDraft[] noteId String? @unique createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -81,3 +83,15 @@ model Draft { updatedAt DateTime @updatedAt } +model CourseDraft { + id String @id @default(uuid()) + userId String + user User @relation(fields: [userId], references: [id]) + resources Resource[] + title String + summary String + image String? + price Int? @default(0) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} diff --git a/src/components/content/lists/ContentListItem.js b/src/components/content/lists/ContentListItem.js index 40bdfe5..023618b 100644 --- a/src/components/content/lists/ContentListItem.js +++ b/src/components/content/lists/ContentListItem.js @@ -1,4 +1,4 @@ -import React from "react"; +import React, {useEffect} from "react"; import Image from "next/image"; import { Button } from "primereact/button"; import { useImageProxy } from "@/hooks/useImageProxy"; @@ -8,6 +8,25 @@ const ContentListItem = (content) => { const { returnImageProxy } = useImageProxy(); const router = useRouter(); const isDraft = Object.keys(content).includes('type'); + const isCourse = content && content?.resources && content?.resources?.length > 0; + + const handleClick = () => { + let path = ''; + + if (isDraft) { + path = '/draft'; + } else if (isCourse) { + path = '/course'; + } else { + path = '/details'; + } + + const draftSuffix = isCourse ? '/draft' : ''; + const fullPath = `${path}/${content.id}${draftSuffix}`; + + router.push(fullPath); + }; + return (
@@ -26,7 +45,7 @@ const ContentListItem = (content) => {
+ +
+ { + processedEvent?.content && + } +
+ + ); +} diff --git a/src/components/course/DraftCourseLesson.js b/src/components/course/DraftCourseLesson.js new file mode 100644 index 0000000..9fb7ab1 --- /dev/null +++ b/src/components/course/DraftCourseLesson.js @@ -0,0 +1,71 @@ +import React, { useEffect, useState } from "react"; +import { Tag } from "primereact/tag"; +import Image from "next/image"; +import { useImageProxy } from "@/hooks/useImageProxy"; +import dynamic from "next/dynamic"; + +const MDDisplay = dynamic( + () => import("@uiw/react-markdown-preview"), + { + ssr: false, + } +); + +const DraftCourseLesson = ({ lesson, course }) => { + const { returnImageProxy } = useImageProxy(); + + return ( +
+
+
+
+
+ {lesson && lesson.topics && lesson.topics.length > 0 && ( + lesson.topics.map((topic, index) => ( + + )) + )} +
+

{lesson?.title}

+

{lesson?.summary}

+ +
+
+ {lesson && ( +
+ resource thumbnail +
+ )} +
+
+
+
+ { + lesson?.content && + } +
+
+ ) +} + +export default DraftCourseLesson; diff --git a/src/components/forms/CourseForm.js b/src/components/forms/CourseForm.js index ad00e42..0a54f8a 100644 --- a/src/components/forms/CourseForm.js +++ b/src/components/forms/CourseForm.js @@ -44,94 +44,84 @@ const CourseForm = () => { } }, [session]); - /** - * Course Creation Flow: - * 1. Generate a new course ID - * 2. Process each lesson: - * - If unpublished: create event, publish to Nostr, save to DB, delete draft - * - If published: use existing data - * 3. Create and publish course event to Nostr - * 4. Save course to database - * 5. Show success message and redirect to course page - */ + useEffect(() => { + console.log('selectedLessons:', selectedLessons); + }, [selectedLessons]); - const handleSubmit = async (e) => { + const handleDraftSubmit = async (e) => { e.preventDefault(); - const newCourseId = uuidv4(); - const processedLessons = []; + // Prepare the lessons from selected lessons + const resources = await Promise.all(selectedLessons.map(async (lesson) => { + // if .type is present than this lesson is a draft we need to publish + if (lesson?.type) { + const event = createLessonEvent(lesson); + const published = await event.publish(); + + if (!published) { + throw new Error(`Failed to publish lesson: ${lesson.title}`); + } + + // Now post to resources + const resource = await axios.post('/api/resources', { + id: event.tags.find(tag => tag[0] === 'd')[1], + userId: user.id, + price: lesson.price || 0, + noteId: event.id, + }); + + if (resource.status !== 201) { + throw new Error(`Failed to post resource: ${lesson.title}`); + } + + // now delete the draft + const deleted = await axios.delete(`/api/drafts/${lesson.id}`); + + if (deleted.status !== 204) { + throw new Error(`Failed to delete draft: ${lesson.title}`); + } + + return { + id: lesson.id, + userId: user.id, + price: lesson.price || 0, + noteId: event.id, + } + } else { + return { + id: lesson.d, + userId: user.id, + price: lesson.price || 0, + noteId: lesson.id, + } + } + })); + + console.log('resources:', resources); + + const payload = { + userId: user.id, + title, + summary, + image: coverImage, + price: price || 0, + resources, // Send the array of lesson/resource IDs + }; + + console.log('payload:', payload); try { - // Step 1: Process lessons - for (const lesson of selectedLessons) { - let noteId = lesson.noteId; + // Post the course draft to the API + const response = await axios.post('/api/courses/drafts', payload); - if (!lesson.published_at) { - // Publish unpublished lesson - const event = createLessonEvent(lesson); - const published = await event.publish(); - - if (!published) { - throw new Error(`Failed to publish lesson: ${lesson.title}`); - } - - noteId = event.id; - - // Save to db and delete draft - await Promise.all([ - axios.post('/api/resources', { - id: lesson.id, - noteId: noteId, - userId: user.id, - price: lesson.price || 0, - }), - axios.delete(`/api/drafts/${lesson.id}`) - ]); - } - // if the lesson was already published we will have d tag, otherwise we will have id - // if the lesson was already published we will have kind tag, otherwise we will use price tag to determine the kind - // if the lesson was already published we will have pubkey tag, otherwise we will use user.pubkey - processedLessons.push({ - d: lesson?.d || lesson.id, - kind: lesson.kind ?? (lesson.price ? 30402 : 30023), - pubkey: lesson.pubkey || user.pubkey - }); - } - - // Step 2: Create and publish course - const courseEvent = createCourseEvent(newCourseId, title, summary, coverImage, processedLessons); - const published = await courseEvent.publish(); - - console.log('published', published); - - if (!published) { - throw new Error('Failed to publish course'); - } - - // Step 3: Save course to db - console.log('processedLessons:', processedLessons); - await axios.post('/api/courses', { - id: newCourseId, - resources: { - connect: processedLessons.map(lesson => ({ id: lesson?.d })) - }, - noteId: courseEvent.id, - user: { - connect: { id: user.id } - }, - price: price || 0 - }); - - // step 4: Update all resources to have the course id - await Promise.all(processedLessons.map(lesson => axios.put(`/api/resources/${lesson?.d}`, { courseId: newCourseId }))); - - // Step 5: Show success message and redirect - showToast('success', 'Course created successfully'); - router.push(`/course/${courseEvent.id}`); + console.log('response:', response); + // If successful, navigate to the course page + showToast('success', 'Course draft saved successfully'); + router.push(`/course/${response.data.id}/draft`); } catch (error) { - console.error('Error creating course:', error); - showToast('error', error.message || 'Failed to create course. Please try again.'); + console.error('Error saving course draft:', error); + showToast('error', 'Failed to save course draft. Please try again.'); } }; @@ -150,22 +140,6 @@ const CourseForm = () => { return event; }; - const createCourseEvent = (courseId, title, summary, coverImage, lessons) => { - const event = new NDKEvent(ndk); - event.kind = 30004; - event.content = ""; - event.tags = [ - ['d', courseId], - ['name', title], - ['picture', coverImage], - ['image', coverImage], - ['description', summary], - ['l', "Education"], - ...lessons.map((lesson) => ['a', `${lesson.kind}:${lesson.pubkey}:${lesson.d}`]), - ]; - return event; - }; - const handleLessonChange = (e, index) => { const selectedLessonId = e.value; const selectedLesson = getContentOptions(index).flatMap(group => group.items).find(lesson => lesson.value === selectedLessonId); @@ -258,7 +232,7 @@ const CourseForm = () => { } return ( -
+
setTitle(e.target.value)} placeholder="Title" />
diff --git a/src/components/forms/ResourceForm.js b/src/components/forms/ResourceForm.js index 3967a4d..b7d95bd 100644 --- a/src/components/forms/ResourceForm.js +++ b/src/components/forms/ResourceForm.js @@ -96,6 +96,7 @@ const ResourceForm = ({ draft = null, isPublished = false }) => { summary, price, content, + d: draft.d, image: coverImage, topics: [...topics.map(topic => topic.trim().toLowerCase()), 'plebdevs', 'resource'] } @@ -112,8 +113,11 @@ const ResourceForm = ({ draft = null, isPublished = false }) => { const published = await ndk.publish(event); if (published) { + // update the resource with new noteId + const response = await axios.put(`/api/resources/${draft.d}`, { noteId: event.id }); + console.log('response', response); showToast('success', 'Success', 'Resource published successfully.'); - router.push(`/resource/${event.id}`); + router.push(`/details/${event.id}`); } else { showToast('error', 'Error', 'Failed to publish resource. Please try again.'); } diff --git a/src/db/models/courseDraftModels.js b/src/db/models/courseDraftModels.js new file mode 100644 index 0000000..909552b --- /dev/null +++ b/src/db/models/courseDraftModels.js @@ -0,0 +1,58 @@ +import prisma from "@/db/prisma"; + +// Get all CourseDrafts for a specific user +export const getAllCourseDraftsByUserId = async (userId) => { + return await prisma.courseDraft.findMany({ + where: { userId }, + include: { + user: true, // Include the related user + resources: true, // Include related resources + }, + }); +}; + +// Get a specific CourseDraft by its ID +export const getCourseDraftById = async (id) => { + return await prisma.courseDraft.findUnique({ + where: { id }, + include: { + user: true, // Include the related user + resources: true, // Include related resources + }, + }); +}; + +// Create a new CourseDraft +export const createCourseDraft = async (data) => { + return await prisma.courseDraft.create({ + data: { + ...data, + resources: { + connect: data.resources.map((resource) => ({ id: resource.id })), + }, + }, + include: { + resources: true, + } + }); +}; + +// Update an existing CourseDraft by its ID +export const updateCourseDraft = async (id, data) => { + return await prisma.courseDraft.update({ + where: { id }, + data: { + ...data, + resources: { + set: data.resourceIds?.map((resourceId) => ({ id: resourceId })), + }, + }, + }); +}; + +// Delete a CourseDraft by its ID +export const deleteCourseDraft = async (id) => { + return await prisma.courseDraft.delete({ + where: { id }, + }); +}; diff --git a/src/db/models/resourceModels.js b/src/db/models/resourceModels.js index 78900a2..799bbaf 100644 --- a/src/db/models/resourceModels.js +++ b/src/db/models/resourceModels.js @@ -32,6 +32,24 @@ export async function isResourcePartOfAnyCourse(resourceId) { return courses.length > 0; } +export const updateLessonInCourse = async (courseId, resourceId, data) => { + return await prisma.course.update({ + where: { id: courseId }, + data: { + resources: { + update: { + where: { id: resourceId }, + data: { + title: data.title, + summary: data.summary, + // Add any other fields you want to update in the lesson + } + } + } + } + }); +}; + export const createResource = async (data) => { return await prisma.resource.create({ data, diff --git a/src/db/models/userModels.js b/src/db/models/userModels.js index 00b5b51..70d000e 100644 --- a/src/db/models/userModels.js +++ b/src/db/models/userModels.js @@ -51,6 +51,7 @@ export const createUser = async (data) => { }; export const updateUser = async (id, data) => { + console.log("user modelllll", id, data) return await prisma.user.update({ where: { id }, data, diff --git a/src/hooks/apiQueries/useContentIdsQuery.js b/src/hooks/apiQueries/useContentIdsQuery.js index 5767d6f..546d5fc 100644 --- a/src/hooks/apiQueries/useContentIdsQuery.js +++ b/src/hooks/apiQueries/useContentIdsQuery.js @@ -23,8 +23,8 @@ export function useContentIdsQuery() { const { data: contentIds, isLoading: contentIdsLoading, error: contentIdsError, refetch: refetchContentIds } = useQuery({ queryKey: ['contentIds', isClient], queryFn: fetchContentIdsDB, - staleTime: 1000 * 60 * 30, // 30 minutes - refetchInterval: 1000 * 60 * 30, // 30 minutes + // staleTime: 1000 * 60 * 30, // 30 minutes + // refetchInterval: 1000 * 60 * 30, // 30 minutes enabled: isClient }); diff --git a/src/hooks/apiQueries/useDraftsQuery.js b/src/hooks/apiQueries/useDraftsQuery.js index c7b2d39..9b841e9 100644 --- a/src/hooks/apiQueries/useDraftsQuery.js +++ b/src/hooks/apiQueries/useDraftsQuery.js @@ -20,15 +20,31 @@ export function useDraftsQuery() { const fetchDraftsDB = async () => { try { + let allDrafts = []; if (!user.id) { return []; } const response = await axios.get(`/api/drafts/all/${user.id}`); + if (response.status === 200) { + allDrafts = response.data; + const courseDrafts = await fetchCourseDrafts(); + allDrafts = [...allDrafts, ...courseDrafts]; + } + return allDrafts; + } catch (error) { + console.error('Error fetching drafts from DB:', error); + return []; + } + }; + + const fetchCourseDrafts = async () => { + try { + const response = await axios.get(`/api/courses/drafts/${user.id}/all`); const drafts = response.data; console.log('drafts:', drafts); return drafts; } catch (error) { - console.error('Error fetching drafts from DB:', error); + console.error('Error fetching course drafts from DB:', error); return []; } }; @@ -36,8 +52,8 @@ export function useDraftsQuery() { const { data: drafts, isLoading: draftsLoading, error: draftsError, refetch: refetchDrafts } = useQuery({ queryKey: ['drafts', isClient], queryFn: fetchDraftsDB, - staleTime: 1000 * 60 * 30, // 30 minutes - refetchInterval: 1000 * 60 * 30, // 30 minutes + // staleTime: 1000 * 60 * 30, // 30 minutes + // refetchInterval: 1000 * 60 * 30, // 30 minutes enabled: isClient && !!user.id, // Only enable if client-side and user ID is available }); diff --git a/src/hooks/nostrQueries/content/useCoursesQuery.js b/src/hooks/nostrQueries/content/useCoursesQuery.js index 80b626c..4bd55b1 100644 --- a/src/hooks/nostrQueries/content/useCoursesQuery.js +++ b/src/hooks/nostrQueries/content/useCoursesQuery.js @@ -1,45 +1,33 @@ import { useState, useEffect } from 'react'; import { useQuery } from '@tanstack/react-query'; import { useNDKContext } from '@/context/NDKContext'; -import { useContentIdsQuery } from '@/hooks/apiQueries/useContentIdsQuery'; +import axios from 'axios'; const AUTHOR_PUBKEY = process.env.NEXT_PUBLIC_AUTHOR_PUBKEY; export function useCoursesQuery() { const [isClient, setIsClient] = useState(false); - - const { contentIds, contentIdsLoading, contentIdsError, refetchContentIds } = useContentIdsQuery(); const ndk = useNDKContext(); useEffect(() => { setIsClient(true); }, []); - useEffect(() => { - refetchContentIds(); - }, [refetchContentIds]); - - const hasRequiredProperties = (event) => { - if (contentIdsLoading) { - return false; - } - - const hasCourseTag = event.tags.some(([tag, value]) => tag === "t" && value === "course"); - const hasId = contentIds.includes(event.id); - return hasCourseTag && hasId; + const hasRequiredProperties = (event, contentIds) => { + // currently no topic tag added + // const hasCourseTag = event.tags.some(([tag, value]) => tag === "t" && value === "course"); + const hasId = event.tags.some(([tag, value]) => tag === "d" && contentIds.includes(value)); + return hasId; }; const fetchCoursesFromNDK = async () => { try { - if (contentIdsLoading) { - return []; // or a loading state indication - } - if (contentIdsError) { - console.error('Error fetching content IDs:', contentIdsError); - return []; - } - if (!contentIds) { - return []; + const response = await axios.get(`/api/content/all`); + const contentIds = response.data; + + if (!contentIds || contentIds.length === 0) { + console.log('No content IDs found'); + return []; // Return early if no content IDs are found } await ndk.connect(); @@ -47,9 +35,11 @@ export function useCoursesQuery() { const filter = { kinds: [30004], authors: [AUTHOR_PUBKEY] }; const events = await ndk.fetchEvents(filter); + console.log('events', events); + if (events && events.size > 0) { const eventsArray = Array.from(events); - const courses = eventsArray.filter(event => hasRequiredProperties(event)); + const courses = eventsArray.filter(event => hasRequiredProperties(event, contentIds)); return courses; } return []; @@ -62,8 +52,8 @@ export function useCoursesQuery() { const { data: courses, isLoading: coursesLoading, error: coursesError, refetch: refetchCourses } = useQuery({ queryKey: ['courses', isClient], queryFn: fetchCoursesFromNDK, - staleTime: 1000 * 60 * 30, // 30 minutes - refetchInterval: 1000 * 60 * 30, // 30 minutes + // staleTime: 1000 * 60 * 30, // 30 minutes + // refetchInterval: 1000 * 60 * 30, // 30 minutes enabled: isClient, }); diff --git a/src/hooks/nostrQueries/content/useResourcesQuery.js b/src/hooks/nostrQueries/content/useResourcesQuery.js index bfaff7b..4f3ba70 100644 --- a/src/hooks/nostrQueries/content/useResourcesQuery.js +++ b/src/hooks/nostrQueries/content/useResourcesQuery.js @@ -1,73 +1,59 @@ import { useState, useEffect } from 'react'; import { useQuery } from '@tanstack/react-query'; import { useNDKContext } from '@/context/NDKContext'; -import { useContentIdsQuery } from '@/hooks/apiQueries/useContentIdsQuery'; +import axios from 'axios'; -const AUTHOR_PUBKEY = process.env.NEXT_PUBLIC_AUTHOR_PUBKEY +const AUTHOR_PUBKEY = process.env.NEXT_PUBLIC_AUTHOR_PUBKEY; export function useResourcesQuery() { - const [isClient, setIsClient] = useState(false); + const [isClient, setIsClient] = useState(false); + const ndk = useNDKContext(); - const { contentIds, contentIdsLoading, contentIdsError, refetchContentIds } = useContentIdsQuery(); - const ndk = useNDKContext(); + useEffect(() => { + setIsClient(true); + }, []); - useEffect(() => { - setIsClient(true); - }, []); + const hasRequiredProperties = (event, contentIds) => { + const hasPlebDevs = event.tags.some(([tag, value]) => tag === "t" && value === "plebdevs"); + const hasResource = event.tags.some(([tag, value]) => tag === "t" && value === "resource"); + const hasId = event.tags.some(([tag, value]) => tag === "d" && contentIds.includes(value)); + return hasPlebDevs && hasResource && hasId; + }; - useEffect(() => { - refetchContentIds(); - }, [refetchContentIds]); + const fetchResourcesFromNDK = async () => { + try { + const response = await axios.get(`/api/content/all`); + const contentIds = response.data; - const hasRequiredProperties = (event) => { - if (!contentIds) { - return false; - } + if (!contentIds || contentIds.length === 0) { + console.log('No content IDs found'); + return []; // Return early if no content IDs are found + } - const hasPlebDevs = event.tags.some(([tag, value]) => tag === "t" && value === "plebdevs"); - const hasWorkshop = event.tags.some(([tag, value]) => tag === "t" && value === "resource"); - const hasId = contentIds.includes(event.id); - return hasPlebDevs && hasWorkshop && hasId; - }; + await ndk.connect(); - const fetchResourcesFromNDK = async () => { - try { - if (contentIdsLoading) { - return []; // or a loading state indication - } - if (contentIdsError) { - console.error('Error fetching content IDs:', contentIdsError); - return []; - } - if (!contentIds) { - return []; - } - console.log('Fetching workshops from NDK'); - await ndk.connect(); + const filter = { kinds: [30023, 30402], authors: [AUTHOR_PUBKEY] }; + const events = await ndk.fetchEvents(filter); - const filter = { kinds: [30023, 30402], authors: [AUTHOR_PUBKEY] }; - const events = await ndk.fetchEvents(filter); + if (events && events.size > 0) { + const eventsArray = Array.from(events); + const resources = eventsArray.filter(event => hasRequiredProperties(event, contentIds)); + return resources; + } + return []; + } catch (error) { + console.error('Error fetching resources from NDK:', error); + return []; + } + }; - if (events && events.size > 0) { - const eventsArray = Array.from(events); - console.log('eventsArray', eventsArray) - const resources = eventsArray.filter(event => hasRequiredProperties(event)); - return resources; - } - return []; - } catch (error) { - console.error('Error fetching workshops from NDK:', error); - return []; - } - }; + const { data: resources, isLoading: resourcesLoading, error: resourcesError, refetch: refetchResources } = useQuery({ + queryKey: ['resources', isClient], + queryFn: fetchResourcesFromNDK, + // staleTime: 1000 * 60 * 30, // 30 minutes + // refetchInterval: 1000 * 60 * 30, // 30 minutes + enabled: isClient, + }); - const { data: resources, isLoading: resourcesLoading, error: resourcesError, refetch: refetchResources } = useQuery({ - queryKey: ['resources', isClient], - queryFn: fetchResourcesFromNDK, - staleTime: 1000 * 60 * 30, // 30 minutes - refetchInterval: 1000 * 60 * 30, // 30 minutes - enabled: isClient, - }) - - return { resources, resourcesLoading, resourcesError, refetchResources } -} \ No newline at end of file + return { resources, resourcesLoading, resourcesError, refetchResources }; +} diff --git a/src/hooks/nostrQueries/content/useWorkshopsQuery.js b/src/hooks/nostrQueries/content/useWorkshopsQuery.js index d1c07cd..af59866 100644 --- a/src/hooks/nostrQueries/content/useWorkshopsQuery.js +++ b/src/hooks/nostrQueries/content/useWorkshopsQuery.js @@ -1,47 +1,35 @@ import { useState, useEffect } from 'react'; import { useQuery } from '@tanstack/react-query'; import { useNDKContext } from '@/context/NDKContext'; -import { useContentIdsQuery } from '@/hooks/apiQueries/useContentIdsQuery'; +import axios from 'axios'; const AUTHOR_PUBKEY = process.env.NEXT_PUBLIC_AUTHOR_PUBKEY; export function useWorkshopsQuery() { const [isClient, setIsClient] = useState(false); - - const { contentIds, contentIdsLoading, contentIdsError, refetchContentIds } = useContentIdsQuery(); const ndk = useNDKContext(); useEffect(() => { setIsClient(true); }, []); - useEffect(() => { - refetchContentIds(); - }, [refetchContentIds]); - - const hasRequiredProperties = (event) => { - if (contentIdsLoading) { - return false; - } + const hasRequiredProperties = (event, contentIds) => { const hasPlebDevs = event.tags.some(([tag, value]) => tag === "t" && value === "plebdevs"); const hasWorkshop = event.tags.some(([tag, value]) => tag === "t" && value === "workshop"); - const hasId = contentIds.includes(event.id); + const hasId = event.tags.some(([tag, value]) => tag === "d" && contentIds.includes(value)); return hasPlebDevs && hasWorkshop && hasId; }; const fetchWorkshopsFromNDK = async () => { try { - if (contentIdsLoading) { - return []; // or a loading state indication + const response = await axios.get(`/api/content/all`); + const contentIds = response.data; + + if (!contentIds || contentIds.length === 0) { + console.log('No content IDs found'); + return []; // Return early if no content IDs are found } - if (contentIdsError) { - console.error('Error fetching content IDs:', contentIdsError); - return []; - } - if (!contentIds) { - return []; - } - console.log('Fetching workshops from NDK'); + await ndk.connect(); const filter = { kinds: [30023, 30402], authors: [AUTHOR_PUBKEY] }; @@ -49,8 +37,7 @@ export function useWorkshopsQuery() { if (events && events.size > 0) { const eventsArray = Array.from(events); - console.log('eventsArray', eventsArray); - const workshops = eventsArray.filter(event => hasRequiredProperties(event)); + const workshops = eventsArray.filter(event => hasRequiredProperties(event, contentIds)); return workshops; } return []; @@ -63,8 +50,8 @@ export function useWorkshopsQuery() { const { data: workshops, isLoading: workshopsLoading, error: workshopsError, refetch: refetchWorkshops } = useQuery({ queryKey: ['workshops', isClient], queryFn: fetchWorkshopsFromNDK, - staleTime: 1000 * 60 * 30, // 30 minutes - refetchInterval: 1000 * 60 * 30, // 30 minutes + // staleTime: 1000 * 60 * 30, // 30 minutes + // refetchInterval: 1000 * 60 * 30, // 30 minutes enabled: isClient, }); diff --git a/src/pages/api/auth/[...nextauth].js b/src/pages/api/auth/[...nextauth].js index 9eee582..8842ecd 100644 --- a/src/pages/api/auth/[...nextauth].js +++ b/src/pages/api/auth/[...nextauth].js @@ -41,7 +41,14 @@ export default NextAuth({ const response = await axios.get(`${BASE_URL}/api/users/${credentials.pubkey}`); if (response.status === 200 && response.data) { const fields = await findKind0Fields(profile); - return { pubkey: credentials.pubkey, ...fields }; + + // Combine user object with kind0Fields, giving priority to kind0Fields + const combinedUser = { ...fields, ...response.data }; + + // Update the user on the backend if necessary + // await axios.put(`${BASE_URL}/api/users/${combinedUser.id}`, combinedUser); + + return combinedUser; } else if (response.status === 204) { // Create user if (profile) { @@ -63,7 +70,7 @@ export default NextAuth({ ], callbacks: { async jwt({ token, user }) { - // Add user to the token if user object exists + // Add combined user object to the token if (user) { token.user = user; } diff --git a/src/pages/api/courses/drafts/[slug].js b/src/pages/api/courses/drafts/[slug].js new file mode 100644 index 0000000..2ffbc52 --- /dev/null +++ b/src/pages/api/courses/drafts/[slug].js @@ -0,0 +1,56 @@ +import { getAllCourseDraftsByUserId, getCourseDraftById, updateCourseDraft, deleteCourseDraft } from "@/db/models/courseDraftModels"; + +export default async function handler(req, res) { + const { slug } = req.query; + console.log('slug:', slug); + const userId = req.body?.userId || req.query?.userId; + console.log('userId:', userId); + + if (req.method === 'GET') { + if (slug && !userId) { + try { + const courseDraft = await getCourseDraftById(slug); + if (courseDraft) { + res.status(200).json(courseDraft); + } else { + res.status(404).json({ error: 'Course draft not found' }); + } + } catch (error) { + res.status(500).json({ error: error.message }); + } + } else if (userId) { + try { + console.log('INHEEEERE:', userId); + const courseDrafts = await getAllCourseDraftsByUserId(userId); + res.status(200).json(courseDrafts); + } catch (error) { + res.status(500).json({ error: error.message }); + } + } else { + res.status(400).json({ error: 'User ID is required' }); + } + } else if (req.method === 'PUT') { + if (!slug) { + return res.status(400).json({ error: 'Slug is required to update a course draft' }); + } + try { + const updatedCourseDraft = await updateCourseDraft(slug, req.body); + res.status(200).json(updatedCourseDraft); + } catch (error) { + res.status(400).json({ error: error.message }); + } + } else if (req.method === 'DELETE') { + if (!slug) { + return res.status(400).json({ error: 'Slug is required to delete a course draft' }); + } + try { + await deleteCourseDraft(slug); + res.status(204).end(); + } catch (error) { + res.status(500).json({ error: error.message }); + } + } else { + res.setHeader('Allow', ['GET', 'PUT', 'DELETE']); + res.status(405).end(`Method ${req.method} Not Allowed`); + } +} diff --git a/src/pages/api/courses/drafts/[slug]/all.js b/src/pages/api/courses/drafts/[slug]/all.js new file mode 100644 index 0000000..d1aaaa5 --- /dev/null +++ b/src/pages/api/courses/drafts/[slug]/all.js @@ -0,0 +1,20 @@ +import { getAllCourseDraftsByUserId } from "@/db/models/courseDraftModels"; + +export default async function handler(req, res) { + // the slug here is user id to get all drafts for a given user + const {slug} = req.query; + if (req.method === 'GET') { + if (slug) { + try { + const courseDrafts = await getAllCourseDraftsByUserId(slug); + res.status(200).json(courseDrafts); + } catch (error) { + res.status(500).json({ error: error.message }); + } + } else { + res.status(400).json({ error: 'User ID is required' }); + } + } else { + res.status(405).json({ error: 'Method not allowed' }); + } +} \ No newline at end of file diff --git a/src/pages/api/courses/drafts/index.js b/src/pages/api/courses/drafts/index.js new file mode 100644 index 0000000..fd85a69 --- /dev/null +++ b/src/pages/api/courses/drafts/index.js @@ -0,0 +1,18 @@ +import { createCourseDraft } from "@/db/models/courseDraftModels"; + +export default async function handler(req, res) { + if (req.method === 'POST') { + try { + if (!req.body || !req.body.userId) { + return res.status(400).json({ error: 'User ID is required' }); + } + + const newCourseDraft = await createCourseDraft(req.body); + res.status(201).json(newCourseDraft); + } catch (error) { + res.status(400).json({ error: error.message }); + } + } else { + res.status(405).json({ error: 'Method not allowed' }); + } +} \ No newline at end of file diff --git a/src/pages/api/users/[slug].js b/src/pages/api/users/[slug].js index 34c2811..c602fc8 100644 --- a/src/pages/api/users/[slug].js +++ b/src/pages/api/users/[slug].js @@ -32,7 +32,7 @@ export default async function handler(req, res) { case 'PUT': if (!isPubkey) { // Update operation should be done with an ID, not a pubkey - const updatedUser = await updateUser(parseInt(slug), req.body); + const updatedUser = await updateUser(slug, req.body); res.status(200).json(updatedUser); } else { // Handle attempt to update user with pubkey diff --git a/src/pages/auth/signin.js b/src/pages/auth/signin.js index 668a894..647d430 100644 --- a/src/pages/auth/signin.js +++ b/src/pages/auth/signin.js @@ -11,8 +11,6 @@ export default function SignIn() { const { data: session, status } = useSession(); // Get the current session's data and status - // const ndk = useNDKContext() - useEffect(() => { console.log("session", session) }, [session]) @@ -33,7 +31,6 @@ export default function SignIn() { try { const user = await nip07signer.user() - console.log("user in signin", user) const pubkey = user?._pubkey signIn("nostr", { pubkey }) } catch (error) { diff --git a/src/pages/course/[slug]/draft.js b/src/pages/course/[slug]/draft.js new file mode 100644 index 0000000..f6985bf --- /dev/null +++ b/src/pages/course/[slug]/draft.js @@ -0,0 +1,90 @@ +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/course/DraftCourseDetails"; +import DraftCourseLesson from "@/components/course/DraftCourseLesson"; +import dynamic from 'next/dynamic'; +import { useNDKContext } from "@/context/NDKContext"; + +const MDDisplay = dynamic( + () => import("@uiw/react-markdown-preview"), + { + ssr: false, + } +); + +const DraftCourse = () => { + const [course, setCourse] = useState(null); + const [lessons, setLessons] = useState([]); + const [lessonsWithAuthors, setLessonsWithAuthors] = useState([]); + + const router = useRouter(); + const ndk = useNDKContext(); + + const fetchAuthor = useCallback(async (pubkey) => { + if (!pubkey) return; + const author = await ndk.getUser({ pubkey }); + const profile = await author.fetchProfile(); + const fields = await findKind0Fields(profile); + if (fields) { + return fields; + } + return null; // Return null if no fields found + }, [ndk]); + + useEffect(() => { + if (router.isReady) { + const { slug } = router.query; + + axios.get(`/api/courses/drafts/${slug}`) + .then(res => { + console.log('res:', res.data); + setCourse(res.data); + setLessons(res.data.resources); // Set the raw lessons + }) + .catch(err => { + console.error(err); + }); + } + }, [router.isReady, router.query]); + + useEffect(() => { + const fetchLessonDetails = async () => { + if (lessons.length > 0) { + await ndk.connect(); + + const newLessonsWithAuthors = await Promise.all(lessons.map(async (lesson) => { + const filter = { + "#d": [lesson.id] + }; + + const event = await ndk.fetchEvent(filter); + if (event) { + const author = await fetchAuthor(event.pubkey); + return { + ...parseEvent(event), + author + }; + } + return lesson; // Fallback to the original lesson if no event found + })); + + setLessonsWithAuthors(newLessonsWithAuthors); + } + }; + + fetchLessonDetails(); + }, [lessons, ndk, fetchAuthor]); + + return ( + <> + + {lessonsWithAuthors.length > 0 && lessonsWithAuthors.map((lesson, index) => ( + + ))} + + ); +} + +export default DraftCourse; diff --git a/src/pages/course/[slug].js b/src/pages/course/[slug]/index.js similarity index 100% rename from src/pages/course/[slug].js rename to src/pages/course/[slug]/index.js diff --git a/src/pages/details/[slug]/index.js b/src/pages/details/[slug]/index.js index 935db59..3bf7d7c 100644 --- a/src/pages/details/[slug]/index.js +++ b/src/pages/details/[slug]/index.js @@ -16,6 +16,7 @@ import { useToast } from '@/hooks/useToast'; import { useNDKContext } from '@/context/NDKContext'; import { useZapsSubscription } from '@/hooks/nostrQueries/zaps/useZapsSubscription'; import 'primeicons/primeicons.css'; + const MDDisplay = dynamic( () => import("@uiw/react-markdown-preview"), { @@ -31,6 +32,7 @@ const BitcoinConnectPayButton = dynamic( ); const privkey = process.env.NEXT_PUBLIC_APP_PRIV_KEY; +const pubkey = process.env.NEXT_PUBLIC_APP_PUBLIC_KEY; export default function Details() { const [event, setEvent] = useState(null); @@ -54,6 +56,7 @@ export default function Details() { useEffect(() => { if (session) { + console.log('session:', session); setUser(session.user); } }, [session]); @@ -77,17 +80,14 @@ export default function Details() { useEffect(() => { const decryptContent = async () => { if (user && paidResource) { - if (!user.purchased.includes(processedEvent.id)) { - // decrypt the content - console.log('privkey', privkey); - console.log('user.pubkey', user.pubkey); - console.log('processedEvent.content', processedEvent.content); - const decryptedContent = await nip04.decrypt(privkey, user.pubkey, processedEvent.content); - console.log('decryptedContent', decryptedContent); - setDecryptedContent(decryptedContent); + if (user.purchased.includes(processedEvent.id) || (user?.role && user?.role.subscribed)) { + // decrypt the content + const decryptedContent = await nip04.decrypt(privkey, pubkey, processedEvent.content); + console.log('decryptedContent', decryptedContent); + setDecryptedContent(decryptedContent); + } } } - } decryptContent(); }, [user, paidResource, processedEvent]); @@ -181,7 +181,7 @@ export default function Details() { const handleDelete = async () => { try { - const response = await axios.delete(`/api/resources/${processedEvent.id}`); + const response = await axios.delete(`/api/resources/${processedEvent.d}`); if (response.status === 204) { showToast('success', 'Success', 'Resource deleted successfully.'); router.push('/'); @@ -274,9 +274,13 @@ export default function Details() { )}
{ - processedEvent?.content && + decryptedContent ? ( + + ) : ( + processedEvent?.content && + ) }
); -} \ No newline at end of file +} diff --git a/src/pages/draft/[slug]/index.js b/src/pages/draft/[slug]/index.js index bb83ef5..1a8828d 100644 --- a/src/pages/draft/[slug]/index.js +++ b/src/pages/draft/[slug]/index.js @@ -84,7 +84,7 @@ export default function Draft() { return; } - console.log('unsignedEvent:', unsignedEvent.validate()); + console.log('unsignedEvent:', unsignedEvent.validate(), unsignedEvent); console.log('unsignedEvent validation:', validationResult); if (unsignedEvent) { diff --git a/src/utils/nostr.js b/src/utils/nostr.js index 5613d25..e6f6522 100644 --- a/src/utils/nostr.js +++ b/src/utils/nostr.js @@ -1,7 +1,6 @@ import { nip19 } from "nostr-tools"; export const findKind0Fields = async (kind0) => { - console.log('kind0', kind0); let fields = {} const usernameProperties = ['name', 'displayName', 'display_name', 'username', 'handle', 'alias'];