diff --git a/src/components/content/courses/CourseDetails.js b/src/components/content/courses/CourseDetails.js
index 669e25c..1b0dc74 100644
--- a/src/components/content/courses/CourseDetails.js
+++ b/src/components/content/courses/CourseDetails.js
@@ -38,6 +38,10 @@ export default function CourseDetails({ processedEvent, paidCourse, lessons, dec
console.log("processedEvent", processedEvent);
}, [processedEvent]);
+ useEffect(() => {
+ console.log("lessons", lessons);
+ }, [lessons]);
+
useEffect(() => {
console.log("zaps", zaps);
}, [zaps]);
diff --git a/src/components/content/courses/DraftCourseDetails.js b/src/components/content/courses/DraftCourseDetails.js
index bf9ec73..684f51a 100644
--- a/src/components/content/courses/DraftCourseDetails.js
+++ b/src/components/content/courses/DraftCourseDetails.js
@@ -1,4 +1,4 @@
-import React, { useEffect, useState, useCallback } from 'react';
+import React, { useEffect, useState, useCallback, useRef } from 'react';
import { useRouter } from 'next/router';
import { useImageProxy } from '@/hooks/useImageProxy';
import { Tag } from 'primereact/tag';
@@ -6,6 +6,7 @@ import { Button } from 'primereact/button';
import Image from 'next/image';
import dynamic from 'next/dynamic';
import axios from 'axios';
+import { nip04, nip19 } from 'nostr-tools';
import { v4 as uuidv4 } from 'uuid';
import { useSession } from 'next-auth/react';
import { useNDKContext } from "@/context/NDKContext";
@@ -21,9 +22,30 @@ const MDDisplay = dynamic(
}
);
+function validateEvent(event) {
+ if (typeof event.kind !== "number") return "Invalid kind";
+ if (typeof event.content !== "string") return "Invalid content";
+ if (typeof event.created_at !== "number") return "Invalid created_at";
+ if (typeof event.pubkey !== "string") return "Invalid pubkey";
+ if (!event.pubkey.match(/^[a-f0-9]{64}$/)) return "Invalid pubkey format";
+
+ if (!Array.isArray(event.tags)) return "Invalid tags";
+ for (let i = 0; i < event.tags.length; i++) {
+ const tag = event.tags[i];
+ if (!Array.isArray(tag)) return "Invalid tag structure";
+ for (let j = 0; j < tag.length; j++) {
+ if (typeof tag[j] === "object") return "Invalid tag value";
+ }
+ }
+
+ return true;
+}
+
export default function DraftCourseDetails({ processedEvent, draftId, lessons }) {
const [author, setAuthor] = useState(null);
const [user, setUser] = useState(null);
+ const [processedLessons, setProcessedLessons] = useState([]);
+ const hasRunEffect = useRef(false);
const { showToast } = useToast();
const { returnImageProxy } = useImageProxy();
@@ -41,6 +63,10 @@ export default function DraftCourseDetails({ processedEvent, draftId, lessons })
}
}, [ndk]);
+ useEffect(() => {
+ console.log('lessons in comp', lessons);
+ }, [lessons]);
+
useEffect(() => {
if (processedEvent) {
fetchAuthor(processedEvent?.user?.pubkey);
@@ -55,33 +81,98 @@ export default function DraftCourseDetails({ processedEvent, draftId, lessons })
const handleDelete = () => {
axios.delete(`/api/courses/drafts/${processedEvent.id}`)
- .then(() => {
- showToast('success', 'Success', 'Draft Course deleted successfully');
- router.push('/');
- })
- .catch((error) => {
- showToast('error', 'Error', 'Failed to delete draft course');
- });
+ .then(() => {
+ showToast('success', 'Success', 'Draft Course deleted successfully');
+ router.push('/');
+ })
+ .catch((error) => {
+ showToast('error', 'Error', 'Failed to delete draft course');
+ });
}
+ const handlePostResource = async (resource) => {
+ console.log('resourceeeeee:', resource.tags);
+ const dTag = resource.tags.find(tag => tag[0] === 'd')[1];
+ let price
+
+ try {
+ price = resource.tags.find(tag => tag[0] === 'price')[1];
+ } catch (err) {
+ price = 0;
+ }
+
+ const nAddress = nip19.naddrEncode({
+ pubkey: resource.pubkey,
+ kind: resource.kind,
+ identifier: dTag,
+ });
+
+ const userResponse = await axios.get(`/api/users/${user.pubkey}`);
+
+ if (!userResponse.data) {
+ showToast('error', 'Error', 'User not found', 'Please try again.');
+ return;
+ }
+
+ const payload = {
+ id: dTag,
+ userId: userResponse.data.id,
+ price: Number(price),
+ noteId: nAddress
+ };
+
+ const response = await axios.post(`/api/resources`, payload);
+
+ if (response.status !== 201) {
+ showToast('error', 'Error', 'Failed to create resource. Please try again.');
+ return;
+ }
+
+ return response.data;
+ };
+
const handleSubmit = async (e) => {
e.preventDefault();
const newCourseId = uuidv4();
- const processedLessons = [];
try {
// Step 0: Add signer if not already added
if (!ndk.signer) {
await addSigner();
- }
+ }
// Step 1: Process lessons
- for (const lesson of lessons) {
- processedLessons.push({
- d: lesson?.d,
- kind: lesson?.price ? 30402 : 30023,
- pubkey: lesson.pubkey
- });
+ for (const lesson of processedLessons) {
+ // publish any draft lessons and delete draft lessons
+ const unpublished = lesson?.unpublished;
+ if (unpublished && Object.keys(unpublished).length > 0) {
+ const validationResult = validateEvent(unpublished);
+ if (validationResult !== true) {
+ console.error('Invalid event:', validationResult);
+ showToast('error', 'Error', `Invalid event: ${validationResult}`);
+ return;
+ }
+
+ const published = await unpublished.publish();
+
+ const saved = await handlePostResource(unpublished);
+
+ console.log('saved', saved);
+
+ if (published && saved) {
+ axios.delete(`/api/drafts/${lesson?.d}`)
+ .then(res => {
+ if (res.status === 204) {
+ showToast('success', 'Success', 'Draft deleted successfully.');
+ } else {
+ showToast('error', 'Error', 'Failed to delete draft.');
+ }
+ })
+ .catch(err => {
+ console.error(err);
+ });
+ }
+ }
}
// Step 2: Create and publish course
@@ -95,7 +186,6 @@ export default function DraftCourseDetails({ processedEvent, draftId, lessons })
}
// Step 3: Save course to db
- console.log('processedLessons:', processedLessons);
await axios.post('/api/courses', {
id: newCourseId,
resources: {
@@ -141,6 +231,99 @@ export default function DraftCourseDetails({ processedEvent, draftId, lessons })
return event;
};
+ useEffect(() => {
+ async function buildEvent(draft) {
+ const event = new NDKEvent(ndk);
+ let type;
+ let encryptedContent;
+
+ console.log('Draft:', draft);
+
+ switch (draft?.type) {
+ case 'resource':
+ if (draft?.price) {
+ // encrypt the content with NEXT_PUBLIC_APP_PRIV_KEY to NEXT_PUBLIC_APP_PUBLIC_KEY
+ encryptedContent = await nip04.encrypt(process.env.NEXT_PUBLIC_APP_PRIV_KEY, process.env.NEXT_PUBLIC_APP_PUBLIC_KEY, draft.content);
+ }
+
+ event.kind = draft?.price ? 30402 : 30023; // Determine kind based on if price is present
+ event.content = draft?.price ? encryptedContent : draft.content;
+ event.created_at = Math.floor(Date.now() / 1000);
+ event.pubkey = user.pubkey;
+ event.tags = [
+ ['d', draft.id],
+ ['title', draft.title],
+ ['summary', draft.summary],
+ ['image', draft.image],
+ ...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}`]] : []),
+ ];
+
+ type = 'resource';
+ break;
+ case 'workshop':
+ if (draft?.price) {
+ // encrypt the content with NEXT_PUBLIC_APP_PRIV_KEY to NEXT_PUBLIC_APP_PUBLIC_KEY
+ encryptedContent = await nip04.encrypt(process.env.NEXT_PUBLIC_APP_PRIV_KEY, process.env.NEXT_PUBLIC_APP_PUBLIC_KEY, draft.content);
+ }
+
+ event.kind = draft?.price ? 30402 : 30023;
+ event.content = draft?.price ? encryptedContent : draft.content;
+ event.created_at = Math.floor(Date.now() / 1000);
+ event.pubkey = user.pubkey;
+ event.tags = [
+ ['d', draft.id],
+ ['title', draft.title],
+ ['summary', draft.summary],
+ ['image', draft.image],
+ ...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}`]] : []),
+ ];
+
+ type = 'workshop';
+ break;
+ default:
+ return null;
+ }
+
+ return { unsignedEvent: event, type };
+ }
+
+ async function buildDraftEvent(lesson) {
+ const { unsignedEvent, type } = await buildEvent(lesson);
+ return unsignedEvent
+ }
+
+ if (!hasRunEffect.current && lessons.length > 0 && user && author) {
+ hasRunEffect.current = true;
+
+ lessons.forEach(async (lesson) => {
+ const isDraft = !lesson?.pubkey;
+ if (isDraft) {
+ const unsignedEvent = await buildDraftEvent(lesson);
+ setProcessedLessons(prev => [...prev, {
+ d: lesson?.id,
+ kind: lesson?.price ? 30402 : 30023,
+ pubkey: unsignedEvent.pubkey,
+ unpublished: unsignedEvent
+ }]);
+ } else {
+ setProcessedLessons(prev => [...prev, {
+ d: lesson?.d,
+ kind: lesson?.price ? 30402 : 30023,
+ pubkey: lesson.pubkey
+ }]);
+ }
+ });
+ }
+ }, [lessons, user, author, ndk]);
+
+ useEffect(() => {
+ console.log('processedLessons', processedLessons);
+ }, [processedLessons]);
+
return (
diff --git a/src/components/content/lists/ContentListItem.js b/src/components/content/lists/ContentListItem.js
index 0f6cd33..e3c418a 100644
--- a/src/components/content/lists/ContentListItem.js
+++ b/src/components/content/lists/ContentListItem.js
@@ -9,6 +9,7 @@ const ContentListItem = (content) => {
const router = useRouter();
const isDraft = Object.keys(content).includes('type');
const isCourse = content && content?.kind === 30004;
+ const isCourseDraft = content && content?.resources?.length > 0 && !content?.kind;
const handleClick = () => {
let path = '';
@@ -17,11 +18,13 @@ const ContentListItem = (content) => {
path = '/draft';
} else if (isCourse) {
path = '/course';
+ } else if (isCourseDraft) {
+ path = `/course/${content.id}/draft`
+ return router.push(path);
} else {
path = '/details';
}
- // const draftSuffix = isCourse ? '/draft' : '';
const fullPath = `${path}/${content.id}`;
router.push(fullPath);
diff --git a/src/components/forms/EditCourseForm.js b/src/components/forms/EditCourseForm.js
index 5956298..0e011e7 100644
--- a/src/components/forms/EditCourseForm.js
+++ b/src/components/forms/EditCourseForm.js
@@ -18,6 +18,7 @@ import ContentDropdownItem from "@/components/content/dropdowns/ContentDropdownI
import 'primeicons/primeicons.css';
// todo dealing with adding drafts as new lessons
+// todo disable ability to add a free lesson to a paid course and vice versa (or just make the user remove the lesson if they want to change the price)
// todo deal with error where 2 new lessons popup when only one is added from the dropdown
// todo on edit lessons need to make sure that the user is still choosing the order those lessons appear in the course
const EditCourseForm = ({ draft }) => {
diff --git a/src/components/profile/UserContent.js b/src/components/profile/UserContent.js
index 6f7962d..bbf0bdb 100644
--- a/src/components/profile/UserContent.js
+++ b/src/components/profile/UserContent.js
@@ -6,6 +6,7 @@ import { useCoursesQuery } from "@/hooks/nostrQueries/content/useCoursesQuery";
import { useResourcesQuery } from "@/hooks/nostrQueries/content/useResourcesQuery";
import { useWorkshopsQuery } from "@/hooks/nostrQueries/content/useWorkshopsQuery";
import { useDraftsQuery } from "@/hooks/apiQueries/useDraftsQuery";
+import { useCourseDraftsQuery } from "@/hooks/apiQueries/useCourseDraftsQuery";
import { useContentIdsQuery } from "@/hooks/apiQueries/useContentIdsQuery";
import { useSession } from "next-auth/react";
import { useToast } from "@/hooks/useToast";
@@ -29,6 +30,7 @@ const UserContent = () => {
const { courses, coursesLoading, coursesError } = useCoursesQuery();
const { resources, resourcesLoading, resourcesError } = useResourcesQuery();
const { workshops, workshopsLoading, workshopsError } = useWorkshopsQuery();
+ const { courseDrafts, courseDraftsLoading, courseDraftsError } = useCourseDraftsQuery();
const { drafts, draftsLoading, draftsError } = useDraftsQuery();
const { contentIds, contentIdsLoading, contentIdsError, refetchContentIds } = useContentIdsQuery();
@@ -45,7 +47,8 @@ const UserContent = () => {
const contentItems = [
{ label: "Published", icon: "pi pi-verified" },
{ label: "Drafts", icon: "pi pi-file-edit" },
- { label: "Resources", icon: "pi pi-book" },
+ { label: "Draft Courses", icon: "pi pi-book" },
+ { label: "Resources", icon: "pi pi-file" },
{ label: "Workshops", icon: "pi pi-video" },
{ label: "Courses", icon: "pi pi-desktop" },
];
@@ -90,6 +93,8 @@ const UserContent = () => {
case 1:
return drafts || [];
case 2:
+ return courseDrafts || [];
+ case 3:
return resources?.map(parseEvent) || [];
case 3:
return workshops?.map(parseEvent) || [];
@@ -102,10 +107,10 @@ const UserContent = () => {
setContent(getContentByIndex(activeIndex));
}
- }, [activeIndex, isClient, drafts, resources, workshops, courses, publishedContent])
+ }, [activeIndex, isClient, drafts, resources, workshops, courses, publishedContent, courseDrafts])
- const isLoading = coursesLoading || resourcesLoading || workshopsLoading || draftsLoading || contentIdsLoading;
- const isError = coursesError || resourcesError || workshopsError || draftsError || contentIdsError;
+ const isLoading = coursesLoading || resourcesLoading || workshopsLoading || draftsLoading || contentIdsLoading || courseDraftsLoading;
+ const isError = coursesError || resourcesError || workshopsError || draftsError || contentIdsError || courseDraftsError;
return (
diff --git a/src/db/models/courseDraftModels.js b/src/db/models/courseDraftModels.js
index 5c904df..15bb0ee 100644
--- a/src/db/models/courseDraftModels.js
+++ b/src/db/models/courseDraftModels.js
@@ -18,6 +18,7 @@ export const getCourseDraftById = async (id) => {
include: {
user: true, // Include the related user
resources: true, // Include related resources
+ drafts: true, // Include related drafts
},
});
};
diff --git a/src/hooks/apiQueries/useCourseDraftsQuery.js b/src/hooks/apiQueries/useCourseDraftsQuery.js
new file mode 100644
index 0000000..fae3125
--- /dev/null
+++ b/src/hooks/apiQueries/useCourseDraftsQuery.js
@@ -0,0 +1,46 @@
+import { useState, useEffect } from 'react';
+import { useQuery } from '@tanstack/react-query';
+import axios from 'axios';
+import { useSession } from 'next-auth/react';
+
+export function useCourseDraftsQuery() {
+ const [isClient, setIsClient] = useState(false);
+ const { data: session, status } = useSession();
+ const [user, setUser] = useState(null);
+
+ useEffect(() => {
+ if (session) {
+ setUser(session.user);
+ }
+ }, [session]);
+
+ useEffect(() => {
+ setIsClient(true);
+ }, []);
+
+ const fetchCourseDrafts = async () => {
+ try {
+ if (!user?.id) {
+ return [];
+ }
+ const response = await axios.get(`/api/courses/drafts/${user.id}/all`);
+ return response.data;
+ } catch (error) {
+ console.error('Error fetching course drafts:', error);
+ return [];
+ }
+ };
+
+ const {
+ data: courseDrafts,
+ isLoading: courseDraftsLoading,
+ error: courseDraftsError,
+ refetch: refetchCourseDrafts
+ } = useQuery({
+ queryKey: ['courseDrafts', isClient],
+ queryFn: fetchCourseDrafts,
+ enabled: isClient && !!user?.id, // Only enable if client-side and user ID is available
+ });
+
+ return { courseDrafts, courseDraftsLoading, courseDraftsError, refetchCourseDrafts };
+}
diff --git a/src/pages/api/courses/drafts/[slug].js b/src/pages/api/courses/drafts/[slug].js
index acbdde9..e68584a 100644
--- a/src/pages/api/courses/drafts/[slug].js
+++ b/src/pages/api/courses/drafts/[slug].js
@@ -9,8 +9,14 @@ export default async function handler(req, res) {
if (slug && !userId) {
try {
const courseDraft = await getCourseDraftById(slug);
+
+ // For now we will combine resources and drafts into one array
+ const courseDraftWithResources = {
+ ...courseDraft,
+ resources: [...courseDraft.resources, ...courseDraft.drafts]
+ };
if (courseDraft) {
- res.status(200).json(courseDraft);
+ res.status(200).json(courseDraftWithResources);
} else {
res.status(404).json({ error: 'Course draft not found' });
}
diff --git a/src/pages/course/[slug]/draft/index.js b/src/pages/course/[slug]/draft/index.js
index 06633f2..36ad4fe 100644
--- a/src/pages/course/[slug]/draft/index.js
+++ b/src/pages/course/[slug]/draft/index.js
@@ -4,17 +4,11 @@ import axios from "axios";
import { parseEvent, findKind0Fields } from "@/utils/nostr";
import DraftCourseDetails from "@/components/content/courses/DraftCourseDetails";
import DraftCourseLesson from "@/components/content/courses/DraftCourseLesson";
-import dynamic from 'next/dynamic';
import { useNDKContext } from "@/context/NDKContext";
-
-const MDDisplay = dynamic(
- () => import("@uiw/react-markdown-preview"),
- {
- ssr: false,
- }
-);
+import { useSession } from "next-auth/react";
const DraftCourse = () => {
+ const { data: session } = useSession();
const [course, setCourse] = useState(null);
const [lessons, setLessons] = useState([]);
const [lessonsWithAuthors, setLessonsWithAuthors] = useState([]);
@@ -40,7 +34,6 @@ const DraftCourse = () => {
axios.get(`/api/courses/drafts/${slug}`)
.then(res => {
- console.log('res:', res.data);
setCourse(res.data);
console.log('coursesssss:', res.data);
setLessons(res.data.resources); // Set the raw lessons
@@ -54,22 +47,33 @@ const DraftCourse = () => {
useEffect(() => {
const fetchLessonDetails = async () => {
if (lessons.length > 0) {
+ console.log('lessons in fetchLessonDetails', lessons);
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
+ // figure out if it is a resource or a draft
+ const isDraft = !lesson.noteId;
+ if (isDraft) {
+ const parsedLessonObject = {
+ ...lesson,
+ author: session.user
+ }
+ return parsedLessonObject;
+ } else {
+ 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
}));
@@ -78,7 +82,7 @@ const DraftCourse = () => {
};
fetchLessonDetails();
- }, [lessons, ndk, fetchAuthor]);
+ }, [lessons, ndk, fetchAuthor, session]);
return (
<>
diff --git a/src/pages/course/[slug]/index.js b/src/pages/course/[slug]/index.js
index da07c12..24f15b8 100644
--- a/src/pages/course/[slug]/index.js
+++ b/src/pages/course/[slug]/index.js
@@ -107,7 +107,7 @@ const Course = () => {
}, [lessonIds, ndk, fetchAuthor]);
useEffect(() => {
- if (course?.price) {
+ if (course?.price && course?.price > 0) {
setPaidCourse(true);
}
}, [course]);