Allow both drafts and published resources to make it into course draft, seperated selectedContentItem in course form into its own component

This commit is contained in:
austinkelsay 2024-08-18 14:45:51 -05:00
parent 054adf6869
commit d1c121e6e8
8 changed files with 125 additions and 171 deletions

View File

@ -91,6 +91,12 @@ CREATE TABLE "CourseDraft" (
CONSTRAINT "CourseDraft_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "_CourseDraftToDraft" (
"A" TEXT NOT NULL,
"B" TEXT NOT NULL
);
-- CreateIndex
CREATE UNIQUE INDEX "User_pubkey_key" ON "User"("pubkey");
@ -106,6 +112,12 @@ CREATE UNIQUE INDEX "Resource_noteId_key" ON "Resource"("noteId");
-- CreateIndex
CREATE UNIQUE INDEX "CourseDraft_courseId_key" ON "CourseDraft"("courseId");
-- CreateIndex
CREATE UNIQUE INDEX "_CourseDraftToDraft_AB_unique" ON "_CourseDraftToDraft"("A", "B");
-- CreateIndex
CREATE INDEX "_CourseDraftToDraft_B_index" ON "_CourseDraftToDraft"("B");
-- AddForeignKey
ALTER TABLE "User" ADD CONSTRAINT "User_roleId_fkey" FOREIGN KEY ("roleId") REFERENCES "Role"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@ -138,3 +150,9 @@ ALTER TABLE "CourseDraft" ADD CONSTRAINT "CourseDraft_userId_fkey" FOREIGN KEY (
-- AddForeignKey
ALTER TABLE "CourseDraft" ADD CONSTRAINT "CourseDraft_courseId_fkey" FOREIGN KEY ("courseId") REFERENCES "Course"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_CourseDraftToDraft" ADD CONSTRAINT "_CourseDraftToDraft_A_fkey" FOREIGN KEY ("A") REFERENCES "CourseDraft"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_CourseDraftToDraft" ADD CONSTRAINT "_CourseDraftToDraft_B_fkey" FOREIGN KEY ("B") REFERENCES "Draft"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -82,6 +82,7 @@ model Draft {
topics String[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
courseDrafts CourseDraft[]
}
model CourseDraft {
@ -89,6 +90,7 @@ model CourseDraft {
userId String
user User @relation(fields: [userId], references: [id])
resources Resource[]
drafts Draft[]
title String
summary String
image String?

View File

@ -76,10 +76,12 @@ const CoursePaymentButton = ({ lnAddress, amount, onSuccess, onError, courseId }
return (
<>
<Button
label={`Pay ${amount} sats`}
label={`${amount} sats`}
onClick={() => setDialogVisible(true)}
disabled={!invoice}
severity='info'
severity='primary'
rounded
icon='pi pi-wallet'
className='text-[#f8f8ff] text-sm'
/>
<Dialog

View File

@ -74,11 +74,13 @@ const ResourcePaymentButton = ({ lnAddress, amount, onSuccess, onError, resource
return (
<>
<Button
label={`Pay ${amount} sats`}
label={`${amount} sats`}
icon="pi pi-wallet"
onClick={() => setDialogVisible(true)}
disabled={!invoice}
className="p-2 bg-blue-500 text-white rounded"
severity='primary'
rounded
className="text-[#f8f8ff] text-sm"
/>
<Dialog
visible={dialogVisible}

View File

@ -0,0 +1,31 @@
import React from "react";
import Image from "next/image";
import { useImageProxy } from "@/hooks/useImageProxy";
import { formatUnixTimestamp } from "@/utils/time";
const SelectedContentItem = ({ content }) => {
const { returnImageProxy } = useImageProxy();
return (
<div className="w-full border-2 rounded-lg border-gray-700 p-2 rounded-tr-none rounded-br-none">
<div className="flex flex-row gap-4">
<Image
alt="content thumbnail"
src={returnImageProxy(content.image)}
width={50}
height={50}
className="w-[100px] h-[100px] object-cover object-center border-round"
/>
<div className="flex-1 max-w-[80vw]">
<div className="text-lg text-900 font-bold">{content.title}</div>
<div className="w-full text-sm text-600 text-wrap">{content.summary}</div>
<div className="text-sm pt-6 text-gray-500">
{content.published_at ? formatUnixTimestamp(content.published_at) : "not yet published"}
</div>
</div>
</div>
</div>
);
};
export default SelectedContentItem;

View File

@ -1,33 +1,11 @@
import React from "react";
import Image from "next/image";
import { useImageProxy } from "@/hooks/useImageProxy";
import { Button } from "primereact/button";
import { formatUnixTimestamp } from "@/utils/time";
import { Button } from "primereact/button";
const ContentDropdownItem = ({ content, onSelect, selected }) => {
const ContentDropdownItem = ({ content, onSelect }) => {
const { returnImageProxy } = useImageProxy();
console.log('selected:', content);
if (selected) {
return (
<div className="w-full border-y-2 border-l-2 rounded-l-lg border-gray-700">
<div className="flex flex-row gap-4 p-2">
<Image
alt="content thumbnail"
src={returnImageProxy(content.image)}
width={50}
height={50}
className="w-[100px] h-[100px] object-cover object-center border-round"
/>
<div className="flex-1">
<div className="text-lg text-900 font-bold">{content.title}</div>
<div className="min-h-[40px] text-sm text-600 line-clamp-2 break-words">{content.summary}</div>
<div className="text-sm pt-6 text-gray-500">{content.published_at ? formatUnixTimestamp(content.published_at) : "not yet published"}</div>
</div>
</div>
</div>
);
}
return (
<div className="w-full border-t-2 border-gray-700 py-4">
@ -42,7 +20,9 @@ const ContentDropdownItem = ({ content, onSelect, selected }) => {
<div className="flex-1 max-w-[80vw]">
<div className="text-lg text-900 font-bold">{content.title}</div>
<div className="w-full text-sm text-600 text-wrap">{content.summary}</div>
<div className="text-sm pt-6 text-gray-500">{content.published_at ? formatUnixTimestamp(content.published_at) : "not yet published"}</div>
<div className="text-sm pt-6 text-gray-500">
{content.published_at ? formatUnixTimestamp(content.published_at) : "not yet published"}
</div>
</div>
<div className="flex flex-col justify-end">
<Button label="Select" onClick={() => onSelect(content)} />

View File

@ -17,6 +17,7 @@ import { useDraftsQuery } from "@/hooks/apiQueries/useDraftsQuery";
import { NDKEvent } from "@nostr-dev-kit/ndk";
import { parseEvent } from "@/utils/nostr";
import ContentDropdownItem from "@/components/content/dropdowns/ContentDropdownItem";
import SelectedContentItem from "@/components/content/SelectedContentItem";
import 'primeicons/primeicons.css';
@ -29,9 +30,7 @@ const CourseForm = ({ draft = null, isPublished = false }) => {
const [isPaidCourse, setIsPaidCourse] = useState(draft?.price ? true : false);
const [price, setPrice] = useState(draft?.price || 0);
const [coverImage, setCoverImage] = useState('');
const [lessons, setLessons] = useState([{ id: uuidv4(), title: 'Select a lesson' }]);
const [loadingLessons, setLoadingLessons] = useState(true);
const [selectedLessons, setSelectedLessons] = useState([]);
const [selectedContent, setSelectedContent] = useState([]);
const [topics, setTopics] = useState(['']);
const { resources, resourcesLoading, resourcesError } = useResourcesQuery();
@ -49,41 +48,6 @@ const CourseForm = ({ draft = null, isPublished = false }) => {
}
}, [session]);
useEffect(() => {
console.log('selectedLessons:', selectedLessons);
}, [selectedLessons]);
useEffect(() => {
const fetchLessons = async () => {
if (draft && draft?.resources) {
const parsedLessons = await Promise.all(
draft.resources.map(async (lesson) => {
const parsedLesson = await fetchLessonEventFromNostr(lesson.noteId);
return parsedLesson;
})
);
setSelectedLessons([...selectedLessons, ...parsedLessons]);
setLoadingLessons(false); // Data is loaded
} else {
setLoadingLessons(false); // No draft means no lessons to load
}
};
fetchLessons();
}, [draft]); // Only depend on draft
const fetchLessonEventFromNostr = async (eventId) => {
try {
await ndk.connect();
const fetchedEvent = await ndk.fetchEvent(eventId);
if (fetchedEvent) {
return parseEvent(fetchedEvent);
}
} catch (error) {
showToast('error', 'Error', `Failed to fetch lesson: ${eventId}`);
}
}
useEffect(() => {
if (draft) {
console.log('draft:', draft);
@ -92,7 +56,7 @@ const CourseForm = ({ draft = null, isPublished = false }) => {
setIsPaidCourse(draft.price > 0);
setPrice(draft.price || 0);
setCoverImage(draft.image);
// setSelectedLessons(draft.resources || []);
setSelectedContent(draft.resources.concat(draft.drafts) || []);
setTopics(draft.topics || ['']);
}
}, [draft]);
@ -110,26 +74,20 @@ const CourseForm = ({ draft = null, isPublished = false }) => {
}
try {
// Step 1: Create the course draft
const courseDraftPayload = {
userId: user.id, // Make sure this is set
userId: user.id,
title,
summary,
image: coverImage,
price: price || 0,
topics,
resources: selectedContent.filter(content => content.kind === 30023 || content.kind === 30402).map(resource => resource.d),
drafts: selectedContent.filter(content => !content.kind).map(draft => draft.id),
};
const courseDraftResponse = await axios.post('/api/courses/drafts', courseDraftPayload);
const courseDraftId = courseDraftResponse.data.id;
// Step 2: Associate resources with the course draft
for (const lesson of selectedLessons) {
await axios.put(`/api/resources/${lesson.d}`, {
courseDraftId: courseDraftId
});
}
showToast('success', 'Success', 'Course draft saved successfully');
router.push(`/course/${courseDraftId}/draft`);
} catch (error) {
@ -138,50 +96,15 @@ const CourseForm = ({ draft = null, isPublished = false }) => {
}
};
const createLessonEvent = (lesson) => {
const event = new NDKEvent(ndk);
event.kind = lesson.price ? 30402 : 30023;
event.content = lesson.content;
event.tags = [
['d', lesson.id],
['title', lesson.title],
['summary', lesson.summary],
['image', lesson.image],
...lesson.topics.map(topic => ['t', topic]),
['published_at', Math.floor(Date.now() / 1000).toString()],
];
return event;
};
const handleLessonChange = (e, index) => {
const selectedLessonId = e.value;
const selectedLesson = getContentOptions(index).flatMap(group => group.items).find(lesson => lesson.value === selectedLessonId);
const updatedLessons = lessons.map((lesson, i) =>
i === index ? { ...lesson, id: selectedLessonId, title: selectedLesson.label.props.content.title } : lesson
);
setLessons(updatedLessons);
};
const handleLessonSelect = (content) => {
setSelectedLessons([...selectedLessons, content]);
addLesson();
};
const addLesson = () => {
setLessons([...lessons, { id: uuidv4(), title: 'Select a lesson' }]);
};
const removeLesson = (index) => {
const updatedLessons = lessons.filter((_, i) => i !== index);
const updatedSelectedLessons = selectedLessons.filter((_, i) => i !== index);
if (updatedLessons.length === 0) {
updatedLessons.push({ id: uuidv4(), title: 'Select a lesson' });
const handleContentSelect = (content) => {
if (!selectedContent.some(item => item.id === content.id)) {
setSelectedContent([...selectedContent, content]);
}
};
setLessons(updatedLessons);
setSelectedLessons(updatedSelectedLessons);
const removeContent = (index) => {
const updatedContent = selectedContent.filter((_, i) => i !== index);
setSelectedContent(updatedContent);
};
const addTopic = () => {
@ -198,36 +121,34 @@ const CourseForm = ({ draft = null, isPublished = false }) => {
setTopics(updatedTopics);
};
const getContentOptions = (index) => {
const getContentOptions = () => {
if (resourcesLoading || !resources || workshopsLoading || !workshops || draftsLoading || !drafts) {
return [];
}
const filterContent = (content) => {
console.log('contentttttt', content);
// If there is price in content.tags, then it is a paid content 'price' in the 0 index and stringified int in the 1 index
const contentPrice = content.tags.find(tag => tag[0] === 'price') ? parseInt(content.tags.find(tag => tag[0] === 'price')[1]) : 0;
const contentPrice = content.tags ? (content.tags.find(tag => tag[0] === 'price') ? parseInt(content.tags.find(tag => tag[0] === 'price')[1]) : 0) : (content.price || 0);
return isPaidCourse ? contentPrice > 0 : contentPrice === 0;
};
const draftOptions = drafts.filter(filterContent).map(draft => ({
label: <ContentDropdownItem content={draft} onSelect={(content) => handleLessonSelect(content, index)} selected={lessons[index] && lessons[index].id === draft.id} />,
value: draft.id
label: draft.title,
value: draft
}));
const resourceOptions = resources.filter(filterContent).map(resource => {
const { id, kind, pubkey, content, title, summary, image, published_at, d, topics } = parseEvent(resource);
const parsedResource = parseEvent(resource);
return {
label: <ContentDropdownItem content={{ id, kind, pubkey, content, title, summary, image, published_at, d, topics }} onSelect={(content) => handleLessonSelect(content, index)} selected={lessons[index] && lessons[index].id === id} />,
value: id
label: parsedResource.title,
value: parsedResource
};
});
const workshopOptions = workshops.filter(filterContent).map(workshop => {
const { id, kind, pubkey, content, title, summary, image, published_at, d, topics } = parseEvent(workshop);
const parsedWorkshop = parseEvent(workshop);
return {
label: <ContentDropdownItem content={{ id, kind, pubkey, content, title, summary, image, published_at, d, topics }} onSelect={(content) => handleLessonSelect(content, index)} selected={lessons[index] && lessons[index].id === id} />,
value: id
label: parsedWorkshop.title,
value: parsedWorkshop
};
});
@ -247,8 +168,7 @@ const CourseForm = ({ draft = null, isPublished = false }) => {
];
};
// const lessonOptions = getContentOptions();
if (loadingLessons || resourcesLoading || workshopsLoading || draftsLoading) {
if (resourcesLoading || workshopsLoading || draftsLoading) {
return <ProgressSpinner />;
}
@ -279,29 +199,27 @@ const CourseForm = ({ draft = null, isPublished = false }) => {
</div>
<div className="mt-8 flex-col w-full">
<div className="mt-4 flex-col w-full">
{selectedLessons.map((lesson, index) => {
return (
<div key={lesson.id} className="p-inputgroup flex-1 mt-4">
<ContentDropdownItem content={lesson} selected={true} />
<Button icon="pi pi-times" className="p-button-danger" onClick={() => removeLesson(index)} />
{selectedContent.map((content, index) => (
<div key={content.id} className="flex mt-4">
<SelectedContentItem content={content} />
<Button
icon="pi pi-times"
className="p-button-danger rounded-tl-none rounded-bl-none"
onClick={() => removeContent(index)}
/>
</div>
)
})
}
{lessons.map((lesson, index) => (
<div key={lesson.id} className="p-inputgroup flex-1 mt-4">
))}
<div className="p-inputgroup flex-1 mt-4">
<Dropdown
value={lesson.title}
options={getContentOptions(index)}
onChange={(e) => handleLessonChange(e, index)}
placeholder="Select a Lesson"
itemTemplate={(option) => option.label}
options={getContentOptions()}
onChange={(e) => handleContentSelect(e.value)}
placeholder="Select Content"
itemTemplate={(option) => <ContentDropdownItem content={option.value} onSelect={handleContentSelect} />}
optionLabel="label"
optionGroupLabel="label"
optionGroupChildren="items"
/>
</div>
))}
</div>
</div>
<div className="mt-4 flex-col w-full">

View File

@ -1,11 +1,9 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
import prisma from "@/db/prisma";
export default async function handler(req, res) {
if (req.method === 'POST') {
try {
const { userId, title, summary, image, price, topics, resourceIds } = req.body;
const { userId, title, summary, image, price, topics, resources, drafts } = req.body;
if (!userId) {
return res.status(400).json({ error: 'userId is required' });
@ -20,10 +18,13 @@ export default async function handler(req, res) {
topics: topics || [],
user: { connect: { id: userId } },
resources: {
connect: resourceIds ? resourceIds.map(id => ({ id })) : []
connect: resources ? resources.map(id => ({ id })) : []
},
drafts: {
connect: drafts ? drafts.map(id => ({ id })) : []
}
},
include: { resources: true }
include: { resources: true, drafts: true }
});
res.status(201).json(courseDraft);