Add additional links to drafts and published resources, updated forms and rendering accordingly

This commit is contained in:
austinkelsay 2024-08-24 17:10:00 -05:00
parent b2d9d2bbe6
commit 347ca659d3
11 changed files with 220 additions and 75 deletions

View File

@ -93,6 +93,7 @@ CREATE TABLE "Draft" (
"image" TEXT,
"price" INTEGER DEFAULT 0,
"topics" TEXT[],
"additionalLinks" TEXT[],
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,

View File

@ -80,7 +80,6 @@ model Course {
updatedAt DateTime @updatedAt
}
// Additional resources
model Resource {
id String @id // Client generates UUID
userId String
@ -94,22 +93,23 @@ model Resource {
updatedAt DateTime @updatedAt
}
// Additional resources
model Draft {
id String @id @default(uuid())
userId String
user User @relation(fields: [userId], references: [id])
type String
title String
summary String
content String
image String?
price Int? @default(0)
topics String[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
draftLessons DraftLesson[]
lessons Lesson[]
id String @id @default(uuid())
userId String
user User @relation(fields: [userId], references: [id])
type String
title String
summary String
content String
image String?
price Int? @default(0)
topics String[]
additionalLinks String[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
draftLessons DraftLesson[]
lessons Lesson[]
}
model CourseDraft {

View File

@ -14,6 +14,7 @@ import { NDKEvent } from "@nostr-dev-kit/ndk";
import { findKind0Fields } from '@/utils/nostr';
import { useToast } from '@/hooks/useToast';
import { formatDateTime } from '@/utils/time';
import { validateEvent } from '@/utils/nostr';
import 'primeicons/primeicons.css';
const MDDisplay = dynamic(
@ -23,25 +24,6 @@ const MDDisplay = dynamic(
}
);
function validateEvent(event) {
if (typeof event.kind !== "number") return "Invalid kind";
if (typeof event.content !== "string") return "Invalid content";
if (typeof event.created_at !== "number") return "Invalid created_at";
if (typeof event.pubkey !== "string") return "Invalid pubkey";
if (!event.pubkey.match(/^[a-f0-9]{64}$/)) return "Invalid pubkey format";
if (!Array.isArray(event.tags)) return "Invalid tags";
for (let i = 0; i < event.tags.length; i++) {
const tag = event.tags[i];
if (!Array.isArray(tag)) return "Invalid tag structure";
for (let j = 0; j < tag.length; j++) {
if (typeof tag[j] === "object") return "Invalid tag value";
}
}
return true;
}
export default function DraftCourseDetails({ processedEvent, draftId, lessons }) {
const [author, setAuthor] = useState(null);
const [user, setUser] = useState(null);
@ -259,6 +241,7 @@ export default function DraftCourseDetails({ processedEvent, draftId, lessons })
...draft.topics.map(topic => ['t', topic]),
['published_at', Math.floor(Date.now() / 1000).toString()],
...(draft?.price ? [['price', draft.price.toString()], ['location', `https://plebdevs.com/details/${draft.id}`]] : []),
...(draft?.additionalLinks ? draft.additionalLinks.map(link => ['r', link]) : []),
];
type = 'resource';
@ -281,6 +264,7 @@ export default function DraftCourseDetails({ processedEvent, draftId, lessons })
...draft.topics.map(topic => ['t', topic]),
['published_at', Math.floor(Date.now() / 1000).toString()],
...(draft?.price ? [['price', draft.price.toString()], ['location', `https://plebdevs.com/details/${draft.id}`]] : []),
...(draft?.additionalLinks ? draft.additionalLinks.map(link => ['r', link]) : []),
];
type = 'workshop';
@ -360,7 +344,7 @@ export default function DraftCourseDetails({ processedEvent, draftId, lessons })
{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"
alt="course thumbnail"
src={returnImageProxy(processedEvent.image)}
width={344}
height={194}

View File

@ -1,9 +1,19 @@
import React, { useEffect, useState } from "react";
import { Tag } from "primereact/tag";
import { Message } from "primereact/message";
import { Button } from "primereact/button";
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(
@ -14,8 +24,21 @@ const MDDisplay = dynamic(
);
const DraftCourseLesson = ({ lesson, course }) => {
const { returnImageProxy } = useImageProxy();
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) {
console.log(lesson);
@ -39,6 +62,20 @@ const DraftCourseLesson = ({ 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'>Additional 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"
@ -63,9 +100,17 @@ const DraftCourseLesson = ({ lesson, course }) => {
}
<div className='flex flex-row w-full mt-6 items-center'>
{isPublished ? (
<Message severity="success" text="published" />
<>
<Message severity="success" text="published" />
<Button onClick={() => router.push(`/details/${lesson.id}`)} label="View" outlined className="w-auto m-2" />
<Button onClick={() => router.push(`/details/${lesson.id}/edit`)} label="Edit" severity='warning' outlined className="w-auto m-2" />
</>
) : (
<Message severity="info" text="draft" />
<>
<Message severity="info" text="draft (unpublished)" />
<Button onClick={() => router.push(`/draft/${lesson.id}`)} label="View" outlined className="w-auto m-2" />
<Button onClick={() => router.push(`/draft/${lesson.id}/edit`)} label="Edit" severity='warning' outlined className="w-auto m-2" />
</>
)}
</div>
</div>

View File

@ -17,6 +17,8 @@ const MDEditor = dynamic(
}
);
import 'primeicons/primeicons.css';
import { Tooltip } from 'primereact/tooltip';
import 'primereact/resources/primereact.min.css';
const ResourceForm = ({ draft = null, isPublished = false }) => {
const [title, setTitle] = useState(draft?.title || '');
@ -27,6 +29,7 @@ const ResourceForm = ({ draft = null, isPublished = false }) => {
const [topics, setTopics] = useState(draft?.topics || ['']);
const [content, setContent] = useState(draft?.content || '');
const [user, setUser] = useState(null);
const [additionalLinks, setAdditionalLinks] = useState(draft?.additionalLinks || ['']);
const { data: session, status } = useSession();
const { showToast } = useToast();
@ -57,6 +60,7 @@ const ResourceForm = ({ draft = null, isPublished = false }) => {
setContent(draft.content);
setCoverImage(draft.image);
setTopics(draft.topics || []);
setAdditionalLinks(draft.additionalLinks || []);
}
}, [draft]);
@ -98,7 +102,8 @@ const ResourceForm = ({ draft = null, isPublished = false }) => {
content,
d: draft.d,
image: coverImage,
topics: [...topics.map(topic => topic.trim().toLowerCase()), 'plebdevs', 'resource']
topics: [...topics.map(topic => topic.trim().toLowerCase()), 'plebdevs', 'resource'],
additionalLinks: additionalLinks.filter(link => link.trim() !== '')
}
console.log('handlePublishedResource', updatedDraft);
@ -148,7 +153,8 @@ const ResourceForm = ({ draft = null, isPublished = false }) => {
price: isPaidResource ? price : null,
content,
image: coverImage,
topics: [...topics.map(topic => topic.trim().toLowerCase()), 'plebdevs', 'resource']
topics: [...topics.map(topic => topic.trim().toLowerCase()), 'plebdevs', 'resource'],
additionalLinks: additionalLinks.filter(link => link.trim() !== '')
};
if (!draft) {
@ -193,6 +199,22 @@ const ResourceForm = ({ draft = null, isPublished = false }) => {
setTopics(updatedTopics);
};
const handleAdditionalLinkChange = (index, value) => {
const updatedAdditionalLinks = additionalLinks.map((link, i) => i === index ? value : link);
setAdditionalLinks(updatedAdditionalLinks);
};
const addAdditionalLink = (e) => {
e.preventDefault();
setAdditionalLinks([...additionalLinks, '']); // Add an empty string to the additionalLinks array
};
const removeAdditionalLink = (e, index) => {
e.preventDefault();
const updatedAdditionalLinks = additionalLinks.filter((_, i) => i !== index);
setAdditionalLinks(updatedAdditionalLinks);
};
return (
<form onSubmit={isPublished && draft ? handlePublishedResource : handleSubmit}>
<div className="p-inputgroup flex-1">
@ -224,6 +246,30 @@ const ResourceForm = ({ draft = null, isPublished = false }) => {
/>
</div>
</div>
<div className="mt-8 flex-col w-full">
<span className="pl-1 flex items-center">
Additional Links
<i className="pi pi-info-circle ml-2 cursor-pointer"
data-pr-tooltip="Add any relevant links that pair with this content"
data-pr-position="right"
data-pr-at="right+5 top"
data-pr-my="left center-2"
style={{ fontSize: '1rem', color: 'var(--primary-color)' }}
/>
</span>
{additionalLinks.map((link, index) => (
<div className="p-inputgroup flex-1" key={index}>
<InputText value={link} onChange={(e) => handleAdditionalLinkChange(index, e.target.value)} placeholder="https://plebdevs.com" className="w-full mt-2" />
{index > 0 && (
<Button icon="pi pi-times" className="p-button-danger mt-2" onClick={(e) => removeAdditionalLink(e, index)} />
)}
</div>
))}
<div className="w-full flex flex-row items-end justify-end py-2">
<Button icon="pi pi-plus" onClick={addAdditionalLink} />
</div>
<Tooltip target=".pi-info-circle" />
</div>
<div className="mt-8 flex-col w-full">
{topics.map((topic, index) => (
<div className="p-inputgroup flex-1" key={index}>
@ -244,4 +290,4 @@ const ResourceForm = ({ draft = null, isPublished = false }) => {
);
}
export default ResourceForm;
export default ResourceForm;

View File

@ -8,6 +8,8 @@ import { Button } from 'primereact/button';
import { useToast } from '@/hooks/useToast';
import { useSession } from 'next-auth/react';
import 'primeicons/primeicons.css';
import { Tooltip } from 'primereact/tooltip';
import 'primereact/resources/primereact.min.css';
const WorkshopForm = ({ draft = null }) => {
const [title, setTitle] = useState(draft?.title || '');
@ -17,6 +19,7 @@ const WorkshopForm = ({ draft = null }) => {
const [videoUrl, setVideoUrl] = useState(draft?.content || '');
const [coverImage, setCoverImage] = useState(draft?.image || '');
const [topics, setTopics] = useState(draft?.topics || ['']);
const [additionalLinks, setAdditionalLinks] = useState(draft?.additionalLinks || ['']);
const router = useRouter();
const { data: session, status } = useSession();
@ -38,6 +41,7 @@ const WorkshopForm = ({ draft = null }) => {
setVideoUrl(draft.content);
setCoverImage(draft.image);
setTopics(draft.topics || ['']);
setAdditionalLinks(draft.additionalLinks || ['']);
}
}, [draft]);
@ -72,7 +76,8 @@ const WorkshopForm = ({ draft = null }) => {
content: embedCode,
image: coverImage,
user: userResponse.data.id,
topics: [...topics.map(topic => topic.trim().toLowerCase()), 'plebdevs', 'workshop']
topics: [...topics.map(topic => topic.trim().toLowerCase()), 'plebdevs', 'workshop'],
additionalLinks: additionalLinks.filter(link => link.trim() !== ''),
};
if (payload && payload.user) {
@ -96,11 +101,6 @@ const WorkshopForm = ({ draft = null }) => {
}
};
const onUpload = (event) => {
showToast('success', 'Success', 'File Uploaded');
console.log(event.files[0]);
}
const handleTopicChange = (index, value) => {
const updatedTopics = topics.map((topic, i) => i === index ? value : topic);
setTopics(updatedTopics);
@ -116,6 +116,22 @@ const WorkshopForm = ({ draft = null }) => {
const updatedTopics = topics.filter((_, i) => i !== index);
setTopics(updatedTopics);
};
const handleLinkChange = (index, value) => {
const updatedLinks = additionalLinks.map((link, i) => i === index ? value : link);
setAdditionalLinks(updatedLinks);
};
const addLink = (e) => {
e.preventDefault();
setAdditionalLinks([...additionalLinks, '']);
};
const removeLink = (e, index) => {
e.preventDefault();
const updatedLinks = additionalLinks.filter((_, i) => i !== index);
setAdditionalLinks(updatedLinks);
};
return (
@ -143,6 +159,30 @@ const WorkshopForm = ({ draft = null }) => {
<div className="p-inputgroup flex-1 mt-4">
<InputText value={coverImage} onChange={(e) => setCoverImage(e.target.value)} placeholder="Cover Image URL" />
</div>
<div className="mt-8 flex-col w-full">
<span className="pl-1 flex items-center">
Additional Links
<i className="pi pi-info-circle ml-2 cursor-pointer"
data-pr-tooltip="Add any relevant or additional links that pair with this content"
data-pr-position="right"
data-pr-at="right+5 top"
data-pr-my="left center-2"
style={{ fontSize: '1rem', color: 'var(--primary-color)' }}
/>
</span>
{additionalLinks.map((link, index) => (
<div className="p-inputgroup flex-1" key={index}>
<InputText value={link} onChange={(e) => handleLinkChange(index, e.target.value)} placeholder="https://example.com" className="w-full mt-2" />
{index > 0 && (
<Button icon="pi pi-times" className="p-button-danger mt-2" onClick={(e) => removeLink(e, index)} />
)}
</div>
))}
<div className="w-full flex flex-row items-end justify-end py-2">
<Button icon="pi pi-plus" onClick={addLink} />
</div>
<Tooltip target=".pi-info-circle" />
</div>
<div className="mt-4 flex-col w-full">
{topics.map((topic, index) => (
<div className="p-inputgroup flex-1" key={index}>
@ -163,4 +203,4 @@ const WorkshopForm = ({ draft = null }) => {
);
}
export default WorkshopForm;
export default WorkshopForm;

View File

@ -27,20 +27,22 @@ export const createDraft = async (data) => {
id: data.user,
},
},
additionalLinks: data.additionalLinks || [],
},
});
};
export const updateDraft = async (id, data) => {
const { user, ...otherData } = data;
const { user, additionalLinks, ...otherData } = data;
return await prisma.draft.update({
where: { id },
data: {
...otherData,
user: user ? {
connect: { id: user }
} : undefined
} : undefined,
additionalLinks: additionalLinks || undefined,
},
});
};
@ -49,4 +51,4 @@ export const deleteDraft = async (id) => {
return await prisma.draft.delete({
where: { id },
});
}
}

View File

@ -3,7 +3,7 @@ import { useRouter } from "next/router";
import { parseEvent } from "@/utils/nostr";
import ResourceForm from "@/components/forms/ResourceForm";
import WorkshopForm from "@/components/forms/WorkshopForm";
import CourseForm from "@/components/forms/CourseForm";
import CourseForm from "@/components/forms/course/CourseForm";
import { useNDKContext } from "@/context/NDKContext";
import { useToast } from "@/hooks/useToast";

View File

@ -3,7 +3,7 @@ import { useRouter } from "next/router";
import axios from "axios";
import ResourceForm from "@/components/forms/ResourceForm";
import WorkshopForm from "@/components/forms/WorkshopForm";
import CourseForm from "@/components/forms/CourseForm";
import CourseForm from "@/components/forms/course/CourseForm";
const Edit = () => {
const [draft, setDraft] = useState(null);

View File

@ -11,10 +11,13 @@ import { useToast } from '@/hooks/useToast';
import { Tag } from 'primereact/tag';
import { useNDKContext } from '@/context/NDKContext';
import { NDKEvent } from '@nostr-dev-kit/ndk';
import { formatDateTime } from '@/utils/time';
import Image from 'next/image';
import useResponsiveImageDimensions from '@/hooks/useResponsiveImageDimensions';
import 'primeicons/primeicons.css';
import dynamic from 'next/dynamic';
import { validateEvent } from '@/utils/nostr';
const MDDisplay = dynamic(
() => import("@uiw/react-markdown-preview"),
{
@ -22,25 +25,6 @@ const MDDisplay = dynamic(
}
);
function validateEvent(event) {
if (typeof event.kind !== "number") return "Invalid kind";
if (typeof event.content !== "string") return "Invalid content";
if (typeof event.created_at !== "number") return "Invalid created_at";
if (typeof event.pubkey !== "string") return "Invalid pubkey";
if (!event.pubkey.match(/^[a-f0-9]{64}$/)) return "Invalid pubkey format";
if (!Array.isArray(event.tags)) return "Invalid tags";
for (let i = 0; i < event.tags.length; i++) {
const tag = event.tags[i];
if (!Array.isArray(tag)) return "Invalid tag structure";
for (let j = 0; j < tag.length; j++) {
if (typeof tag[j] === "object") return "Invalid tag value";
}
}
return true;
}
export default function Draft() {
const [draft, setDraft] = useState(null);
const { returnImageProxy } = useImageProxy();
@ -206,6 +190,7 @@ export default function Draft() {
...draft.topics.map(topic => ['t', topic]),
['published_at', Math.floor(Date.now() / 1000).toString()],
...(draft?.price ? [['price', draft.price.toString()], ['location', `https://plebdevs.com/details/${draft.id}`]] : []),
...(draft?.additionalLinks ? draft.additionalLinks.map(link => ['r', link]) : []),
];
type = 'resource';
@ -228,6 +213,7 @@ export default function Draft() {
...draft.topics.map(topic => ['t', topic]),
['published_at', Math.floor(Date.now() / 1000).toString()],
...(draft?.price ? [['price', draft.price.toString()], ['location', `https://plebdevs.com/details/${draft.id}`]] : []),
...(draft?.additionalLinks ? draft.additionalLinks.map(link => ['r', link]) : []),
];
type = 'workshop';
@ -256,10 +242,24 @@ export default function Draft() {
</div>
<h1 className='text-4xl mt-6'>{draft?.title}</h1>
<p className='text-xl mt-6'>{draft?.summary}</p>
{draft?.additionalLinks && draft.additionalLinks.length > 0 && (
<div className='mt-6'>
<h3 className='text-lg font-semibold mb-2'>Additional links:</h3>
<ul className='list-disc list-inside'>
{draft.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="resource thumbnail"
src={returnImageProxy(draft?.author?.avatar, draft?.author?.pubkey)}
src={returnImageProxy(draft?.user?.avatar, draft?.user?.pubkey)}
width={50}
height={50}
className="rounded-full mr-4"
@ -273,6 +273,7 @@ export default function Draft() {
</p>
)}
</div>
<p className="pt-8 text-sm text-gray-400">{draft?.createdAt && formatDateTime(draft?.createdAt)}</p>
</div>
<div className='flex flex-col max-tab:mt-12 max-mob:mt-12'>
{draft && (
@ -306,4 +307,4 @@ export default function Draft() {
</div>
</div>
);
}
}

View File

@ -36,6 +36,7 @@ export const parseEvent = (event) => {
pubkey: event.pubkey || '',
content: event.content || '',
kind: event.kind || '',
additionalLinks: [],
title: '',
summary: '',
image: '',
@ -83,6 +84,9 @@ export const parseEvent = (event) => {
case 't':
tag[1] !== "plebdevs" && eventData.topics.push(tag[1]);
break;
case 'r':
eventData.additionalLinks.push(tag[1]);
break;
default:
break;
}
@ -144,6 +148,9 @@ export const parseCourseEvent = (event) => {
eventData.topics.push(topic);
});
break;
case 'r':
eventData.additionalLinks.push(tag[1]);
break;
default:
break;
}
@ -155,3 +162,22 @@ export const parseCourseEvent = (event) => {
export const hexToNpub = (hex) => {
return nip19.npubEncode(hex);
}
export function validateEvent(event) {
if (typeof event.kind !== "number") return "Invalid kind";
if (typeof event.content !== "string") return "Invalid content";
if (typeof event.created_at !== "number") return "Invalid created_at";
if (typeof event.pubkey !== "string") return "Invalid pubkey";
if (!event.pubkey.match(/^[a-f0-9]{64}$/)) return "Invalid pubkey format";
if (!Array.isArray(event.tags)) return "Invalid tags";
for (let i = 0; i < event.tags.length; i++) {
const tag = event.tags[i];
if (!Array.isArray(tag)) return "Invalid tag structure";
for (let j = 0; j < tag.length; j++) {
if (typeof tag[j] === "object") return "Invalid tag value";
}
}
return true;
}