A lotta good stuff

This commit is contained in:
austinkelsay 2024-08-09 14:28:57 -05:00
parent 80edbf0905
commit b953b76785
27 changed files with 792 additions and 236 deletions

View File

@ -74,6 +74,26 @@ CREATE TABLE "Draft" (
CONSTRAINT "Draft_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "CourseDraft" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"title" TEXT NOT NULL,
"summary" TEXT NOT NULL,
"image" TEXT,
"price" INTEGER DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "CourseDraft_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "_CourseDraftToResource" (
"A" TEXT NOT NULL,
"B" TEXT NOT NULL
);
-- CreateIndex
CREATE UNIQUE INDEX "User_pubkey_key" ON "User"("pubkey");
@ -86,6 +106,12 @@ CREATE UNIQUE INDEX "Course_noteId_key" ON "Course"("noteId");
-- CreateIndex
CREATE UNIQUE INDEX "Resource_noteId_key" ON "Resource"("noteId");
-- CreateIndex
CREATE UNIQUE INDEX "_CourseDraftToResource_AB_unique" ON "_CourseDraftToResource"("A", "B");
-- CreateIndex
CREATE INDEX "_CourseDraftToResource_B_index" ON "_CourseDraftToResource"("B");
-- AddForeignKey
ALTER TABLE "User" ADD CONSTRAINT "User_roleId_fkey" FOREIGN KEY ("roleId") REFERENCES "Role"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@ -109,3 +135,12 @@ ALTER TABLE "Resource" ADD CONSTRAINT "Resource_courseId_fkey" FOREIGN KEY ("cou
-- AddForeignKey
ALTER TABLE "Draft" ADD CONSTRAINT "Draft_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CourseDraft" ADD CONSTRAINT "CourseDraft_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_CourseDraftToResource" ADD CONSTRAINT "_CourseDraftToResource_A_fkey" FOREIGN KEY ("A") REFERENCES "CourseDraft"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_CourseDraftToResource" ADD CONSTRAINT "_CourseDraftToResource_B_fkey" FOREIGN KEY ("B") REFERENCES "Resource"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -16,6 +16,7 @@ model User {
courses Course[] // Relation field added for courses created by the user
resources Resource[] // Relation field added for resources created by the user
drafts Draft[] // Relation field added for drafts created by the user
courseDrafts CourseDraft[] // Relation field added for course drafts created by the user
role Role? @relation(fields: [roleId], references: [id])
roleId String?
createdAt DateTime @default(now())
@ -61,6 +62,7 @@ model Resource {
courseId String?
price Int @default(0)
purchases Purchase[]
courseDrafts CourseDraft[]
noteId String? @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@ -81,3 +83,15 @@ model Draft {
updatedAt DateTime @updatedAt
}
model CourseDraft {
id String @id @default(uuid())
userId String
user User @relation(fields: [userId], references: [id])
resources Resource[]
title String
summary String
image String?
price Int? @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}

View File

@ -1,4 +1,4 @@
import React from "react";
import React, {useEffect} from "react";
import Image from "next/image";
import { Button } from "primereact/button";
import { useImageProxy } from "@/hooks/useImageProxy";
@ -8,6 +8,25 @@ const ContentListItem = (content) => {
const { returnImageProxy } = useImageProxy();
const router = useRouter();
const isDraft = Object.keys(content).includes('type');
const isCourse = content && content?.resources && content?.resources?.length > 0;
const handleClick = () => {
let path = '';
if (isDraft) {
path = '/draft';
} else if (isCourse) {
path = '/course';
} else {
path = '/details';
}
const draftSuffix = isCourse ? '/draft' : '';
const fullPath = `${path}/${content.id}${draftSuffix}`;
router.push(fullPath);
};
return (
<div className="p-4 border-bottom-1 surface-border" key={content.id}>
@ -26,7 +45,7 @@ const ContentListItem = (content) => {
</div>
<div className="text-right">
<Button
onClick={() => router.push(`${ isDraft ? '/draft' : '/details' }/${content.id}`)}
onClick={handleClick}
label="Open"
outlined
/>

View File

@ -56,6 +56,7 @@ export default function CourseDetails({ processedEvent }) {
};
const fetchAuthor = useCallback(async (pubkey) => {
if (!pubkey) return;
const author = await ndk.getUser({ pubkey });
const profile = await author.fetchProfile();
const fields = await findKind0Fields(profile);

View File

@ -0,0 +1,191 @@
import React, { useEffect, useState, useCallback } from 'react';
import { useRouter } from 'next/router';
import { useImageProxy } from '@/hooks/useImageProxy';
import { Tag } from 'primereact/tag';
import { Button } from 'primereact/button';
import Image from 'next/image';
import dynamic from 'next/dynamic';
import axios from 'axios';
import { v4 as uuidv4 } from 'uuid';
import { useSession } from 'next-auth/react';
import { useNDKContext } from "@/context/NDKContext";
import { NDKEvent } from "@nostr-dev-kit/ndk";
import { findKind0Fields } from '@/utils/nostr';
import { useToast } from '@/hooks/useToast';
import 'primeicons/primeicons.css';
const MDDisplay = dynamic(
() => import("@uiw/react-markdown-preview"),
{
ssr: false,
}
);
export default function DraftCourseDetails({ processedEvent, lessons }) {
const [author, setAuthor] = useState(null);
const [user, setUser] = useState(null);
const { showToast } = useToast();
const { returnImageProxy } = useImageProxy();
const { data: session, status } = useSession();
const router = useRouter();
const ndk = useNDKContext();
const fetchAuthor = useCallback(async (pubkey) => {
if (!pubkey) return;
const author = await ndk.getUser({ pubkey });
const profile = await author.fetchProfile();
const fields = await findKind0Fields(profile);
if (fields) {
setAuthor(fields);
}
}, [ndk]);
useEffect(() => {
if (processedEvent) {
fetchAuthor(processedEvent?.user?.pubkey);
}
}, [fetchAuthor, processedEvent]);
useEffect(() => {
if (session) {
setUser(session.user);
}
}, [session]);
const handleDelete = () => {
console.log('delete');
}
const handleSubmit = async (e) => {
e.preventDefault();
const newCourseId = uuidv4();
const processedLessons = [];
try {
// Step 1: Process lessons
for (const lesson of lessons) {
processedLessons.push({
d: lesson?.d,
kind: lesson?.price ? 30402 : 30023,
pubkey: lesson.pubkey
});
}
// Step 2: Create and publish course
const courseEvent = createCourseEvent(newCourseId, processedEvent.title, processedEvent.summary, processedEvent.image, processedLessons);
const published = await courseEvent.publish();
console.log('published', published);
if (!published) {
throw new Error('Failed to publish course');
}
// Step 3: Save course to db
console.log('processedLessons:', processedLessons);
await axios.post('/api/courses', {
id: newCourseId,
resources: {
connect: processedLessons.map(lesson => ({ id: lesson?.d }))
},
noteId: courseEvent.id,
user: {
connect: { id: user.id }
},
price: processedEvent?.price || 0
});
// step 4: Update all resources to have the course id
await Promise.all(processedLessons.map(lesson => axios.put(`/api/resources/${lesson?.d}`, { courseId: newCourseId })));
// Step 5: Delete draft
await axios.delete(`/api/courses/drafts/${processedEvent.id}`);
// Step 6: Show success message and redirect
showToast('success', 'Course created successfully');
router.push(`/course/${courseEvent.id}`);
} catch (error) {
console.error('Error creating course:', error);
showToast('error', error.message || 'Failed to create course. Please try again.');
}
};
const createCourseEvent = (courseId, title, summary, coverImage, lessons) => {
const event = new NDKEvent(ndk);
event.kind = 30004;
event.content = "";
event.tags = [
['d', courseId],
['name', title],
['picture', coverImage],
['image', coverImage],
['description', summary],
['l', "Education"],
...lessons.map((lesson) => ['a', `${lesson.kind}:${lesson.pubkey}:${lesson.d}`]),
];
return event;
};
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'>
<div className='w-[75vw] mx-auto flex flex-row items-start justify-between max-tab:flex-col max-mob:flex-col max-tab:w-[95vw] max-mob:w-[95vw]'>
<div className='flex flex-col items-start max-w-[45vw] max-tab:max-w-[100vw] max-mob:max-w-[100vw]'>
<div className='pt-2 flex flex-row justify-start w-full'>
{processedEvent && processedEvent.topics && processedEvent.topics.length > 0 && (
processedEvent.topics.map((topic, index) => (
<Tag className='mr-2 text-white' key={index} value={topic}></Tag>
))
)}
</div>
<h1 className='text-4xl mt-6'>{processedEvent?.title}</h1>
<p className='text-xl mt-6'>{processedEvent?.summary}</p>
<div className='flex flex-row w-full mt-6 items-center'>
<Image
alt="avatar thumbnail"
src={returnImageProxy(author?.avatar, author?.pubkey)}
width={50}
height={50}
className="rounded-full mr-4"
/>
<p className='text-lg'>
Created by{' '}
<a rel='noreferrer noopener' target='_blank' className='text-blue-500 hover:underline'>
{author?.username || author?.name || author?.pubkey}
</a>
</p>
</div>
</div>
<div className='flex flex-col max-tab:mt-12 max-mob:mt-12'>
{processedEvent && (
<div className='flex flex-col items-center justify-between rounded-lg h-72 p-4 bg-gray-700 drop-shadow-md'>
<Image
alt="resource thumbnail"
src={returnImageProxy(processedEvent.image)}
width={344}
height={194}
className="w-[344px] h-full object-cover object-top rounded-lg"
/>
</div>
)}
</div>
</div>
</div>
<div className='w-[75vw] mx-auto flex flex-row justify-end mt-12'>
<div className='w-fit flex flex-row justify-between'>
<Button onClick={handleSubmit} label="Publish" severity='success' outlined className="w-auto m-2" />
<Button onClick={() => router.push(`/draft/${draft?.id}/edit`)} label="Edit" severity='warning' outlined className="w-auto m-2" />
<Button onClick={handleDelete} label="Delete" severity='danger' outlined className="w-auto m-2 mr-0" />
</div>
</div>
<div className='w-[75vw] mx-auto mt-12 p-12 border-t-2 border-gray-300 max-tab:p-0 max-mob:p-0 max-tab:max-w-[100vw] max-mob:max-w-[100vw]'>
{
processedEvent?.content && <MDDisplay source={processedEvent.content} />
}
</div>
</div>
);
}

View File

@ -0,0 +1,71 @@
import React, { useEffect, useState } from "react";
import { Tag } from "primereact/tag";
import Image from "next/image";
import { useImageProxy } from "@/hooks/useImageProxy";
import dynamic from "next/dynamic";
const MDDisplay = dynamic(
() => import("@uiw/react-markdown-preview"),
{
ssr: false,
}
);
const DraftCourseLesson = ({ lesson, course }) => {
const { returnImageProxy } = useImageProxy();
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'>
<div className='w-[75vw] mx-auto flex flex-row items-start justify-between max-tab:flex-col max-mob:flex-col max-tab:w-[95vw] max-mob:w-[95vw]'>
<div className='flex flex-col items-start max-w-[45vw] max-tab:max-w-[100vw] max-mob:max-w-[100vw]'>
<div className='pt-2 flex flex-row justify-start w-full'>
{lesson && lesson.topics && lesson.topics.length > 0 && (
lesson.topics.map((topic, index) => (
<Tag className='mr-2 text-white' key={index} value={topic}></Tag>
))
)}
</div>
<h1 className='text-4xl mt-6'>{lesson?.title}</h1>
<p className='text-xl mt-6'>{lesson?.summary}</p>
<div className='flex flex-row w-full mt-6 items-center'>
<Image
alt="avatar thumbnail"
src={returnImageProxy(lesson.author?.avatar, lesson.author?.pubkey)}
width={50}
height={50}
className="rounded-full mr-4"
/>
<p className='text-lg'>
Created by{' '}
<a rel='noreferrer noopener' target='_blank' className='text-blue-500 hover:underline'>
{lesson.author?.username || lesson.author?.name || lesson.author?.pubkey}
</a>
</p>
</div>
</div>
<div className='flex flex-col max-tab:mt-12 max-mob:mt-12'>
{lesson && (
<div className='flex flex-col items-center justify-between rounded-lg h-72 p-4 bg-gray-700 drop-shadow-md'>
<Image
alt="resource thumbnail"
src={returnImageProxy(lesson.image)}
width={344}
height={194}
className="w-[344px] h-full object-cover object-top rounded-lg"
/>
</div>
)}
</div>
</div>
</div>
<div className='w-[75vw] mx-auto mt-12 p-12 border-t-2 border-gray-300 max-tab:p-0 max-mob:p-0 max-tab:max-w-[100vw] max-mob:max-w-[100vw]'>
{
lesson?.content && <MDDisplay source={lesson.content} />
}
</div>
</div>
)
}
export default DraftCourseLesson;

View File

@ -44,94 +44,84 @@ const CourseForm = () => {
}
}, [session]);
/**
* Course Creation Flow:
* 1. Generate a new course ID
* 2. Process each lesson:
* - If unpublished: create event, publish to Nostr, save to DB, delete draft
* - If published: use existing data
* 3. Create and publish course event to Nostr
* 4. Save course to database
* 5. Show success message and redirect to course page
*/
useEffect(() => {
console.log('selectedLessons:', selectedLessons);
}, [selectedLessons]);
const handleSubmit = async (e) => {
const handleDraftSubmit = async (e) => {
e.preventDefault();
const newCourseId = uuidv4();
const processedLessons = [];
// Prepare the lessons from selected lessons
const resources = await Promise.all(selectedLessons.map(async (lesson) => {
// if .type is present than this lesson is a draft we need to publish
if (lesson?.type) {
const event = createLessonEvent(lesson);
const published = await event.publish();
if (!published) {
throw new Error(`Failed to publish lesson: ${lesson.title}`);
}
// Now post to resources
const resource = await axios.post('/api/resources', {
id: event.tags.find(tag => tag[0] === 'd')[1],
userId: user.id,
price: lesson.price || 0,
noteId: event.id,
});
if (resource.status !== 201) {
throw new Error(`Failed to post resource: ${lesson.title}`);
}
// now delete the draft
const deleted = await axios.delete(`/api/drafts/${lesson.id}`);
if (deleted.status !== 204) {
throw new Error(`Failed to delete draft: ${lesson.title}`);
}
return {
id: lesson.id,
userId: user.id,
price: lesson.price || 0,
noteId: event.id,
}
} else {
return {
id: lesson.d,
userId: user.id,
price: lesson.price || 0,
noteId: lesson.id,
}
}
}));
console.log('resources:', resources);
const payload = {
userId: user.id,
title,
summary,
image: coverImage,
price: price || 0,
resources, // Send the array of lesson/resource IDs
};
console.log('payload:', payload);
try {
// Step 1: Process lessons
for (const lesson of selectedLessons) {
let noteId = lesson.noteId;
// Post the course draft to the API
const response = await axios.post('/api/courses/drafts', payload);
if (!lesson.published_at) {
// Publish unpublished lesson
const event = createLessonEvent(lesson);
const published = await event.publish();
if (!published) {
throw new Error(`Failed to publish lesson: ${lesson.title}`);
}
noteId = event.id;
// Save to db and delete draft
await Promise.all([
axios.post('/api/resources', {
id: lesson.id,
noteId: noteId,
userId: user.id,
price: lesson.price || 0,
}),
axios.delete(`/api/drafts/${lesson.id}`)
]);
}
// if the lesson was already published we will have d tag, otherwise we will have id
// if the lesson was already published we will have kind tag, otherwise we will use price tag to determine the kind
// if the lesson was already published we will have pubkey tag, otherwise we will use user.pubkey
processedLessons.push({
d: lesson?.d || lesson.id,
kind: lesson.kind ?? (lesson.price ? 30402 : 30023),
pubkey: lesson.pubkey || user.pubkey
});
}
// Step 2: Create and publish course
const courseEvent = createCourseEvent(newCourseId, title, summary, coverImage, processedLessons);
const published = await courseEvent.publish();
console.log('published', published);
if (!published) {
throw new Error('Failed to publish course');
}
// Step 3: Save course to db
console.log('processedLessons:', processedLessons);
await axios.post('/api/courses', {
id: newCourseId,
resources: {
connect: processedLessons.map(lesson => ({ id: lesson?.d }))
},
noteId: courseEvent.id,
user: {
connect: { id: user.id }
},
price: price || 0
});
// step 4: Update all resources to have the course id
await Promise.all(processedLessons.map(lesson => axios.put(`/api/resources/${lesson?.d}`, { courseId: newCourseId })));
// Step 5: Show success message and redirect
showToast('success', 'Course created successfully');
router.push(`/course/${courseEvent.id}`);
console.log('response:', response);
// If successful, navigate to the course page
showToast('success', 'Course draft saved successfully');
router.push(`/course/${response.data.id}/draft`);
} catch (error) {
console.error('Error creating course:', error);
showToast('error', error.message || 'Failed to create course. Please try again.');
console.error('Error saving course draft:', error);
showToast('error', 'Failed to save course draft. Please try again.');
}
};
@ -150,22 +140,6 @@ const CourseForm = () => {
return event;
};
const createCourseEvent = (courseId, title, summary, coverImage, lessons) => {
const event = new NDKEvent(ndk);
event.kind = 30004;
event.content = "";
event.tags = [
['d', courseId],
['name', title],
['picture', coverImage],
['image', coverImage],
['description', summary],
['l', "Education"],
...lessons.map((lesson) => ['a', `${lesson.kind}:${lesson.pubkey}:${lesson.d}`]),
];
return event;
};
const handleLessonChange = (e, index) => {
const selectedLessonId = e.value;
const selectedLesson = getContentOptions(index).flatMap(group => group.items).find(lesson => lesson.value === selectedLessonId);
@ -258,7 +232,7 @@ const CourseForm = () => {
}
return (
<form onSubmit={handleSubmit}>
<form onSubmit={handleDraftSubmit}>
<div className="p-inputgroup flex-1">
<InputText value={title} onChange={(e) => setTitle(e.target.value)} placeholder="Title" />
</div>

View File

@ -96,6 +96,7 @@ const ResourceForm = ({ draft = null, isPublished = false }) => {
summary,
price,
content,
d: draft.d,
image: coverImage,
topics: [...topics.map(topic => topic.trim().toLowerCase()), 'plebdevs', 'resource']
}
@ -112,8 +113,11 @@ const ResourceForm = ({ draft = null, isPublished = false }) => {
const published = await ndk.publish(event);
if (published) {
// update the resource with new noteId
const response = await axios.put(`/api/resources/${draft.d}`, { noteId: event.id });
console.log('response', response);
showToast('success', 'Success', 'Resource published successfully.');
router.push(`/resource/${event.id}`);
router.push(`/details/${event.id}`);
} else {
showToast('error', 'Error', 'Failed to publish resource. Please try again.');
}

View File

@ -0,0 +1,58 @@
import prisma from "@/db/prisma";
// Get all CourseDrafts for a specific user
export const getAllCourseDraftsByUserId = async (userId) => {
return await prisma.courseDraft.findMany({
where: { userId },
include: {
user: true, // Include the related user
resources: true, // Include related resources
},
});
};
// Get a specific CourseDraft by its ID
export const getCourseDraftById = async (id) => {
return await prisma.courseDraft.findUnique({
where: { id },
include: {
user: true, // Include the related user
resources: true, // Include related resources
},
});
};
// Create a new CourseDraft
export const createCourseDraft = async (data) => {
return await prisma.courseDraft.create({
data: {
...data,
resources: {
connect: data.resources.map((resource) => ({ id: resource.id })),
},
},
include: {
resources: true,
}
});
};
// Update an existing CourseDraft by its ID
export const updateCourseDraft = async (id, data) => {
return await prisma.courseDraft.update({
where: { id },
data: {
...data,
resources: {
set: data.resourceIds?.map((resourceId) => ({ id: resourceId })),
},
},
});
};
// Delete a CourseDraft by its ID
export const deleteCourseDraft = async (id) => {
return await prisma.courseDraft.delete({
where: { id },
});
};

View File

@ -32,6 +32,24 @@ export async function isResourcePartOfAnyCourse(resourceId) {
return courses.length > 0;
}
export const updateLessonInCourse = async (courseId, resourceId, data) => {
return await prisma.course.update({
where: { id: courseId },
data: {
resources: {
update: {
where: { id: resourceId },
data: {
title: data.title,
summary: data.summary,
// Add any other fields you want to update in the lesson
}
}
}
}
});
};
export const createResource = async (data) => {
return await prisma.resource.create({
data,

View File

@ -51,6 +51,7 @@ export const createUser = async (data) => {
};
export const updateUser = async (id, data) => {
console.log("user modelllll", id, data)
return await prisma.user.update({
where: { id },
data,

View File

@ -23,8 +23,8 @@ export function useContentIdsQuery() {
const { data: contentIds, isLoading: contentIdsLoading, error: contentIdsError, refetch: refetchContentIds } = useQuery({
queryKey: ['contentIds', isClient],
queryFn: fetchContentIdsDB,
staleTime: 1000 * 60 * 30, // 30 minutes
refetchInterval: 1000 * 60 * 30, // 30 minutes
// staleTime: 1000 * 60 * 30, // 30 minutes
// refetchInterval: 1000 * 60 * 30, // 30 minutes
enabled: isClient
});

View File

@ -20,15 +20,31 @@ export function useDraftsQuery() {
const fetchDraftsDB = async () => {
try {
let allDrafts = [];
if (!user.id) {
return [];
}
const response = await axios.get(`/api/drafts/all/${user.id}`);
if (response.status === 200) {
allDrafts = response.data;
const courseDrafts = await fetchCourseDrafts();
allDrafts = [...allDrafts, ...courseDrafts];
}
return allDrafts;
} catch (error) {
console.error('Error fetching drafts from DB:', error);
return [];
}
};
const fetchCourseDrafts = async () => {
try {
const response = await axios.get(`/api/courses/drafts/${user.id}/all`);
const drafts = response.data;
console.log('drafts:', drafts);
return drafts;
} catch (error) {
console.error('Error fetching drafts from DB:', error);
console.error('Error fetching course drafts from DB:', error);
return [];
}
};
@ -36,8 +52,8 @@ export function useDraftsQuery() {
const { data: drafts, isLoading: draftsLoading, error: draftsError, refetch: refetchDrafts } = useQuery({
queryKey: ['drafts', isClient],
queryFn: fetchDraftsDB,
staleTime: 1000 * 60 * 30, // 30 minutes
refetchInterval: 1000 * 60 * 30, // 30 minutes
// staleTime: 1000 * 60 * 30, // 30 minutes
// refetchInterval: 1000 * 60 * 30, // 30 minutes
enabled: isClient && !!user.id, // Only enable if client-side and user ID is available
});

View File

@ -1,45 +1,33 @@
import { useState, useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useNDKContext } from '@/context/NDKContext';
import { useContentIdsQuery } from '@/hooks/apiQueries/useContentIdsQuery';
import axios from 'axios';
const AUTHOR_PUBKEY = process.env.NEXT_PUBLIC_AUTHOR_PUBKEY;
export function useCoursesQuery() {
const [isClient, setIsClient] = useState(false);
const { contentIds, contentIdsLoading, contentIdsError, refetchContentIds } = useContentIdsQuery();
const ndk = useNDKContext();
useEffect(() => {
setIsClient(true);
}, []);
useEffect(() => {
refetchContentIds();
}, [refetchContentIds]);
const hasRequiredProperties = (event) => {
if (contentIdsLoading) {
return false;
}
const hasCourseTag = event.tags.some(([tag, value]) => tag === "t" && value === "course");
const hasId = contentIds.includes(event.id);
return hasCourseTag && hasId;
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;
};
const fetchCoursesFromNDK = async () => {
try {
if (contentIdsLoading) {
return []; // or a loading state indication
}
if (contentIdsError) {
console.error('Error fetching content IDs:', contentIdsError);
return [];
}
if (!contentIds) {
return [];
const response = await axios.get(`/api/content/all`);
const contentIds = response.data;
if (!contentIds || contentIds.length === 0) {
console.log('No content IDs found');
return []; // Return early if no content IDs are found
}
await ndk.connect();
@ -47,9 +35,11 @@ export function useCoursesQuery() {
const filter = { kinds: [30004], authors: [AUTHOR_PUBKEY] };
const events = await ndk.fetchEvents(filter);
console.log('events', events);
if (events && events.size > 0) {
const eventsArray = Array.from(events);
const courses = eventsArray.filter(event => hasRequiredProperties(event));
const courses = eventsArray.filter(event => hasRequiredProperties(event, contentIds));
return courses;
}
return [];
@ -62,8 +52,8 @@ export function useCoursesQuery() {
const { data: courses, isLoading: coursesLoading, error: coursesError, refetch: refetchCourses } = useQuery({
queryKey: ['courses', isClient],
queryFn: fetchCoursesFromNDK,
staleTime: 1000 * 60 * 30, // 30 minutes
refetchInterval: 1000 * 60 * 30, // 30 minutes
// staleTime: 1000 * 60 * 30, // 30 minutes
// refetchInterval: 1000 * 60 * 30, // 30 minutes
enabled: isClient,
});

View File

@ -1,73 +1,59 @@
import { useState, useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useNDKContext } from '@/context/NDKContext';
import { useContentIdsQuery } from '@/hooks/apiQueries/useContentIdsQuery';
import axios from 'axios';
const AUTHOR_PUBKEY = process.env.NEXT_PUBLIC_AUTHOR_PUBKEY
const AUTHOR_PUBKEY = process.env.NEXT_PUBLIC_AUTHOR_PUBKEY;
export function useResourcesQuery() {
const [isClient, setIsClient] = useState(false);
const [isClient, setIsClient] = useState(false);
const ndk = useNDKContext();
const { contentIds, contentIdsLoading, contentIdsError, refetchContentIds } = useContentIdsQuery();
const ndk = useNDKContext();
useEffect(() => {
setIsClient(true);
}, []);
useEffect(() => {
setIsClient(true);
}, []);
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;
};
useEffect(() => {
refetchContentIds();
}, [refetchContentIds]);
const fetchResourcesFromNDK = async () => {
try {
const response = await axios.get(`/api/content/all`);
const contentIds = response.data;
const hasRequiredProperties = (event) => {
if (!contentIds) {
return false;
}
if (!contentIds || contentIds.length === 0) {
console.log('No content IDs found');
return []; // Return early if no content IDs are found
}
const hasPlebDevs = event.tags.some(([tag, value]) => tag === "t" && value === "plebdevs");
const hasWorkshop = event.tags.some(([tag, value]) => tag === "t" && value === "resource");
const hasId = contentIds.includes(event.id);
return hasPlebDevs && hasWorkshop && hasId;
};
await ndk.connect();
const fetchResourcesFromNDK = async () => {
try {
if (contentIdsLoading) {
return []; // or a loading state indication
}
if (contentIdsError) {
console.error('Error fetching content IDs:', contentIdsError);
return [];
}
if (!contentIds) {
return [];
}
console.log('Fetching workshops from NDK');
await ndk.connect();
const filter = { kinds: [30023, 30402], authors: [AUTHOR_PUBKEY] };
const events = await ndk.fetchEvents(filter);
const filter = { kinds: [30023, 30402], authors: [AUTHOR_PUBKEY] };
const events = await ndk.fetchEvents(filter);
if (events && events.size > 0) {
const eventsArray = Array.from(events);
const resources = eventsArray.filter(event => hasRequiredProperties(event, contentIds));
return resources;
}
return [];
} catch (error) {
console.error('Error fetching resources from NDK:', error);
return [];
}
};
if (events && events.size > 0) {
const eventsArray = Array.from(events);
console.log('eventsArray', eventsArray)
const resources = eventsArray.filter(event => hasRequiredProperties(event));
return resources;
}
return [];
} catch (error) {
console.error('Error fetching workshops from NDK:', error);
return [];
}
};
const { data: resources, isLoading: resourcesLoading, error: resourcesError, refetch: refetchResources } = useQuery({
queryKey: ['resources', isClient],
queryFn: fetchResourcesFromNDK,
// staleTime: 1000 * 60 * 30, // 30 minutes
// refetchInterval: 1000 * 60 * 30, // 30 minutes
enabled: isClient,
});
const { data: resources, isLoading: resourcesLoading, error: resourcesError, refetch: refetchResources } = useQuery({
queryKey: ['resources', isClient],
queryFn: fetchResourcesFromNDK,
staleTime: 1000 * 60 * 30, // 30 minutes
refetchInterval: 1000 * 60 * 30, // 30 minutes
enabled: isClient,
})
return { resources, resourcesLoading, resourcesError, refetchResources }
}
return { resources, resourcesLoading, resourcesError, refetchResources };
}

View File

@ -1,47 +1,35 @@
import { useState, useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useNDKContext } from '@/context/NDKContext';
import { useContentIdsQuery } from '@/hooks/apiQueries/useContentIdsQuery';
import axios from 'axios';
const AUTHOR_PUBKEY = process.env.NEXT_PUBLIC_AUTHOR_PUBKEY;
export function useWorkshopsQuery() {
const [isClient, setIsClient] = useState(false);
const { contentIds, contentIdsLoading, contentIdsError, refetchContentIds } = useContentIdsQuery();
const ndk = useNDKContext();
useEffect(() => {
setIsClient(true);
}, []);
useEffect(() => {
refetchContentIds();
}, [refetchContentIds]);
const hasRequiredProperties = (event) => {
if (contentIdsLoading) {
return false;
}
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 = contentIds.includes(event.id);
const hasId = event.tags.some(([tag, value]) => tag === "d" && contentIds.includes(value));
return hasPlebDevs && hasWorkshop && hasId;
};
const fetchWorkshopsFromNDK = async () => {
try {
if (contentIdsLoading) {
return []; // or a loading state indication
const response = await axios.get(`/api/content/all`);
const contentIds = response.data;
if (!contentIds || contentIds.length === 0) {
console.log('No content IDs found');
return []; // Return early if no content IDs are found
}
if (contentIdsError) {
console.error('Error fetching content IDs:', contentIdsError);
return [];
}
if (!contentIds) {
return [];
}
console.log('Fetching workshops from NDK');
await ndk.connect();
const filter = { kinds: [30023, 30402], authors: [AUTHOR_PUBKEY] };
@ -49,8 +37,7 @@ export function useWorkshopsQuery() {
if (events && events.size > 0) {
const eventsArray = Array.from(events);
console.log('eventsArray', eventsArray);
const workshops = eventsArray.filter(event => hasRequiredProperties(event));
const workshops = eventsArray.filter(event => hasRequiredProperties(event, contentIds));
return workshops;
}
return [];
@ -63,8 +50,8 @@ export function useWorkshopsQuery() {
const { data: workshops, isLoading: workshopsLoading, error: workshopsError, refetch: refetchWorkshops } = useQuery({
queryKey: ['workshops', isClient],
queryFn: fetchWorkshopsFromNDK,
staleTime: 1000 * 60 * 30, // 30 minutes
refetchInterval: 1000 * 60 * 30, // 30 minutes
// staleTime: 1000 * 60 * 30, // 30 minutes
// refetchInterval: 1000 * 60 * 30, // 30 minutes
enabled: isClient,
});

View File

@ -41,7 +41,14 @@ export default NextAuth({
const response = await axios.get(`${BASE_URL}/api/users/${credentials.pubkey}`);
if (response.status === 200 && response.data) {
const fields = await findKind0Fields(profile);
return { pubkey: credentials.pubkey, ...fields };
// Combine user object with kind0Fields, giving priority to kind0Fields
const combinedUser = { ...fields, ...response.data };
// Update the user on the backend if necessary
// await axios.put(`${BASE_URL}/api/users/${combinedUser.id}`, combinedUser);
return combinedUser;
} else if (response.status === 204) {
// Create user
if (profile) {
@ -63,7 +70,7 @@ export default NextAuth({
],
callbacks: {
async jwt({ token, user }) {
// Add user to the token if user object exists
// Add combined user object to the token
if (user) {
token.user = user;
}

View File

@ -0,0 +1,56 @@
import { getAllCourseDraftsByUserId, getCourseDraftById, updateCourseDraft, deleteCourseDraft } from "@/db/models/courseDraftModels";
export default async function handler(req, res) {
const { slug } = req.query;
console.log('slug:', slug);
const userId = req.body?.userId || req.query?.userId;
console.log('userId:', userId);
if (req.method === 'GET') {
if (slug && !userId) {
try {
const courseDraft = await getCourseDraftById(slug);
if (courseDraft) {
res.status(200).json(courseDraft);
} else {
res.status(404).json({ error: 'Course draft not found' });
}
} catch (error) {
res.status(500).json({ error: error.message });
}
} else if (userId) {
try {
console.log('INHEEEERE:', userId);
const courseDrafts = await getAllCourseDraftsByUserId(userId);
res.status(200).json(courseDrafts);
} catch (error) {
res.status(500).json({ error: error.message });
}
} else {
res.status(400).json({ error: 'User ID is required' });
}
} else if (req.method === 'PUT') {
if (!slug) {
return res.status(400).json({ error: 'Slug is required to update a course draft' });
}
try {
const updatedCourseDraft = await updateCourseDraft(slug, req.body);
res.status(200).json(updatedCourseDraft);
} catch (error) {
res.status(400).json({ error: error.message });
}
} else if (req.method === 'DELETE') {
if (!slug) {
return res.status(400).json({ error: 'Slug is required to delete a course draft' });
}
try {
await deleteCourseDraft(slug);
res.status(204).end();
} catch (error) {
res.status(500).json({ error: error.message });
}
} else {
res.setHeader('Allow', ['GET', 'PUT', 'DELETE']);
res.status(405).end(`Method ${req.method} Not Allowed`);
}
}

View File

@ -0,0 +1,20 @@
import { getAllCourseDraftsByUserId } from "@/db/models/courseDraftModels";
export default async function handler(req, res) {
// the slug here is user id to get all drafts for a given user
const {slug} = req.query;
if (req.method === 'GET') {
if (slug) {
try {
const courseDrafts = await getAllCourseDraftsByUserId(slug);
res.status(200).json(courseDrafts);
} catch (error) {
res.status(500).json({ error: error.message });
}
} else {
res.status(400).json({ error: 'User ID is required' });
}
} else {
res.status(405).json({ error: 'Method not allowed' });
}
}

View File

@ -0,0 +1,18 @@
import { createCourseDraft } from "@/db/models/courseDraftModels";
export default async function handler(req, res) {
if (req.method === 'POST') {
try {
if (!req.body || !req.body.userId) {
return res.status(400).json({ error: 'User ID is required' });
}
const newCourseDraft = await createCourseDraft(req.body);
res.status(201).json(newCourseDraft);
} catch (error) {
res.status(400).json({ error: error.message });
}
} else {
res.status(405).json({ error: 'Method not allowed' });
}
}

View File

@ -32,7 +32,7 @@ export default async function handler(req, res) {
case 'PUT':
if (!isPubkey) {
// Update operation should be done with an ID, not a pubkey
const updatedUser = await updateUser(parseInt(slug), req.body);
const updatedUser = await updateUser(slug, req.body);
res.status(200).json(updatedUser);
} else {
// Handle attempt to update user with pubkey

View File

@ -11,8 +11,6 @@ export default function SignIn() {
const { data: session, status } = useSession(); // Get the current session's data and status
// const ndk = useNDKContext()
useEffect(() => {
console.log("session", session)
}, [session])
@ -33,7 +31,6 @@ export default function SignIn() {
try {
const user = await nip07signer.user()
console.log("user in signin", user)
const pubkey = user?._pubkey
signIn("nostr", { pubkey })
} catch (error) {

View File

@ -0,0 +1,90 @@
import React, { useEffect, useState, useCallback } from "react";
import { useRouter } from "next/router";
import axios from "axios";
import { parseEvent, findKind0Fields } from "@/utils/nostr";
import DraftCourseDetails from "@/components/course/DraftCourseDetails";
import DraftCourseLesson from "@/components/course/DraftCourseLesson";
import dynamic from 'next/dynamic';
import { useNDKContext } from "@/context/NDKContext";
const MDDisplay = dynamic(
() => import("@uiw/react-markdown-preview"),
{
ssr: false,
}
);
const DraftCourse = () => {
const [course, setCourse] = useState(null);
const [lessons, setLessons] = useState([]);
const [lessonsWithAuthors, setLessonsWithAuthors] = useState([]);
const router = useRouter();
const ndk = useNDKContext();
const fetchAuthor = useCallback(async (pubkey) => {
if (!pubkey) return;
const author = await ndk.getUser({ pubkey });
const profile = await author.fetchProfile();
const fields = await findKind0Fields(profile);
if (fields) {
return fields;
}
return null; // Return null if no fields found
}, [ndk]);
useEffect(() => {
if (router.isReady) {
const { slug } = router.query;
axios.get(`/api/courses/drafts/${slug}`)
.then(res => {
console.log('res:', res.data);
setCourse(res.data);
setLessons(res.data.resources); // Set the raw lessons
})
.catch(err => {
console.error(err);
});
}
}, [router.isReady, router.query]);
useEffect(() => {
const fetchLessonDetails = async () => {
if (lessons.length > 0) {
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
};
}
return lesson; // Fallback to the original lesson if no event found
}));
setLessonsWithAuthors(newLessonsWithAuthors);
}
};
fetchLessonDetails();
}, [lessons, ndk, fetchAuthor]);
return (
<>
<DraftCourseDetails processedEvent={course} lessons={lessonsWithAuthors} />
{lessonsWithAuthors.length > 0 && lessonsWithAuthors.map((lesson, index) => (
<DraftCourseLesson key={lesson.id} lesson={lesson} course={course} />
))}
</>
);
}
export default DraftCourse;

View File

@ -16,6 +16,7 @@ import { useToast } from '@/hooks/useToast';
import { useNDKContext } from '@/context/NDKContext';
import { useZapsSubscription } from '@/hooks/nostrQueries/zaps/useZapsSubscription';
import 'primeicons/primeicons.css';
const MDDisplay = dynamic(
() => import("@uiw/react-markdown-preview"),
{
@ -31,6 +32,7 @@ const BitcoinConnectPayButton = dynamic(
);
const privkey = process.env.NEXT_PUBLIC_APP_PRIV_KEY;
const pubkey = process.env.NEXT_PUBLIC_APP_PUBLIC_KEY;
export default function Details() {
const [event, setEvent] = useState(null);
@ -54,6 +56,7 @@ export default function Details() {
useEffect(() => {
if (session) {
console.log('session:', session);
setUser(session.user);
}
}, [session]);
@ -77,17 +80,14 @@ export default function Details() {
useEffect(() => {
const decryptContent = async () => {
if (user && paidResource) {
if (!user.purchased.includes(processedEvent.id)) {
// decrypt the content
console.log('privkey', privkey);
console.log('user.pubkey', user.pubkey);
console.log('processedEvent.content', processedEvent.content);
const decryptedContent = await nip04.decrypt(privkey, user.pubkey, processedEvent.content);
console.log('decryptedContent', decryptedContent);
setDecryptedContent(decryptedContent);
if (user.purchased.includes(processedEvent.id) || (user?.role && user?.role.subscribed)) {
// decrypt the content
const decryptedContent = await nip04.decrypt(privkey, pubkey, processedEvent.content);
console.log('decryptedContent', decryptedContent);
setDecryptedContent(decryptedContent);
}
}
}
}
decryptContent();
}, [user, paidResource, processedEvent]);
@ -181,7 +181,7 @@ export default function Details() {
const handleDelete = async () => {
try {
const response = await axios.delete(`/api/resources/${processedEvent.id}`);
const response = await axios.delete(`/api/resources/${processedEvent.d}`);
if (response.status === 204) {
showToast('success', 'Success', 'Resource deleted successfully.');
router.push('/');
@ -274,9 +274,13 @@ export default function Details() {
)}
<div className='w-[75vw] mx-auto mt-12 p-12 border-t-2 border-gray-300 max-tab:p-0 max-mob:p-0 max-tab:max-w-[100vw] max-mob:max-w-[100vw]'>
{
processedEvent?.content && <MDDisplay source={processedEvent.content} />
decryptedContent ? (
<MDDisplay source={decryptedContent} />
) : (
processedEvent?.content && <MDDisplay source={processedEvent.content} />
)
}
</div>
</div>
);
}
}

View File

@ -84,7 +84,7 @@ export default function Draft() {
return;
}
console.log('unsignedEvent:', unsignedEvent.validate());
console.log('unsignedEvent:', unsignedEvent.validate(), unsignedEvent);
console.log('unsignedEvent validation:', validationResult);
if (unsignedEvent) {

View File

@ -1,7 +1,6 @@
import { nip19 } from "nostr-tools";
export const findKind0Fields = async (kind0) => {
console.log('kind0', kind0);
let fields = {}
const usernameProperties = ['name', 'displayName', 'display_name', 'username', 'handle', 'alias'];