mirror of
https://github.com/AustinKelsay/plebdevs.git
synced 2025-06-06 01:02:04 +00:00
Course publishing with partial drafts barely works
This commit is contained in:
parent
d1c121e6e8
commit
ff9efe6fc9
@ -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]);
|
||||
|
@ -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 (
|
||||
<div className='w-full px-24 pt-12 mx-auto mt-4 max-tab:px-0 max-mob:px-0 max-tab:pt-2 max-mob:pt-2'>
|
||||
<div className='w-full flex flex-row justify-between max-tab:flex-col max-mob:flex-col'>
|
||||
|
@ -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);
|
||||
|
@ -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 }) => {
|
||||
|
@ -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 (
|
||||
<div className="w-[90vw] mx-auto max-tab:w-[100vw] max-mob:w-[100vw]">
|
||||
|
@ -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
|
||||
},
|
||||
});
|
||||
};
|
||||
|
46
src/hooks/apiQueries/useCourseDraftsQuery.js
Normal file
46
src/hooks/apiQueries/useCourseDraftsQuery.js
Normal file
@ -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 };
|
||||
}
|
@ -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' });
|
||||
}
|
||||
|
@ -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 (
|
||||
<>
|
||||
|
@ -107,7 +107,7 @@ const Course = () => {
|
||||
}, [lessonIds, ndk, fetchAuthor]);
|
||||
|
||||
useEffect(() => {
|
||||
if (course?.price) {
|
||||
if (course?.price && course?.price > 0) {
|
||||
setPaidCourse(true);
|
||||
}
|
||||
}, [course]);
|
||||
|
Loading…
x
Reference in New Issue
Block a user