mirror of
https://github.com/AustinKelsay/plebdevs.git
synced 2025-05-18 04:05:51 +00:00
Starting on drafts flow, updating forms, updating db, some small ui fixes
This commit is contained in:
parent
187573088b
commit
aca8c6ee82
@ -57,6 +57,23 @@ CREATE TABLE "Resource" (
|
|||||||
CONSTRAINT "Resource_pkey" PRIMARY KEY ("id")
|
CONSTRAINT "Resource_pkey" PRIMARY KEY ("id")
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Draft" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"type" TEXT NOT NULL,
|
||||||
|
"title" TEXT NOT NULL,
|
||||||
|
"summary" TEXT NOT NULL,
|
||||||
|
"content" TEXT NOT NULL,
|
||||||
|
"image" TEXT,
|
||||||
|
"price" INTEGER DEFAULT 0,
|
||||||
|
"topics" TEXT[],
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Draft_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
-- CreateIndex
|
-- CreateIndex
|
||||||
CREATE UNIQUE INDEX "User_pubkey_key" ON "User"("pubkey");
|
CREATE UNIQUE INDEX "User_pubkey_key" ON "User"("pubkey");
|
||||||
|
|
||||||
@ -89,3 +106,6 @@ ALTER TABLE "Resource" ADD CONSTRAINT "Resource_userId_fkey" FOREIGN KEY ("userI
|
|||||||
|
|
||||||
-- AddForeignKey
|
-- AddForeignKey
|
||||||
ALTER TABLE "Resource" ADD CONSTRAINT "Resource_courseId_fkey" FOREIGN KEY ("courseId") REFERENCES "Course"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
ALTER TABLE "Resource" ADD CONSTRAINT "Resource_courseId_fkey" FOREIGN KEY ("courseId") REFERENCES "Course"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Draft" ADD CONSTRAINT "Draft_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
@ -15,6 +15,7 @@ model User {
|
|||||||
purchased Purchase[]
|
purchased Purchase[]
|
||||||
courses Course[] // Relation field added for courses created by the user
|
courses Course[] // Relation field added for courses created by the user
|
||||||
resources Resource[] // Relation field added for resources 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
|
||||||
role Role? @relation(fields: [roleId], references: [id])
|
role Role? @relation(fields: [roleId], references: [id])
|
||||||
roleId String?
|
roleId String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
@ -64,3 +65,19 @@ model Resource {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
@ -17,7 +17,7 @@ const CourseForm = () => {
|
|||||||
const [coverImage, setCoverImage] = useState('');
|
const [coverImage, setCoverImage] = useState('');
|
||||||
const [topics, setTopics] = useState(['']);
|
const [topics, setTopics] = useState(['']);
|
||||||
|
|
||||||
const showToast = useToast();
|
const {showToast} = useToast();
|
||||||
|
|
||||||
const handleSubmit = (e) => {
|
const handleSubmit = (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import React, { useState } from "react";
|
import React, { use, useState } from "react";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { InputText } from "primereact/inputtext";
|
import { InputText } from "primereact/inputtext";
|
||||||
import { InputNumber } from "primereact/inputnumber";
|
import { InputNumber } from "primereact/inputnumber";
|
||||||
@ -6,6 +6,7 @@ import { InputSwitch } from "primereact/inputswitch";
|
|||||||
import { Editor } from "primereact/editor";
|
import { Editor } from "primereact/editor";
|
||||||
import { Button } from "primereact/button";
|
import { Button } from "primereact/button";
|
||||||
import { nip04, verifyEvent, nip19 } from "nostr-tools";
|
import { nip04, verifyEvent, nip19 } from "nostr-tools";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
import { useNostr } from "@/hooks/useNostr";
|
import { useNostr } from "@/hooks/useNostr";
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import { useLocalStorageWithEffect } from "@/hooks/useLocalStorage";
|
import { useLocalStorageWithEffect } from "@/hooks/useLocalStorage";
|
||||||
@ -16,7 +17,7 @@ import 'primeicons/primeicons.css';
|
|||||||
const ResourceForm = () => {
|
const ResourceForm = () => {
|
||||||
const [title, setTitle] = useState('');
|
const [title, setTitle] = useState('');
|
||||||
const [summary, setSummary] = useState('');
|
const [summary, setSummary] = useState('');
|
||||||
const [checked, setChecked] = useState(false);
|
const [isPaidResource, setIsPaidResource] = useState(false);
|
||||||
const [price, setPrice] = useState(0);
|
const [price, setPrice] = useState(0);
|
||||||
const [text, setText] = useState('');
|
const [text, setText] = useState('');
|
||||||
const [coverImage, setCoverImage] = useState('');
|
const [coverImage, setCoverImage] = useState('');
|
||||||
@ -28,168 +29,190 @@ const ResourceForm = () => {
|
|||||||
|
|
||||||
const { publishAll } = useNostr();
|
const { publishAll } = useNostr();
|
||||||
|
|
||||||
const handleSubmit = (e) => {
|
const router = useRouter();
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
|
const userResponse = await axios.get(`/api/users/${user.pubkey}`);
|
||||||
|
|
||||||
|
if (!userResponse.data) {
|
||||||
|
showToast('error', 'Error', 'User not found', 'Please try again.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
title,
|
title,
|
||||||
summary,
|
summary,
|
||||||
isPaidResource: checked,
|
type: 'resource',
|
||||||
price: checked ? price : null,
|
price: isPaidResource ? price : null,
|
||||||
content: text,
|
content: text,
|
||||||
|
image: coverImage,
|
||||||
|
user: userResponse.data.id,
|
||||||
topics: topics.map(topic => topic.trim().toLowerCase())
|
topics: topics.map(topic => topic.trim().toLowerCase())
|
||||||
};
|
};
|
||||||
|
|
||||||
if (checked) {
|
if (payload && payload.user) {
|
||||||
broadcastPaidResource(payload);
|
axios.post('/api/drafts', payload)
|
||||||
} else {
|
.then(response => {
|
||||||
broadcastFreeResource(payload);
|
if (response.status === 201) {
|
||||||
|
showToast('success', 'Success', 'Resource saved as draft.');
|
||||||
|
|
||||||
|
if (response.data?.id) {
|
||||||
|
router.push(`/draft/${response.data.id}`);
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
const broadcastFreeResource = async (payload) => {
|
|
||||||
const newresourceId = uuidv4();
|
|
||||||
const event = {
|
|
||||||
kind: 30023,
|
|
||||||
content: payload.content,
|
|
||||||
created_at: Math.floor(Date.now() / 1000),
|
|
||||||
tags: [
|
|
||||||
['d', newresourceId],
|
|
||||||
['title', payload.title],
|
|
||||||
['summary', payload.summary],
|
|
||||||
['image', ''],
|
|
||||||
['t', ...topics],
|
|
||||||
['published_at', Math.floor(Date.now() / 1000).toString()],
|
|
||||||
]
|
|
||||||
};
|
|
||||||
|
|
||||||
const signedEvent = await window.nostr.signEvent(event);
|
|
||||||
|
|
||||||
const eventVerification = await verifyEvent(signedEvent);
|
|
||||||
|
|
||||||
if (!eventVerification) {
|
|
||||||
showToast('error', 'Error', 'Event verification failed. Please try again.');
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const nAddress = nip19.naddrEncode({
|
|
||||||
pubkey: signedEvent.pubkey,
|
|
||||||
kind: signedEvent.kind,
|
|
||||||
identifier: newresourceId,
|
|
||||||
})
|
})
|
||||||
|
.catch(error => {
|
||||||
console.log('nAddress:', nAddress);
|
console.error(error);
|
||||||
|
|
||||||
const userResponse = await axios.get(`/api/users/${user.pubkey}`)
|
|
||||||
|
|
||||||
if (!userResponse.data) {
|
|
||||||
showToast('error', 'Error', 'User not found', 'Please try again.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const resourcePayload = {
|
|
||||||
id: newresourceId,
|
|
||||||
userId: userResponse.data.id,
|
|
||||||
price: 0,
|
|
||||||
noteId: nAddress,
|
|
||||||
}
|
|
||||||
const response = await axios.post(`/api/resources`, resourcePayload);
|
|
||||||
|
|
||||||
console.log('response:', response);
|
|
||||||
|
|
||||||
if (response.status !== 201) {
|
|
||||||
showToast('error', 'Error', 'Failed to create resource. Please try again.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const publishResponse = await publishAll(signedEvent);
|
|
||||||
|
|
||||||
if (!publishResponse) {
|
|
||||||
showToast('error', 'Error', 'Failed to publish resource. Please try again.');
|
|
||||||
return;
|
|
||||||
} else if (publishResponse?.failedRelays) {
|
|
||||||
publishResponse?.failedRelays.map(relay => {
|
|
||||||
showToast('warn', 'Warning', `Failed to publish to relay: ${relay}`);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
publishResponse?.successfulRelays.map(relay => {
|
|
||||||
showToast('success', 'Success', `Published to relay: ${relay}`);
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// For images, whether included in the markdown content or not, clients SHOULD use image tags as described in NIP-58. This allows clients to display images in carousel format more easily.
|
|
||||||
const broadcastPaidResource = async (payload) => {
|
|
||||||
// encrypt the content with NEXT_PUBLIC_APP_PRIV_KEY to NEXT_PUBLIC_APP_PUBLIC_KEY
|
|
||||||
const encryptedContent = await nip04.encrypt(process.env.NEXT_PUBLIC_APP_PRIV_KEY ,process.env.NEXT_PUBLIC_APP_PUBLIC_KEY, payload.content);
|
|
||||||
const newresourceId = uuidv4();
|
|
||||||
const event = {
|
|
||||||
kind: 30402,
|
|
||||||
content: encryptedContent,
|
|
||||||
created_at: Math.floor(Date.now() / 1000),
|
|
||||||
tags: [
|
|
||||||
['title', payload.title],
|
|
||||||
['summary', payload.summary],
|
|
||||||
['t', ...topics],
|
|
||||||
['image', ''],
|
|
||||||
['d', newresourceId],
|
|
||||||
['location', `https://plebdevs.com/resource/${newresourceId}`],
|
|
||||||
['published_at', Math.floor(Date.now() / 1000).toString()],
|
|
||||||
['price', payload.price]
|
|
||||||
]
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const signedEvent = await window.nostr.signEvent(event);
|
// const saveFreeResource = async (payload) => {
|
||||||
|
// const newresourceId = uuidv4();
|
||||||
|
// const event = {
|
||||||
|
// kind: 30023,
|
||||||
|
// content: payload.content,
|
||||||
|
// created_at: Math.floor(Date.now() / 1000),
|
||||||
|
// tags: [
|
||||||
|
// ['d', newresourceId],
|
||||||
|
// ['title', payload.title],
|
||||||
|
// ['summary', payload.summary],
|
||||||
|
// ['image', ''],
|
||||||
|
// ['t', ...topics],
|
||||||
|
// ['published_at', Math.floor(Date.now() / 1000).toString()],
|
||||||
|
// ]
|
||||||
|
// };
|
||||||
|
|
||||||
const eventVerification = await verifyEvent(signedEvent);
|
// const signedEvent = await window.nostr.signEvent(event);
|
||||||
|
|
||||||
if (!eventVerification) {
|
// const eventVerification = await verifyEvent(signedEvent);
|
||||||
showToast('error', 'Error', 'Event verification failed. Please try again.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const nAddress = nip19.naddrEncode({
|
// if (!eventVerification) {
|
||||||
pubkey: signedEvent.pubkey,
|
// showToast('error', 'Error', 'Event verification failed. Please try again.');
|
||||||
kind: signedEvent.kind,
|
// return;
|
||||||
identifier: newresourceId,
|
// }
|
||||||
})
|
|
||||||
|
|
||||||
console.log('nAddress:', nAddress);
|
// const nAddress = nip19.naddrEncode({
|
||||||
|
// pubkey: signedEvent.pubkey,
|
||||||
|
// kind: signedEvent.kind,
|
||||||
|
// identifier: newresourceId,
|
||||||
|
// })
|
||||||
|
|
||||||
const userResponse = await axios.get(`/api/users/${user.pubkey}`)
|
// console.log('nAddress:', nAddress);
|
||||||
|
|
||||||
if (!userResponse.data) {
|
// const userResponse = await axios.get(`/api/users/${user.pubkey}`)
|
||||||
showToast('error', 'Error', 'User not found', 'Please try again.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const resourcePayload = {
|
// if (!userResponse.data) {
|
||||||
id: newresourceId,
|
// showToast('error', 'Error', 'User not found', 'Please try again.');
|
||||||
userId: userResponse.data.id,
|
// return;
|
||||||
price: payload.price || 0,
|
// }
|
||||||
noteId: nAddress,
|
|
||||||
}
|
|
||||||
const response = await axios.post(`/api/resources`, resourcePayload);
|
|
||||||
|
|
||||||
if (response.status !== 201) {
|
// const resourcePayload = {
|
||||||
showToast('error', 'Error', 'Failed to create resource. Please try again.');
|
// id: newresourceId,
|
||||||
return;
|
// userId: userResponse.data.id,
|
||||||
}
|
// price: 0,
|
||||||
|
// noteId: nAddress,
|
||||||
|
// }
|
||||||
|
// const response = await axios.post(`/api/resources`, resourcePayload);
|
||||||
|
|
||||||
const publishResponse = await publishAll(signedEvent);
|
// console.log('response:', response);
|
||||||
|
|
||||||
if (!publishResponse) {
|
// if (response.status !== 201) {
|
||||||
showToast('error', 'Error', 'Failed to publish resource. Please try again.');
|
// showToast('error', 'Error', 'Failed to create resource. Please try again.');
|
||||||
return;
|
// return;
|
||||||
} else if (publishResponse?.failedRelays) {
|
// }
|
||||||
publishResponse?.failedRelays.map(relay => {
|
|
||||||
showToast('warn', 'Warning', `Failed to publish to relay: ${relay}`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
publishResponse?.successfulRelays.map(relay => {
|
// const publishResponse = await publishAll(signedEvent);
|
||||||
showToast('success', 'Success', `Published to relay: ${relay}`);
|
|
||||||
})
|
// if (!publishResponse) {
|
||||||
}
|
// showToast('error', 'Error', 'Failed to publish resource. Please try again.');
|
||||||
|
// return;
|
||||||
|
// } else if (publishResponse?.failedRelays) {
|
||||||
|
// publishResponse?.failedRelays.map(relay => {
|
||||||
|
// showToast('warn', 'Warning', `Failed to publish to relay: ${relay}`);
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
|
||||||
|
// publishResponse?.successfulRelays.map(relay => {
|
||||||
|
// showToast('success', 'Success', `Published to relay: ${relay}`);
|
||||||
|
// })
|
||||||
|
// }
|
||||||
|
|
||||||
|
// // For images, whether included in the markdown content or not, clients SHOULD use image tags as described in NIP-58. This allows clients to display images in carousel format more easily.
|
||||||
|
// const savePaidResource = async (payload) => {
|
||||||
|
// // encrypt the content with NEXT_PUBLIC_APP_PRIV_KEY to NEXT_PUBLIC_APP_PUBLIC_KEY
|
||||||
|
// const encryptedContent = await nip04.encrypt(process.env.NEXT_PUBLIC_APP_PRIV_KEY ,process.env.NEXT_PUBLIC_APP_PUBLIC_KEY, payload.content);
|
||||||
|
// const newresourceId = uuidv4();
|
||||||
|
// const event = {
|
||||||
|
// kind: 30402,
|
||||||
|
// content: encryptedContent,
|
||||||
|
// created_at: Math.floor(Date.now() / 1000),
|
||||||
|
// tags: [
|
||||||
|
// ['title', payload.title],
|
||||||
|
// ['summary', payload.summary],
|
||||||
|
// ['t', ...topics],
|
||||||
|
// ['image', ''],
|
||||||
|
// ['d', newresourceId],
|
||||||
|
// ['location', `https://plebdevs.com/resource/${newresourceId}`],
|
||||||
|
// ['published_at', Math.floor(Date.now() / 1000).toString()],
|
||||||
|
// ['price', payload.price]
|
||||||
|
// ]
|
||||||
|
// };
|
||||||
|
|
||||||
|
// const signedEvent = await window.nostr.signEvent(event);
|
||||||
|
|
||||||
|
// const eventVerification = await verifyEvent(signedEvent);
|
||||||
|
|
||||||
|
// if (!eventVerification) {
|
||||||
|
// showToast('error', 'Error', 'Event verification failed. Please try again.');
|
||||||
|
// return;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// const nAddress = nip19.naddrEncode({
|
||||||
|
// pubkey: signedEvent.pubkey,
|
||||||
|
// kind: signedEvent.kind,
|
||||||
|
// identifier: newresourceId,
|
||||||
|
// })
|
||||||
|
|
||||||
|
// console.log('nAddress:', nAddress);
|
||||||
|
|
||||||
|
// const userResponse = await axios.get(`/api/users/${user.pubkey}`)
|
||||||
|
|
||||||
|
// if (!userResponse.data) {
|
||||||
|
// showToast('error', 'Error', 'User not found', 'Please try again.');
|
||||||
|
// return;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// const resourcePayload = {
|
||||||
|
// id: newresourceId,
|
||||||
|
// userId: userResponse.data.id,
|
||||||
|
// price: payload.price || 0,
|
||||||
|
// noteId: nAddress,
|
||||||
|
// }
|
||||||
|
// const response = await axios.post(`/api/resources`, resourcePayload);
|
||||||
|
|
||||||
|
// if (response.status !== 201) {
|
||||||
|
// showToast('error', 'Error', 'Failed to create resource. Please try again.');
|
||||||
|
// return;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// const publishResponse = await publishAll(signedEvent);
|
||||||
|
|
||||||
|
// if (!publishResponse) {
|
||||||
|
// showToast('error', 'Error', 'Failed to publish resource. Please try again.');
|
||||||
|
// return;
|
||||||
|
// } else if (publishResponse?.failedRelays) {
|
||||||
|
// publishResponse?.failedRelays.map(relay => {
|
||||||
|
// showToast('warn', 'Warning', `Failed to publish to relay: ${relay}`);
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
|
||||||
|
// publishResponse?.successfulRelays.map(relay => {
|
||||||
|
// showToast('success', 'Success', `Published to relay: ${relay}`);
|
||||||
|
// })
|
||||||
|
// }
|
||||||
|
|
||||||
const handleTopicChange = (index, value) => {
|
const handleTopicChange = (index, value) => {
|
||||||
const updatedTopics = topics.map((topic, i) => i === index ? value : topic);
|
const updatedTopics = topics.map((topic, i) => i === index ? value : topic);
|
||||||
@ -252,8 +275,8 @@ const ResourceForm = () => {
|
|||||||
|
|
||||||
<div className="p-inputgroup flex-1 mt-8 flex-col">
|
<div className="p-inputgroup flex-1 mt-8 flex-col">
|
||||||
<p className="py-2">Paid Resource</p>
|
<p className="py-2">Paid Resource</p>
|
||||||
<InputSwitch checked={checked} onChange={(e) => setChecked(e.value)} />
|
<InputSwitch checked={isPaidResource} onChange={(e) => setIsPaidResource(e.value)} />
|
||||||
{checked && (
|
{isPaidResource && (
|
||||||
<div className="p-inputgroup flex-1 py-4">
|
<div className="p-inputgroup flex-1 py-4">
|
||||||
<InputNumber value={price} onValueChange={(e) => setPrice(e.value)} placeholder="Price (sats)" />
|
<InputNumber value={price} onValueChange={(e) => setPrice(e.value)} placeholder="Price (sats)" />
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,9 +1,7 @@
|
|||||||
import prisma from "../prisma";
|
import prisma from "../prisma";
|
||||||
|
|
||||||
const client = new prisma.PrismaClient();
|
|
||||||
|
|
||||||
export const getAllCourses = async () => {
|
export const getAllCourses = async () => {
|
||||||
return await client.course.findMany({
|
return await prisma.course.findMany({
|
||||||
include: {
|
include: {
|
||||||
resources: true, // Include related resources
|
resources: true, // Include related resources
|
||||||
purchases: true, // Include related purchases
|
purchases: true, // Include related purchases
|
||||||
@ -12,7 +10,7 @@ export const getAllCourses = async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const getCourseById = async (id) => {
|
export const getCourseById = async (id) => {
|
||||||
return await client.course.findUnique({
|
return await prisma.course.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
include: {
|
include: {
|
||||||
resources: true, // Include related resources
|
resources: true, // Include related resources
|
||||||
@ -22,20 +20,20 @@ export const getCourseById = async (id) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const createCourse = async (data) => {
|
export const createCourse = async (data) => {
|
||||||
return await client.course.create({
|
return await prisma.course.create({
|
||||||
data,
|
data,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const updateCourse = async (id, data) => {
|
export const updateCourse = async (id, data) => {
|
||||||
return await client.course.update({
|
return await prisma.course.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data,
|
data,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const deleteCourse = async (id) => {
|
export const deleteCourse = async (id) => {
|
||||||
return await client.course.delete({
|
return await prisma.course.delete({
|
||||||
where: { id },
|
where: { id },
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
46
src/db/models/draftModels.js
Normal file
46
src/db/models/draftModels.js
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import prisma from "../prisma";
|
||||||
|
|
||||||
|
export const getAllDraftsByUserId = async (userId) => {
|
||||||
|
return await prisma.draft.findMany({
|
||||||
|
where: { userId },
|
||||||
|
include: {
|
||||||
|
user: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getDraftById = async (id) => {
|
||||||
|
return await prisma.draft.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
user: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createDraft = async (data) => {
|
||||||
|
return await prisma.draft.create({
|
||||||
|
data: {
|
||||||
|
...data,
|
||||||
|
user: {
|
||||||
|
connect: {
|
||||||
|
id: data.user,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export const updateDraft = async (id, data) => {
|
||||||
|
return await prisma.draft.update({
|
||||||
|
where: { id },
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteDraft = async (id) => {
|
||||||
|
return await prisma.draft.delete({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
}
|
@ -1,5 +1,28 @@
|
|||||||
|
// db/prisma.js
|
||||||
|
|
||||||
|
// Import the PrismaClient class from the @prisma/client package.
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
// Declare a variable to hold our Prisma client instance.
|
||||||
|
let prisma;
|
||||||
|
|
||||||
|
// Check if the application is running in a production environment.
|
||||||
|
if (process.env.NODE_ENV === 'production') {
|
||||||
|
// In production, always create a new instance of PrismaClient.
|
||||||
|
prisma = new PrismaClient();
|
||||||
|
} else {
|
||||||
|
// In development or other non-production environments...
|
||||||
|
|
||||||
|
// Check if there is already an instance of PrismaClient attached to the global object.
|
||||||
|
if (!global.prisma) {
|
||||||
|
// If not, create a new instance of PrismaClient...
|
||||||
|
global.prisma = new PrismaClient();
|
||||||
|
}
|
||||||
|
// ...and assign it to the prisma variable. This ensures that in development,
|
||||||
|
// the same instance of PrismaClient is reused across hot reloads and server restarts,
|
||||||
|
// which can help prevent the exhaustion of database connections during development.
|
||||||
|
prisma = global.prisma;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export the prisma client instance, making it available for import in other parts of the application.
|
||||||
export default prisma;
|
export default prisma;
|
@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect, useRef } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
import { SimplePool, nip19, verifyEvent } from "nostr-tools";
|
import { SimplePool, nip19, verifyEvent } from "nostr-tools";
|
||||||
import { Relay } from 'nostr-tools/relay'
|
import { useToast } from "./useToast";
|
||||||
|
|
||||||
const initialRelays = [
|
const initialRelays = [
|
||||||
"wss://nos.lol/",
|
"wss://nos.lol/",
|
||||||
@ -22,6 +22,8 @@ export const useNostr = () => {
|
|||||||
streams: []
|
streams: []
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const {showToast} = useToast();
|
||||||
|
|
||||||
const pool = useRef(new SimplePool({ seenOnEnabled: true }));
|
const pool = useRef(new SimplePool({ seenOnEnabled: true }));
|
||||||
const subscriptions = useRef([]);
|
const subscriptions = useRef([]);
|
||||||
|
|
||||||
@ -200,8 +202,10 @@ export const useNostr = () => {
|
|||||||
results.forEach((result, i) => {
|
results.forEach((result, i) => {
|
||||||
if (result.status === 'fulfilled') {
|
if (result.status === 'fulfilled') {
|
||||||
successfulRelays.push(relays[i])
|
successfulRelays.push(relays[i])
|
||||||
|
showToast('success', `published to ${relays[i]}`)
|
||||||
} else {
|
} else {
|
||||||
failedRelays.push(relays[i])
|
failedRelays.push(relays[i])
|
||||||
|
showToast('error', `failed to publish to ${relays[i]}`)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
39
src/hooks/useResponsiveImageDimensions.js
Normal file
39
src/hooks/useResponsiveImageDimensions.js
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
const useResponsiveImageDimensions = () => {
|
||||||
|
// Initialize screenWidth with a default value for SSR
|
||||||
|
// This can be a typical screen width or the smallest size you want to target
|
||||||
|
const [screenWidth, setScreenWidth] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Set the initial width on the client side
|
||||||
|
setScreenWidth(window.innerWidth);
|
||||||
|
|
||||||
|
const handleResize = () => {
|
||||||
|
setScreenWidth(window.innerWidth);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('resize', handleResize);
|
||||||
|
|
||||||
|
// Cleanup the event listener on component unmount
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('resize', handleResize);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const calculateImageDimensions = () => {
|
||||||
|
if (screenWidth >= 1200) {
|
||||||
|
return { width: 426, height: 240 }; // Large screens
|
||||||
|
} else if (screenWidth >= 768 && screenWidth < 1200) {
|
||||||
|
return { width: 344, height: 194 }; // Medium screens
|
||||||
|
} else if (screenWidth > 0) { // Check if screenWidth is set to avoid incorrect rendering during SSR
|
||||||
|
return { width: screenWidth - 120, height: (screenWidth - 120) * (9 / 16) }; // Small screens
|
||||||
|
} else {
|
||||||
|
return { width: 0, height: 0 }; // Default sizes or SSR fallback
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return calculateImageDimensions();
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useResponsiveImageDimensions;
|
@ -6,7 +6,6 @@ class MyDocument extends Document {
|
|||||||
<Html>
|
<Html>
|
||||||
<Head>
|
<Head>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin />
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Blinker:wght@100;200;300;400;600;700;800;900&family=Poppins&display=swap" rel="stylesheet" />
|
<link href="https://fonts.googleapis.com/css2?family=Blinker:wght@100;200;300;400;600;700;800;900&family=Poppins&display=swap" rel="stylesheet" />
|
||||||
</Head>
|
</Head>
|
||||||
<body>
|
<body>
|
||||||
|
40
src/pages/api/drafts/[slug].js
Normal file
40
src/pages/api/drafts/[slug].js
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import { getDraftById, updateDraft, deleteDraft } from "@/db/models/draftModels";
|
||||||
|
|
||||||
|
export default async function handler(req, res) {
|
||||||
|
const { slug } = req.query;
|
||||||
|
|
||||||
|
if (req.method === 'GET') {
|
||||||
|
try {
|
||||||
|
const draft = await getDraftById(slug);
|
||||||
|
if (draft) {
|
||||||
|
res.status(200).json(draft);
|
||||||
|
} else {
|
||||||
|
res.status(404).json({ error: 'Draft not found' });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
} else if (req.method === 'PUT') {
|
||||||
|
try {
|
||||||
|
const draft = await updateDraft(slug, req.body);
|
||||||
|
if (draft) {
|
||||||
|
res.status(200).json(draft);
|
||||||
|
} else {
|
||||||
|
res.status(400).json({ error: 'Draft not updated' });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
res.status(400).json({ error: error.message });
|
||||||
|
}
|
||||||
|
} else if (req.method === 'DELETE') {
|
||||||
|
try {
|
||||||
|
await deleteDraft(slug);
|
||||||
|
res.status(204).end();
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Handle any other HTTP method
|
||||||
|
res.setHeader('Allow', ['GET', 'PUT', 'DELETE']);
|
||||||
|
res.status(405).end(`Method ${req.method} Not Allowed`);
|
||||||
|
}
|
||||||
|
}
|
22
src/pages/api/drafts/all/[slug].js
Normal file
22
src/pages/api/drafts/all/[slug].js
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import { getAllDraftsByUserId } from "@/db/models/draftModels";
|
||||||
|
|
||||||
|
export default async function handler(req, res) {
|
||||||
|
const { id } = req.query;
|
||||||
|
|
||||||
|
if (req.method === 'GET') {
|
||||||
|
try {
|
||||||
|
const resource = await getAllDraftsByUserId(parseInt(id));
|
||||||
|
if (resource) {
|
||||||
|
res.status(200).json(resource);
|
||||||
|
} else {
|
||||||
|
res.status(404).json({ error: 'Resource not found' });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
res.status(400).json({ error: error.message });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Handle any other HTTP method
|
||||||
|
res.setHeader('Allow', ['GET', 'POST']);
|
||||||
|
res.status(405).end(`Method ${req.method} Not Allowed`);
|
||||||
|
}
|
||||||
|
}
|
20
src/pages/api/drafts/index.js
Normal file
20
src/pages/api/drafts/index.js
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { createDraft } from "@/db/models/draftModels";
|
||||||
|
|
||||||
|
export default async function handler(req, res) {
|
||||||
|
if (req.method === 'POST') {
|
||||||
|
try {
|
||||||
|
const draft = await createDraft(req.body);
|
||||||
|
if (draft) {
|
||||||
|
res.status(201).json(draft);
|
||||||
|
} else {
|
||||||
|
res.status(400).json({ error: 'Draft not created' });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
res.status(400).json({ error: error.message });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Handle any other HTTP method
|
||||||
|
res.setHeader('Allow', ['GET', 'POST']);
|
||||||
|
res.status(405).end(`Method ${req.method} Not Allowed`);
|
||||||
|
}
|
||||||
|
}
|
239
src/pages/draft/[slug].js
Normal file
239
src/pages/draft/[slug].js
Normal file
@ -0,0 +1,239 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import axios from 'axios';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
|
import { useNostr } from '@/hooks/useNostr';
|
||||||
|
import { parseEvent, findKind0Fields, hexToNpub } from '@/utils/nostr';
|
||||||
|
import { verifyEvent, nip19 } from 'nostr-tools';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
import { useLocalStorageWithEffect } from '@/hooks/useLocalStorage';
|
||||||
|
import { useImageProxy } from '@/hooks/useImageProxy';
|
||||||
|
import { Button } from 'primereact/button';
|
||||||
|
import { useToast } from '@/hooks/useToast';
|
||||||
|
import { Tag } from 'primereact/tag';
|
||||||
|
import Image from 'next/image';
|
||||||
|
import useResponsiveImageDimensions from '@/hooks/useResponsiveImageDimensions';
|
||||||
|
import 'primeicons/primeicons.css';
|
||||||
|
|
||||||
|
import ReactMarkdown from 'react-markdown';
|
||||||
|
import rehypeRaw from 'rehype-raw';
|
||||||
|
|
||||||
|
const MarkdownContent = ({ content }) => {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<ReactMarkdown rehypePlugins={[rehypeRaw]} className='markdown-content'>
|
||||||
|
{content}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Details() {
|
||||||
|
const [draft, setDraft] = useState(null);
|
||||||
|
|
||||||
|
const { returnImageProxy } = useImageProxy();
|
||||||
|
const { fetchSingleEvent, fetchKind0 } = useNostr();
|
||||||
|
|
||||||
|
const [user] = useLocalStorageWithEffect('user', {});
|
||||||
|
|
||||||
|
const { width, height } = useResponsiveImageDimensions();
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const {showToast} = useToast();
|
||||||
|
|
||||||
|
const { publishAll } = useNostr();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (router.isReady) {
|
||||||
|
const { slug } = router.query;
|
||||||
|
|
||||||
|
axios.get(`/api/drafts/${slug}`)
|
||||||
|
.then(res => {
|
||||||
|
console.log('res:', res.data);
|
||||||
|
setDraft(res.data);
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.error(err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [router.isReady, router.query]);
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (draft) {
|
||||||
|
const {unsignedEvent, type} = buildEvent(draft);
|
||||||
|
|
||||||
|
if (unsignedEvent) {
|
||||||
|
await publishEvent(unsignedEvent, type);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
showToast('error', 'Error', 'Failed to broadcast resource. Please try again.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const publishEvent = async (event, type) => {
|
||||||
|
const dTag = event.tags.find(tag => tag[0] === 'd')[1];
|
||||||
|
|
||||||
|
const signedEvent = await window.nostr.signEvent(event);
|
||||||
|
|
||||||
|
const eventVerification = await verifyEvent(signedEvent);
|
||||||
|
|
||||||
|
if (!eventVerification) {
|
||||||
|
showToast('error', 'Error', 'Event verification failed. Please try again.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nAddress = nip19.naddrEncode({
|
||||||
|
pubkey: signedEvent.pubkey,
|
||||||
|
kind: signedEvent.kind,
|
||||||
|
identifier: dTag,
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log('nAddress:', nAddress);
|
||||||
|
|
||||||
|
const userResponse = await axios.get(`/api/users/${user.pubkey}`)
|
||||||
|
|
||||||
|
if (!userResponse.data) {
|
||||||
|
showToast('error', 'Error', 'User not found', 'Please try again.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
id: dTag,
|
||||||
|
userId: userResponse.data.id,
|
||||||
|
price: draft.price || 0,
|
||||||
|
noteId: nAddress,
|
||||||
|
}
|
||||||
|
const response = await axios.post(`/api/resources`, payload);
|
||||||
|
|
||||||
|
if (response.status !== 201) {
|
||||||
|
showToast('error', 'Error', 'Failed to create resource. Please try again.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await publishAll(signedEvent);
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildEvent = (draft) => {
|
||||||
|
const NewDTag = uuidv4();
|
||||||
|
let event = {};
|
||||||
|
let type;
|
||||||
|
|
||||||
|
switch (draft?.type) {
|
||||||
|
case 'resource':
|
||||||
|
event = {
|
||||||
|
kind: draft?.price ? 30402 : 30023, // Determine kind based on if price is present
|
||||||
|
content: draft.content,
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
tags: [
|
||||||
|
['d', NewDTag],
|
||||||
|
['title', draft.title],
|
||||||
|
['summary', draft.summary],
|
||||||
|
['image', draft.image],
|
||||||
|
['t', ...draft.topics],
|
||||||
|
['published_at', Math.floor(Date.now() / 1000).toString()],
|
||||||
|
// Include price and location tags only if price is present
|
||||||
|
...(draft?.price ? [['price', draft.price], ['location', `https://plebdevs.com/resource/${draft.id}`]] : []),
|
||||||
|
]
|
||||||
|
};
|
||||||
|
type = 'resource';
|
||||||
|
break;
|
||||||
|
case 'workshop':
|
||||||
|
event = {
|
||||||
|
kind: 30023,
|
||||||
|
content: draft.content,
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
tags: [
|
||||||
|
['d', NewDTag],
|
||||||
|
['title', draft.title],
|
||||||
|
['summary', draft.summary],
|
||||||
|
['image', draft.image],
|
||||||
|
['t', ...draft.topics],
|
||||||
|
['published_at', Math.floor(Date.now() / 1000).toString()],
|
||||||
|
]
|
||||||
|
};
|
||||||
|
type = 'workshop';
|
||||||
|
break;
|
||||||
|
case 'course':
|
||||||
|
event = {
|
||||||
|
kind: 30023,
|
||||||
|
content: draft.content,
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
tags: [
|
||||||
|
['d', NewDTag],
|
||||||
|
['title', draft.title],
|
||||||
|
['summary', draft.summary],
|
||||||
|
['image', draft.image],
|
||||||
|
['t', ...draft.topics],
|
||||||
|
['published_at', Math.floor(Date.now() / 1000).toString()],
|
||||||
|
]
|
||||||
|
};
|
||||||
|
type = 'course';
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
event = null;
|
||||||
|
type = 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
return { unsignedEvent: event, type };
|
||||||
|
};
|
||||||
|
|
||||||
|
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'>
|
||||||
|
{/* <i className='pi pi-arrow-left pl-8 cursor-pointer hover:opacity-75 max-tab:pl-2 max-mob:pl-2' onClick={() => router.push('/')} /> */}
|
||||||
|
<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'>
|
||||||
|
<Tag className='mr-2' value="Primary"></Tag>
|
||||||
|
<Tag className='mr-2' severity="success" value="Success"></Tag>
|
||||||
|
<Tag className='mr-2' severity="info" value="Info"></Tag>
|
||||||
|
<Tag className='mr-2' severity="warning" value="Warning"></Tag>
|
||||||
|
<Tag className='mr-2' severity="danger" value="Danger"></Tag>
|
||||||
|
</div>
|
||||||
|
<h1 className='text-4xl mt-6'>{draft?.title}</h1>
|
||||||
|
<p className='text-xl mt-6'>{draft?.summary}</p>
|
||||||
|
<div className='flex flex-row w-full mt-6 items-center'>
|
||||||
|
<Image
|
||||||
|
alt="resource thumbnail"
|
||||||
|
src={user?.avatar ? returnImageProxy(user.avatar) : `https://secure.gravatar.com/avatar/${user.pubkey}?s=90&d=identicon`}
|
||||||
|
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'>
|
||||||
|
{user?.username}
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className='flex flex-col max-tab:mt-12 max-mob:mt-12'>
|
||||||
|
{draft && (
|
||||||
|
<div style={{ width: width < 768 ? "auto" : width }} onClick={() => router.push(`/details/${draft.id}`)} className="flex flex-col items-center mx-auto cursor-pointer rounded-md shadow-lg">
|
||||||
|
<div style={{ maxWidth: width, minWidth: width }} className="max-tab:h-auto max-mob:h-auto">
|
||||||
|
<Image
|
||||||
|
alt="resource thumbnail"
|
||||||
|
src={returnImageProxy(draft.image)}
|
||||||
|
quality={100}
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
className="w-full h-full object-cover object-center rounded-md"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className='w-[75vw] mx-auto flex flex-row justify-end mt-12'>
|
||||||
|
<Button onClick={handleSubmit} label="Publish" severity='success' outlined className="w-auto my-2" />
|
||||||
|
</div>
|
||||||
|
<div className='w-[75vw] mx-auto mt-24 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]'>
|
||||||
|
{
|
||||||
|
draft?.content && <MarkdownContent content={draft.content} />
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user