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") CONSTRAINT "CourseDraft_pkey" PRIMARY KEY ("id")
); );
-- CreateTable
CREATE TABLE "_CourseDraftToDraft" (
"A" TEXT NOT NULL,
"B" TEXT NOT NULL
);
-- CreateIndex -- CreateIndex
CREATE UNIQUE INDEX "User_pubkey_key" ON "User"("pubkey"); CREATE UNIQUE INDEX "User_pubkey_key" ON "User"("pubkey");
@ -106,6 +112,12 @@ CREATE UNIQUE INDEX "Resource_noteId_key" ON "Resource"("noteId");
-- CreateIndex -- CreateIndex
CREATE UNIQUE INDEX "CourseDraft_courseId_key" ON "CourseDraft"("courseId"); 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 -- AddForeignKey
ALTER TABLE "User" ADD CONSTRAINT "User_roleId_fkey" FOREIGN KEY ("roleId") REFERENCES "Role"("id") ON DELETE SET NULL ON UPDATE CASCADE; 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 -- AddForeignKey
ALTER TABLE "CourseDraft" ADD CONSTRAINT "CourseDraft_courseId_fkey" FOREIGN KEY ("courseId") REFERENCES "Course"("id") ON DELETE SET NULL ON UPDATE CASCADE; 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

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

View File

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

View File

@ -74,11 +74,13 @@ const ResourcePaymentButton = ({ lnAddress, amount, onSuccess, onError, resource
return ( return (
<> <>
<Button <Button
label={`Pay ${amount} sats`} label={`${amount} sats`}
icon="pi pi-wallet" icon="pi pi-wallet"
onClick={() => setDialogVisible(true)} onClick={() => setDialogVisible(true)}
disabled={!invoice} disabled={!invoice}
className="p-2 bg-blue-500 text-white rounded" severity='primary'
rounded
className="text-[#f8f8ff] text-sm"
/> />
<Dialog <Dialog
visible={dialogVisible} 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 React from "react";
import Image from "next/image"; import Image from "next/image";
import { useImageProxy } from "@/hooks/useImageProxy"; import { useImageProxy } from "@/hooks/useImageProxy";
import { Button } from "primereact/button";
import { formatUnixTimestamp } from "@/utils/time"; import { formatUnixTimestamp } from "@/utils/time";
import { Button } from "primereact/button";
const ContentDropdownItem = ({ content, onSelect, selected }) => { const ContentDropdownItem = ({ content, onSelect }) => {
const { returnImageProxy } = useImageProxy(); 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 ( return (
<div className="w-full border-t-2 border-gray-700 py-4"> <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="flex-1 max-w-[80vw]">
<div className="text-lg text-900 font-bold">{content.title}</div> <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="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>
<div className="flex flex-col justify-end"> <div className="flex flex-col justify-end">
<Button label="Select" onClick={() => onSelect(content)} /> <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 { NDKEvent } from "@nostr-dev-kit/ndk";
import { parseEvent } from "@/utils/nostr"; import { parseEvent } from "@/utils/nostr";
import ContentDropdownItem from "@/components/content/dropdowns/ContentDropdownItem"; import ContentDropdownItem from "@/components/content/dropdowns/ContentDropdownItem";
import SelectedContentItem from "@/components/content/SelectedContentItem";
import 'primeicons/primeicons.css'; import 'primeicons/primeicons.css';
@ -29,9 +30,7 @@ const CourseForm = ({ draft = null, isPublished = false }) => {
const [isPaidCourse, setIsPaidCourse] = useState(draft?.price ? true : false); const [isPaidCourse, setIsPaidCourse] = useState(draft?.price ? true : false);
const [price, setPrice] = useState(draft?.price || 0); const [price, setPrice] = useState(draft?.price || 0);
const [coverImage, setCoverImage] = useState(''); const [coverImage, setCoverImage] = useState('');
const [lessons, setLessons] = useState([{ id: uuidv4(), title: 'Select a lesson' }]); const [selectedContent, setSelectedContent] = useState([]);
const [loadingLessons, setLoadingLessons] = useState(true);
const [selectedLessons, setSelectedLessons] = useState([]);
const [topics, setTopics] = useState(['']); const [topics, setTopics] = useState(['']);
const { resources, resourcesLoading, resourcesError } = useResourcesQuery(); const { resources, resourcesLoading, resourcesError } = useResourcesQuery();
@ -49,41 +48,6 @@ const CourseForm = ({ draft = null, isPublished = false }) => {
} }
}, [session]); }, [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(() => { useEffect(() => {
if (draft) { if (draft) {
console.log('draft:', draft); console.log('draft:', draft);
@ -92,7 +56,7 @@ const CourseForm = ({ draft = null, isPublished = false }) => {
setIsPaidCourse(draft.price > 0); setIsPaidCourse(draft.price > 0);
setPrice(draft.price || 0); setPrice(draft.price || 0);
setCoverImage(draft.image); setCoverImage(draft.image);
// setSelectedLessons(draft.resources || []); setSelectedContent(draft.resources.concat(draft.drafts) || []);
setTopics(draft.topics || ['']); setTopics(draft.topics || ['']);
} }
}, [draft]); }, [draft]);
@ -110,26 +74,20 @@ const CourseForm = ({ draft = null, isPublished = false }) => {
} }
try { try {
// Step 1: Create the course draft
const courseDraftPayload = { const courseDraftPayload = {
userId: user.id, // Make sure this is set userId: user.id,
title, title,
summary, summary,
image: coverImage, image: coverImage,
price: price || 0, price: price || 0,
topics, 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 courseDraftResponse = await axios.post('/api/courses/drafts', courseDraftPayload);
const courseDraftId = courseDraftResponse.data.id; 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'); showToast('success', 'Success', 'Course draft saved successfully');
router.push(`/course/${courseDraftId}/draft`); router.push(`/course/${courseDraftId}/draft`);
} catch (error) { } catch (error) {
@ -138,50 +96,15 @@ const CourseForm = ({ draft = null, isPublished = false }) => {
} }
}; };
const createLessonEvent = (lesson) => { const handleContentSelect = (content) => {
const event = new NDKEvent(ndk); if (!selectedContent.some(item => item.id === content.id)) {
event.kind = lesson.price ? 30402 : 30023; setSelectedContent([...selectedContent, content]);
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' });
} }
};
setLessons(updatedLessons); const removeContent = (index) => {
setSelectedLessons(updatedSelectedLessons); const updatedContent = selectedContent.filter((_, i) => i !== index);
setSelectedContent(updatedContent);
}; };
const addTopic = () => { const addTopic = () => {
@ -198,36 +121,34 @@ const CourseForm = ({ draft = null, isPublished = false }) => {
setTopics(updatedTopics); setTopics(updatedTopics);
}; };
const getContentOptions = (index) => { const getContentOptions = () => {
if (resourcesLoading || !resources || workshopsLoading || !workshops || draftsLoading || !drafts) { if (resourcesLoading || !resources || workshopsLoading || !workshops || draftsLoading || !drafts) {
return []; return [];
} }
const filterContent = (content) => { const filterContent = (content) => {
console.log('contentttttt', content); const contentPrice = content.tags ? (content.tags.find(tag => tag[0] === 'price') ? parseInt(content.tags.find(tag => tag[0] === 'price')[1]) : 0) : (content.price || 0);
// 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;
return isPaidCourse ? contentPrice > 0 : contentPrice === 0; return isPaidCourse ? contentPrice > 0 : contentPrice === 0;
}; };
const draftOptions = drafts.filter(filterContent).map(draft => ({ const draftOptions = drafts.filter(filterContent).map(draft => ({
label: <ContentDropdownItem content={draft} onSelect={(content) => handleLessonSelect(content, index)} selected={lessons[index] && lessons[index].id === draft.id} />, label: draft.title,
value: draft.id value: draft
})); }));
const resourceOptions = resources.filter(filterContent).map(resource => { 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 { 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} />, label: parsedResource.title,
value: id value: parsedResource
}; };
}); });
const workshopOptions = workshops.filter(filterContent).map(workshop => { 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 { 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} />, label: parsedWorkshop.title,
value: id value: parsedWorkshop
}; };
}); });
@ -247,8 +168,7 @@ const CourseForm = ({ draft = null, isPublished = false }) => {
]; ];
}; };
// const lessonOptions = getContentOptions(); if (resourcesLoading || workshopsLoading || draftsLoading) {
if (loadingLessons || resourcesLoading || workshopsLoading || draftsLoading) {
return <ProgressSpinner />; return <ProgressSpinner />;
} }
@ -279,29 +199,27 @@ const CourseForm = ({ draft = null, isPublished = false }) => {
</div> </div>
<div className="mt-8 flex-col w-full"> <div className="mt-8 flex-col w-full">
<div className="mt-4 flex-col w-full"> <div className="mt-4 flex-col w-full">
{selectedLessons.map((lesson, index) => { {selectedContent.map((content, index) => (
return ( <div key={content.id} className="flex mt-4">
<div key={lesson.id} className="p-inputgroup flex-1 mt-4"> <SelectedContentItem content={content} />
<ContentDropdownItem content={lesson} selected={true} /> <Button
<Button icon="pi pi-times" className="p-button-danger" onClick={() => removeLesson(index)} /> icon="pi pi-times"
</div> className="p-button-danger rounded-tl-none rounded-bl-none"
) onClick={() => removeContent(index)}
})
}
{lessons.map((lesson, index) => (
<div key={lesson.id} 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}
optionLabel="label"
optionGroupLabel="label"
optionGroupChildren="items"
/> />
</div> </div>
))} ))}
<div className="p-inputgroup flex-1 mt-4">
<Dropdown
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> </div>
<div className="mt-4 flex-col w-full"> <div className="mt-4 flex-col w-full">

View File

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