improve course display, accordians, and fixed tag usage for plebdevs resource and workshop

This commit is contained in:
austinkelsay 2024-08-25 13:41:32 -05:00
parent 61a78f4b28
commit 280c0e5763
15 changed files with 59 additions and 301 deletions

View File

@ -116,8 +116,8 @@ export default function CourseDetails({ processedEvent, paidCourse, lessons, dec
))
)}
</div>
<h1 className='text-4xl mt-6'>{processedEvent?.title}</h1>
<p className='text-xl mt-6'>{processedEvent?.summary}</p>
<h1 className='text-4xl mt-6'>{processedEvent?.name}</h1>
<p className='text-xl mt-6'>{processedEvent?.description}</p>
<div className='flex flex-row w-full mt-6 items-center'>
<Image
alt="avatar thumbnail"

View File

@ -42,6 +42,20 @@ const CourseLesson = ({ lesson, course }) => {
</div>
<h1 className='text-4xl mt-6'>{lesson?.title}</h1>
<p className='text-xl mt-6'>{lesson?.summary}</p>
{lesson?.additionalLinks && lesson.additionalLinks.length > 0 && (
<div className='mt-6'>
<h3 className='text-lg font-semibold mb-2'>External links:</h3>
<ul className='list-disc list-inside'>
{lesson.additionalLinks.map((link, index) => (
<li key={index}>
<a href={link} target="_blank" rel="noopener noreferrer" className='text-blue-500 hover:underline'>
{new URL(link).hostname}
</a>
</li>
))}
</ul>
</div>
)}
<div className='flex flex-row w-full mt-6 items-center'>
<Image
alt="avatar thumbnail"

View File

@ -346,9 +346,9 @@ export default function DraftCourseDetails({ processedEvent, draftId, lessons })
</div>
<h1 className='text-4xl mt-6'>{processedEvent?.title}</h1>
<p className='text-xl mt-6'>{processedEvent?.summary}</p>
{processedEvent?.price && (
{processedEvent?.price && processedEvent?.price !== 0 ? (
<p className='text-lg mt-6'>Price: {processedEvent.price} sats</p>
)}
) : null}
<div className='flex flex-row w-full mt-6 items-center'>
<Image
alt="avatar thumbnail"

View File

@ -6,14 +6,6 @@ import Image from "next/image";
import { useImageProxy } from "@/hooks/useImageProxy";
import { formatDateTime, formatUnixTimestamp } from "@/utils/time";
import { useRouter } from "next/router";
import axios from "axios";
import { useNDKContext } from "@/context/NDKContext";
import { nip04, nip19 } from "nostr-tools";
import { v4 as uuidv4 } from "uuid";
import { useSession } from "next-auth/react";
import { NDKEvent } from "@nostr-dev-kit/ndk";
import { useToast } from "@/hooks/useToast";
import { validateEvent } from "@/utils/nostr";
import dynamic from "next/dynamic";
const MDDisplay = dynamic(
@ -25,19 +17,9 @@ const MDDisplay = dynamic(
const DraftCourseLesson = ({ lesson, course }) => {
const [isPublished, setIsPublished] = useState(false);
const [user, setUser] = useState(null);
const router = useRouter();
const { returnImageProxy } = useImageProxy();
const { ndk, addSigner } = useNDKContext();
const { data: session } = useSession();
const { showToast } = useToast();
useEffect(() => {
if (session) {
setUser(session.user);
}
}, [session]);
useEffect(() => {
if (lesson?.kind) {

View File

@ -1,238 +0,0 @@
import React, { useEffect, useState } from "react";
import axios from "axios";
import { InputText } from "primereact/inputtext";
import { InputNumber } from "primereact/inputnumber";
import { InputSwitch } from "primereact/inputswitch";
import { Button } from "primereact/button";
import { Dropdown } from "primereact/dropdown";
import { ProgressSpinner } from "primereact/progressspinner";
import { useSession } from 'next-auth/react';
import { useRouter } from "next/router";
import { useToast } from "@/hooks/useToast";
import { useNDKContext } from "@/context/NDKContext";
import { useWorkshopsQuery } from "@/hooks/nostrQueries/content/useWorkshopsQuery";
import { useResourcesQuery } from "@/hooks/nostrQueries/content/useResourcesQuery";
import { useDraftsQuery } from "@/hooks/apiQueries/useDraftsQuery";
import { parseEvent } from "@/utils/nostr";
import ContentDropdownItem from "@/components/content/dropdowns/ContentDropdownItem";
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 }) => {
const [title, setTitle] = useState('');
const [summary, setSummary] = useState('');
const [checked, setChecked] = useState(false);
const [price, setPrice] = useState(0);
const [isPaid, setIsPaid] = useState(false);
const [coverImage, setCoverImage] = useState('');
const [selectedLessons, setSelectedLessons] = useState([]);
const [selectedLessonsLoading, setSelectedLessonsLoading] = useState(false);
const [topics, setTopics] = useState(['']);
const { ndk } = useNDKContext();
const { resources, resourcesLoading } = useResourcesQuery();
const { workshops, workshopsLoading } = useWorkshopsQuery();
const { drafts, draftsLoading } = useDraftsQuery();
const { data: session } = useSession();
const router = useRouter();
const { showToast } = useToast();
useEffect(() => {
if (draft) {
const fetchLessonEventFromNostr = async (eventId) => {
try {
await ndk.connect();
const fetchedEvent = await ndk.fetchEvent(eventId);
return fetchedEvent ? parseEvent(fetchedEvent) : null;
} catch (error) {
showToast('error', 'Error', `Failed to fetch lesson: ${eventId}`);
return null;
}
};
const fetchLessons = async () => {
const fetchedLessons = await Promise.all(
draft.resources.map(lesson => fetchLessonEventFromNostr(lesson.noteId))
);
setSelectedLessons(fetchedLessons.filter(Boolean));
};
fetchLessons();
setTitle(draft.title);
setSummary(draft.summary);
setChecked(draft.price > 0);
setPrice(draft.price || 0);
setIsPaid(draft.price > 0);
setCoverImage(draft.image);
setTopics(draft.topics || ['']);
}
}, [draft, ndk, showToast, parseEvent]);
const handlePriceChange = (value) => {
setPrice(value);
setIsPaid(value > 0);
};
const handleSubmit = async (e) => {
e.preventDefault();
try {
// Ensure selectedLessons is an array
const lessonsToUpdate = Array.isArray(selectedLessons) ? selectedLessons : [];
// Update newly added lessons with courseDraftId
const updatePromises = lessonsToUpdate
.filter(lesson => lesson && lesson.id && !draft.resources.some(r => r.id === lesson.id))
.map(lesson =>
axios.put(`/api/resources/${lesson.d}`, { courseDraftId: draft.id })
);
await Promise.all(updatePromises);
// Prepare payload for course draft update
const payload = {
id: draft.id, // Include the id in the payload
title,
summary,
image: coverImage,
price: isPaid ? price : 0,
topics,
resourceIds: lessonsToUpdate.filter(lesson => lesson && lesson.id).map(lesson => lesson.id)
};
// Update course draft
const response = await axios.put(`/api/courses/drafts/${draft.id}`, payload);
console.log('Update response:', response.data);
showToast('success', 'Success', 'Course draft updated successfully');
router.push(`/course/${draft.id}/draft`);
} catch (error) {
console.error('Error updating course draft:', error);
showToast('error', 'Failed to update course draft', error.response?.data?.details || error.message);
}
};
const handleLessonSelect = (content) => {
if (!selectedLessons.some(lesson => lesson.id === content.id)) {
setSelectedLessons(prevLessons => [...prevLessons, content]);
}
};
const removeLesson = (index) => {
const updatedSelectedLessons = selectedLessons.filter((_, i) => i !== index);
setSelectedLessons(updatedSelectedLessons);
};
const addTopic = () => {
setTopics([...topics, '']);
};
const removeTopic = (index) => {
const updatedTopics = topics.filter((_, i) => i !== index);
setTopics(updatedTopics);
};
const handleTopicChange = (index, value) => {
const updatedTopics = topics.map((topic, i) => i === index ? value : topic);
setTopics(updatedTopics);
};
const getContentOptions = () => {
if (resourcesLoading || !resources || workshopsLoading || !workshops || draftsLoading || !drafts) {
return [];
}
const filterContent = (content) => {
const contentPrice = content.price || 0;
return isPaid ? contentPrice > 0 : contentPrice === 0;
};
const resourceOptions = resources.filter(filterContent).map(resource => {
const parsedResource = parseEvent(resource);
return {
label: <ContentDropdownItem content={parsedResource} onSelect={handleLessonSelect} selected={selectedLessons.some(lesson => lesson.id === parsedResource.id)} />,
value: parsedResource
};
});
const workshopOptions = workshops.filter(filterContent).map(workshop => {
const parsedWorkshop = parseEvent(workshop);
return {
label: <ContentDropdownItem content={parsedWorkshop} onSelect={handleLessonSelect} selected={selectedLessons.some(lesson => lesson.id === parsedWorkshop.id)} />,
value: parsedWorkshop
};
});
return [
{ label: 'Resources', items: resourceOptions },
{ label: 'Workshops', items: workshopOptions }
];
};
if (resourcesLoading || workshopsLoading || draftsLoading || selectedLessonsLoading) {
return <ProgressSpinner />;
}
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">
<InputText value={summary} onChange={(e) => setSummary(e.target.value)} placeholder="Summary" />
</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-4 flex-col">
<p className="py-2">Paid Course</p>
<InputSwitch checked={checked} onChange={(e) => setChecked(e.value)} />
{checked && (
<div className="p-inputgroup flex-1 py-4">
<InputNumber value={price} onValueChange={(e) => handlePriceChange(e.value)} placeholder="Price (sats)" />
</div>
)}
</div>
<div className="mt-8 flex-col w-full">
<div className="mt-4 flex-col w-full">
{selectedLessons.map((lesson, index) => (
<div key={index} className="p-inputgroup flex-1 mt-4">
<ContentDropdownItem content={lesson} selected={true} />
<Button icon="pi pi-times" className="p-button-danger" onClick={() => removeLesson(index)} />
</div>
))}
<div className="p-inputgroup flex-1 mt-4">
<Dropdown
options={getContentOptions()}
onChange={(e) => handleLessonSelect(e.value)}
placeholder="Add a Lesson"
optionLabel="label"
optionGroupLabel="label"
optionGroupChildren="items"
value={null}
/>
</div>
</div>
</div>
<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 && (
<Button icon="pi pi-times" className="p-button-danger mt-2" onClick={() => removeTopic(index)} />
)}
</div>
))}
<Button type="button" icon="pi pi-plus" onClick={addTopic} className="p-button-outlined mt-2" />
</div>
<div className="flex justify-center mt-8">
<Button type="submit" label="Update Draft" className="p-button-raised p-button-success" />
</div>
</form>
);
}
export default EditCourseForm;

View File

@ -102,7 +102,7 @@ const ResourceForm = ({ draft = null, isPublished = false }) => {
content,
d: draft.d,
image: coverImage,
topics: [...topics.map(topic => topic.trim().toLowerCase()), 'plebdevs', 'resource'],
topics: [...new Set([...topics.map(topic => topic.trim().toLowerCase()), 'resource'])],
additionalLinks: additionalLinks.filter(link => link.trim() !== '')
}
@ -153,7 +153,7 @@ const ResourceForm = ({ draft = null, isPublished = false }) => {
price: isPaidResource ? price : null,
content,
image: coverImage,
topics: [...topics.map(topic => topic.trim().toLowerCase()), 'plebdevs', 'resource'],
topics: [...new Set([...topics.map(topic => topic.trim().toLowerCase()), 'resource'])],
additionalLinks: additionalLinks.filter(link => link.trim() !== '')
};

View File

@ -76,7 +76,7 @@ const WorkshopForm = ({ draft = null }) => {
content: embedCode,
image: coverImage,
user: userResponse.data.id,
topics: [...topics.map(topic => topic.trim().toLowerCase()), 'plebdevs', 'workshop'],
topics: [...new Set([...topics.map(topic => topic.trim().toLowerCase()), 'workshop'])],
additionalLinks: additionalLinks.filter(link => link.trim() !== ''),
};

View File

@ -13,12 +13,17 @@ const LessonSelector = ({ isPaidCourse, lessons, setLessons, allContent }) => {
const [showResourceForm, setShowResourceForm] = useState(false);
const [showWorkshopForm, setShowWorkshopForm] = useState(false);
const [contentOptions, setContentOptions] = useState([]);
const [openTabs, setOpenTabs] = useState([]);
useEffect(() => {
updateContentOptions();
console.log("lessons", lessons);
}, [allContent, isPaidCourse, lessons]);
useEffect(() => {
setOpenTabs(lessons.map((_, index) => index));
}, [lessons]);
const updateContentOptions = () => {
if (!allContent || allContent.length === 0) {
setContentOptions([]);
@ -26,7 +31,7 @@ const LessonSelector = ({ isPaidCourse, lessons, setLessons, allContent }) => {
}
const filterContent = (content) => {
const contentPrice = content.price || content.tags.find(tag => tag[0] === 'price')?.[1] || 0;
const contentPrice = content?.price || (content?.tags && content?.tags.find(tag => tag[0] === 'price')?.[1]) || 0;
return isPaidCourse ? contentPrice > 0 : true;
};
@ -119,6 +124,10 @@ const LessonSelector = ({ isPaidCourse, lessons, setLessons, allContent }) => {
setShowWorkshopForm(false);
};
const handleTabChange = (e) => {
setOpenTabs(e.index);
};
const AccordianHeader = ({lesson, index}) => {
return (
<div className="flex justify-between items-center">
@ -131,7 +140,7 @@ const LessonSelector = ({ isPaidCourse, lessons, setLessons, allContent }) => {
return (
<div className="mt-8">
<h3>Lessons</h3>
<Accordion multiple>
<Accordion multiple activeIndex={openTabs} onTabChange={handleTabChange}>
{lessons.map((lesson, index) => (
<AccordionTab key={index} header={<AccordianHeader lesson={lesson} index={index} />}>
<div className="p-inputgroup flex-1 mt-4">

View File

@ -71,7 +71,7 @@ export const updateCourseDraft = async (id, data) => {
create: draftLessons.map((lesson, index) => ({
draftId: lesson.draftId,
resourceId: lesson.resourceId,
index: index
index
}))
}
},

View File

@ -14,8 +14,6 @@ export function useCoursesQuery() {
}, []);
const hasRequiredProperties = (event, contentIds) => {
// currently no topic tag added
// const hasCourseTag = event.tags.some(([tag, value]) => tag === "t" && value === "course");
const hasId = event.tags.some(([tag, value]) => tag === "d" && contentIds.includes(value));
return hasId;
};

View File

@ -14,10 +14,9 @@ export function useResourcesQuery() {
}, []);
const hasRequiredProperties = (event, contentIds) => {
const hasPlebDevs = event.tags.some(([tag, value]) => tag === "t" && value === "plebdevs");
const hasResource = event.tags.some(([tag, value]) => tag === "t" && value === "resource");
const hasId = event.tags.some(([tag, value]) => tag === "d" && contentIds.includes(value));
return hasPlebDevs && hasResource && hasId;
return hasResource && hasId;
};
const fetchResourcesFromNDK = async () => {

View File

@ -14,10 +14,9 @@ export function useWorkshopsQuery() {
}, []);
const hasRequiredProperties = (event, contentIds) => {
const hasPlebDevs = event.tags.some(([tag, value]) => tag === "t" && value === "plebdevs");
const hasWorkshop = event.tags.some(([tag, value]) => tag === "t" && value === "workshop");
const hasId = event.tags.some(([tag, value]) => tag === "d" && contentIds.includes(value));
return hasPlebDevs && hasWorkshop && hasId;
return hasWorkshop && hasId;
};
const fetchWorkshopsFromNDK = async () => {

View File

@ -27,17 +27,15 @@ export default async function handler(req, res) {
} else if (req.method === 'PUT') {
try {
const { slug } = req.query;
const { title, summary, image, price, topics } = req.body;
const { title, summary, image, price, topics, draftLessons } = req.body;
const updatedCourseDraft = await prisma.courseDraft.update({
where: { id: slug },
data: {
title,
summary,
image,
price,
topics,
},
const updatedCourseDraft = await updateCourseDraft(slug, {
title,
summary,
image,
price,
topics,
draftLessons
});
res.status(200).json(updatedCourseDraft);
@ -59,4 +57,4 @@ export default async function handler(req, res) {
res.setHeader('Allow', ['GET', 'PUT', 'DELETE']);
res.status(405).end(`Method ${req.method} Not Allowed`);
}
}
}

View File

@ -1,24 +1,26 @@
import prisma from "@/db/prisma";
import { createCourseDraft } from "@/db/models/courseDraftModels";
export default async function handler(req, res) {
if (req.method === 'POST') {
try {
const { userId, title, summary, image, price, topics } = req.body;
const { userId, title, summary, image, price, topics, draftLessons } = req.body;
if (!userId) {
return res.status(400).json({ error: 'userId is required' });
}
const courseDraft = await prisma.courseDraft.create({
data: {
title,
summary,
image,
price,
topics: topics || [],
user: { connect: { id: userId } },
},
include: { draftLessons: true }
const courseDraft = await createCourseDraft({
userId,
title,
summary,
image,
price,
topics: [...new Set([...topics.map(topic => topic.trim().toLowerCase())])],
draftLessons: draftLessons?.map((lesson, index) => ({
draftId: lesson.draftId,
resourceId: lesson.resourceId,
index
})) || []
});
res.status(201).json(courseDraft);

View File

@ -1,4 +1,4 @@
import { getResourceById, updateResource, deleteResource, isResourcePartOfAnyCourse, updateLessonInCourse } from "@/db/models/resourceModels";
import { getResourceById, updateResource, deleteResource, } from "@/db/models/resourceModels";
export default async function handler(req, res) {
const { slug } = req.query;
@ -32,13 +32,8 @@ export default async function handler(req, res) {
}
} else if (req.method === 'DELETE') {
try {
const isPartOfAnyCourse = await isResourcePartOfAnyCourse(slug);
if (isPartOfAnyCourse) {
res.status(400).json({ error: 'Resource is part of one or more courses' });
} else {
await deleteResource(slug);
res.status(204).end();
}
} catch (error) {
res.status(500).json({ error: error.message });
}