mirror of
https://github.com/AustinKelsay/plebdevs.git
synced 2025-06-05 00:32:03 +00:00
Allow simple edits of published courses, add cascade delete to userlessons to make this course update cleaner
This commit is contained in:
parent
1121ed3e93
commit
67ea5f523e
@ -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;
|
@ -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?
|
||||
|
@ -203,7 +203,7 @@ export default function CourseDetails({ processedEvent, paidCourse, lessons, dec
|
||||
{renderPaymentMessage()}
|
||||
{processedEvent?.pubkey === session?.user?.pubkey ? (
|
||||
<div className='flex space-x-2 mt-4 sm:mt-0'>
|
||||
<GenericButton onClick={() => router.push(`/details/${processedEvent.id}/edit`)} label="Edit" severity='warning' outlined />
|
||||
<GenericButton onClick={() => router.push(`/course/${processedEvent.d}/edit`)} label="Edit" severity='warning' outlined />
|
||||
<GenericButton onClick={handleDelete} label="Delete" severity='danger' outlined />
|
||||
<GenericButton outlined icon="pi pi-external-link" onClick={() => window.open(`https://nostr.band/${nAddress}`, '_blank')} tooltip={isMobileView ? null : "View Nostr Event"} tooltipOptions={{ position: paidCourse ? 'left' : 'right' }} />
|
||||
</div>
|
||||
|
227
src/components/forms/course/PublishedCourseForm.js
Normal file
227
src/components/forms/course/PublishedCourseForm.js
Normal file
@ -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 (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="p-inputgroup flex-1">
|
||||
<InputText
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Title"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="p-inputgroup flex-1 mt-4">
|
||||
<InputTextarea
|
||||
value={summary}
|
||||
onChange={(e) => setSummary(e.target.value)}
|
||||
placeholder="Summary"
|
||||
rows={5}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="p-inputgroup flex-1 mt-4">
|
||||
<InputText
|
||||
value={coverImage}
|
||||
onChange={(e) => setCoverImage(e.target.value)}
|
||||
placeholder="Cover Image URL"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="p-inputgroup flex-1 mt-8 flex-col">
|
||||
<p className="py-2">Paid Course</p>
|
||||
<InputSwitch
|
||||
checked={isPaidCourse}
|
||||
onChange={(e) => setIsPaidResource(e.value)}
|
||||
/>
|
||||
{isPaidCourse && (
|
||||
<div className="p-inputgroup flex-1 py-4">
|
||||
<InputNumber
|
||||
value={price}
|
||||
onValueChange={(e) => setPrice(e.value)}
|
||||
placeholder="Price (sats)"
|
||||
min={1}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<LessonSelector
|
||||
isPaidCourse={isPaidCourse}
|
||||
lessons={lessons}
|
||||
setLessons={setLessons}
|
||||
allContent={allContent}
|
||||
// onNewResourceCreate={handleNewResourceCreate}
|
||||
// onNewVideoCreate={handleNewVideoCreate}
|
||||
/>
|
||||
|
||||
<div className="mt-4 flex-col w-full">
|
||||
{topics.map((topic, index) => (
|
||||
<div key={index} className="p-inputgroup flex-1 mt-4">
|
||||
<InputText
|
||||
value={topic}
|
||||
onChange={(e) => handleTopicChange(index, e.target.value)}
|
||||
placeholder={`Topic #${index + 1}`}
|
||||
className="w-full"
|
||||
/>
|
||||
{index > 0 && (
|
||||
<GenericButton
|
||||
icon="pi pi-times"
|
||||
className="p-button-danger mt-2"
|
||||
onClick={(e) => removeTopic(e, index)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<GenericButton
|
||||
icon="pi pi-plus"
|
||||
onClick={addTopic}
|
||||
className="p-button-outlined mt-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center mt-8">
|
||||
<GenericButton
|
||||
type="submit"
|
||||
severity="success"
|
||||
outlined
|
||||
label="Update Course"
|
||||
loading={loading}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default PublishedCourseForm;
|
@ -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
|
||||
}))
|
||||
}
|
||||
|
69
src/pages/course/[slug]/edit.js
Normal file
69
src/pages/course/[slug]/edit.js
Normal file
@ -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 <div className="w-full h-full flex items-center justify-center"><ProgressSpinner /></div>;
|
||||
}
|
||||
|
||||
if (!course) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-[80vw] max-w-[80vw] mx-auto my-8 flex flex-col justify-center">
|
||||
<h2 className="text-center mb-8">Edit Course</h2>
|
||||
<PublishedCourseForm course={course} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditCourse;
|
@ -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;
|
||||
|
Loading…
x
Reference in New Issue
Block a user