mirror of
https://github.com/AustinKelsay/plebdevs.git
synced 2025-04-19 19:01:19 +00:00
Cleanup
This commit is contained in:
parent
fa257743ee
commit
1a1f6a6fe4
@ -2,7 +2,7 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useNostr } from '@/hooks/useNostr';
|
||||
import { parseEvent, findKind0Fields, hexToNpub } from '@/utils/nostr';
|
||||
import { findKind0Fields } from '@/utils/nostr';
|
||||
import { useImageProxy } from '@/hooks/useImageProxy';
|
||||
import { Button } from 'primereact/button';
|
||||
import { Tag } from 'primereact/tag';
|
||||
@ -31,9 +31,8 @@ export default function CourseDetails({processedEvent}) {
|
||||
const [bitcoinConnect, setBitcoinConnect] = useState(false);
|
||||
const [nAddress, setNAddress] = useState(null);
|
||||
const [user] = useLocalStorageWithEffect('user', {});
|
||||
console.log('user:', user);
|
||||
const { returnImageProxy } = useImageProxy();
|
||||
const { fetchSingleEvent, fetchKind0, zapEvent } = useNostr();
|
||||
const { fetchKind0, zapEvent } = useNostr();
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
@ -76,7 +75,6 @@ export default function CourseDetails({processedEvent}) {
|
||||
kind: processedEvent.kind,
|
||||
identifier: processedEvent.d,
|
||||
});
|
||||
console.log('naddr:', naddr);
|
||||
setNAddress(naddr);
|
||||
}
|
||||
}, [processedEvent]);
|
||||
|
@ -69,7 +69,7 @@ export default function CoursesCarousel() {
|
||||
<h2 className="ml-[6%] mt-4">Courses</h2>
|
||||
<div className={"min-h-[384px]"}>
|
||||
<Carousel
|
||||
value={!processedCourses.length > 0 ? [{}, {}, {}] : [...processedCourses, ...processedCourses]}
|
||||
value={!processedCourses.length > 0 ? [{}, {}, {}] : [...processedCourses]}
|
||||
numVisible={2}
|
||||
itemTemplate={!processedCourses.length > 0 ? TemplateSkeleton : CourseTemplate}
|
||||
responsiveOptions={responsiveOptions} />
|
||||
|
@ -64,7 +64,7 @@ export default function ResourcesCarousel() {
|
||||
return (
|
||||
<>
|
||||
<h2 className="ml-[6%] mt-4">Resources</h2>
|
||||
<Carousel value={!processedResources.length > 0 ? [{}, {}, {}] : [...processedResources, ...processedResources]}
|
||||
<Carousel value={!processedResources.length > 0 ? [{}, {}, {}] : [...processedResources]}
|
||||
numVisible={2}
|
||||
itemTemplate={!processedResources.length > 0 ? TemplateSkeleton : ResourceTemplate}
|
||||
responsiveOptions={responsiveOptions} />
|
||||
|
@ -66,7 +66,7 @@ export default function WorkshopsCarousel() {
|
||||
return (
|
||||
<>
|
||||
<h2 className="ml-[6%] mt-4">Workshops</h2>
|
||||
<Carousel value={!processedWorkshops.length > 0 ? [{}, {}, {}] : [...processedWorkshops, ...processedWorkshops]}
|
||||
<Carousel value={!processedWorkshops.length > 0 ? [{}, {}, {}] : [...processedWorkshops]}
|
||||
numVisible={2}
|
||||
itemTemplate={!processedWorkshops.length > 0 ? TemplateSkeleton : WorkshopTemplate}
|
||||
responsiveOptions={responsiveOptions} />
|
||||
|
@ -2,17 +2,19 @@ import React, { useRef } from 'react';
|
||||
import { OverlayPanel } from 'primereact/overlaypanel';
|
||||
import ZapForm from './ZapForm';
|
||||
import { ProgressSpinner } from 'primereact/progressspinner';
|
||||
|
||||
|
||||
const ZapDisplay = ({ zapAmount, event }) => {
|
||||
const op = useRef(null);
|
||||
return (
|
||||
<>
|
||||
<p className="text-xs cursor-pointer" onClick={(e) => op.current.toggle(e)}>
|
||||
<i className="pi pi-bolt text-yellow-300"></i>
|
||||
|
||||
{zapAmount || zapAmount === 0 ? zapAmount : <ProgressSpinner />}
|
||||
</p>
|
||||
<span className="text-xs cursor-pointer" onClick={(e) => op.current.toggle(e)}>
|
||||
<i className="pi pi-bolt text-yellow-300"></i>
|
||||
{zapAmount || zapAmount === 0 ? (
|
||||
zapAmount
|
||||
) : (
|
||||
<ProgressSpinner style={{ display: 'inline-block' }} />
|
||||
)}
|
||||
</span>
|
||||
<OverlayPanel className='w-[40%] h-[40%]' ref={op}>
|
||||
<ZapForm event={event} />
|
||||
</OverlayPanel>
|
||||
@ -20,4 +22,4 @@ const ZapDisplay = ({ zapAmount, event }) => {
|
||||
)
|
||||
}
|
||||
|
||||
export default ZapDisplay;
|
||||
export default ZapDisplay;
|
||||
|
@ -24,6 +24,9 @@ export function useNostr() {
|
||||
const lastSubscriptionTime = useRef(0);
|
||||
const throttleDelay = 2000;
|
||||
|
||||
// ref to keep track of active subscriptions
|
||||
const activeSubscriptions = useRef([]);
|
||||
|
||||
const processSubscriptionQueue = useCallback(() => {
|
||||
if (subscriptionQueue.current.length === 0) return;
|
||||
|
||||
@ -45,7 +48,23 @@ export function useNostr() {
|
||||
if (!pool) return;
|
||||
|
||||
const subscriptionFn = () => {
|
||||
return pool.subscribeMany(defaultRelays, filters, opts);
|
||||
// Create the subscription
|
||||
const sub = pool.subscribeMany(defaultRelays, filters, {
|
||||
...opts,
|
||||
oneose: () => {
|
||||
// Call the original oneose if it exists
|
||||
opts.oneose?.();
|
||||
// Close the subscription after EOSE
|
||||
sub.close();
|
||||
// Remove this subscription from activeSubscriptions
|
||||
activeSubscriptions.current = activeSubscriptions.current.filter(s => s !== sub);
|
||||
}
|
||||
});
|
||||
|
||||
// Add this subscription to activeSubscriptions
|
||||
activeSubscriptions.current.push(sub);
|
||||
|
||||
return sub;
|
||||
};
|
||||
|
||||
subscriptionQueue.current.push(subscriptionFn);
|
||||
@ -54,6 +73,19 @@ export function useNostr() {
|
||||
[pool, processSubscriptionQueue]
|
||||
);
|
||||
|
||||
// Add this new function to close all active subscriptions
|
||||
const closeAllSubscriptions = useCallback(() => {
|
||||
activeSubscriptions.current.forEach(sub => sub.close());
|
||||
activeSubscriptions.current = [];
|
||||
}, []);
|
||||
|
||||
// Use an effect to close all subscriptions when the component unmounts
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
closeAllSubscriptions();
|
||||
};
|
||||
}, [closeAllSubscriptions]);
|
||||
|
||||
const publish = useCallback(
|
||||
async (event) => {
|
||||
if (!pool) return;
|
||||
|
@ -1,386 +0,0 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { SimplePool, nip19, verifyEvent, nip57 } from "nostr-tools";
|
||||
import axios from "axios";
|
||||
import { useToast } from "./useToast";
|
||||
|
||||
const initialRelays = [
|
||||
"wss://nos.lol/",
|
||||
"wss://relay.damus.io/",
|
||||
"wss://relay.snort.social/",
|
||||
"wss://relay.nostr.band/",
|
||||
"wss://nostr.mutinywallet.com/",
|
||||
"wss://relay.mutinywallet.com/",
|
||||
"wss://relay.primal.net/"
|
||||
];
|
||||
|
||||
const AUTHOR_PUBKEY = process.env.NEXT_PUBLIC_AUTHOR_PUBKEY;
|
||||
|
||||
export const useNostr = () => {
|
||||
const [relays, setRelays] = useState(initialRelays);
|
||||
const [relayStatuses, setRelayStatuses] = useState({});
|
||||
const [events, setEvents] = useState({
|
||||
resources: [],
|
||||
workshops: [],
|
||||
courses: [],
|
||||
streams: [],
|
||||
zaps: []
|
||||
});
|
||||
|
||||
const { showToast } = useToast();
|
||||
|
||||
const pool = useRef(new SimplePool({ seenOnEnabled: true }));
|
||||
const subscriptions = useRef([]);
|
||||
|
||||
const getRelayStatuses = () => {
|
||||
if (pool.current && pool.current._conn) {
|
||||
const statuses = {};
|
||||
|
||||
for (const url in pool.current._conn) {
|
||||
const relay = pool.current._conn[url];
|
||||
statuses[url] = relay.status; // Assuming 'status' is an accessible field in Relay object
|
||||
}
|
||||
|
||||
setRelayStatuses(statuses);
|
||||
}
|
||||
};
|
||||
|
||||
const updateRelays = async (newRelays) => {
|
||||
// Set new relays
|
||||
setRelays(newRelays);
|
||||
|
||||
// Ensure the relays are connected before using them
|
||||
await Promise.all(newRelays.map(relay => pool.current.ensureRelay(relay)));
|
||||
};
|
||||
|
||||
const fetchEvents = async (filter, updateDataField, hasRequiredTags) => {
|
||||
try {
|
||||
const sub = pool.current.subscribeMany(relays, filter, {
|
||||
onevent: async (event) => {
|
||||
const shouldInclude = await hasRequiredTags(event.tags);
|
||||
if (shouldInclude) {
|
||||
setEvents(prevData => ({
|
||||
...prevData,
|
||||
[updateDataField]: [...prevData[updateDataField], event]
|
||||
}));
|
||||
}
|
||||
},
|
||||
onerror: (error) => {
|
||||
console.error(`Error fetching ${updateDataField}:`, error);
|
||||
},
|
||||
onclose: () => {
|
||||
// Handle connection closure and retry if needed
|
||||
console.log("Connection closed");
|
||||
// Implement retry logic here
|
||||
},
|
||||
oneose: () => {
|
||||
console.log("Subscription closed");
|
||||
sub.close();
|
||||
}
|
||||
});
|
||||
|
||||
// Store the subscription in the ref for cleanup
|
||||
subscriptions.current.push(sub);
|
||||
} catch (error) {
|
||||
console.error(`Error in fetchEvents for ${updateDataField}:`, error);
|
||||
}
|
||||
};
|
||||
|
||||
// zaps
|
||||
// 1. get the author from the content
|
||||
// 2. get the author's kind0
|
||||
// 3. get the author's lud16 if available
|
||||
// 4. Make a get request to the lud16 endpoint and ensure that allowNostr is true
|
||||
// 5. Create zap request event and sign it
|
||||
// 6. Send to the callback url as a get req with the nostr event as a query param
|
||||
// 7. get the invoice back and pay it with webln
|
||||
// 8. listen for the zap receipt event and update the UI
|
||||
|
||||
const zapEvent = async (event) => {
|
||||
const kind0 = await fetchKind0([{ authors: [event.pubkey], kinds: [0] }], {});
|
||||
|
||||
if (Object.keys(kind0).length === 0) {
|
||||
console.error('Error fetching kind0');
|
||||
return;
|
||||
}
|
||||
|
||||
if (kind0?.lud16) {
|
||||
const lud16Username = kind0.lud16.split('@')[0];
|
||||
const lud16Domain = kind0.lud16.split('@')[1];
|
||||
|
||||
const lud16Url = `https://${lud16Domain}/.well-known/lnurlp/${lud16Username}`;
|
||||
|
||||
const response = await axios.get(lud16Url);
|
||||
|
||||
if (response.data.allowsNostr) {
|
||||
const zapReq = nip57.makeZapRequest({
|
||||
profile: event.pubkey,
|
||||
event: event.id,
|
||||
amount: 1000,
|
||||
relays: relays,
|
||||
comment: 'Plebdevs Zap'
|
||||
});
|
||||
|
||||
console.log('zapReq:', zapReq);
|
||||
|
||||
const signedEvent = await window?.nostr.signEvent(zapReq);
|
||||
|
||||
const callbackUrl = response.data.callback;
|
||||
|
||||
const zapRequestAPICall = `${callbackUrl}?amount=${1000}&nostr=${encodeURI(JSON.stringify(signedEvent))}`;
|
||||
|
||||
const invoiceResponse = await axios.get(zapRequestAPICall);
|
||||
|
||||
if (invoiceResponse?.data?.pr) {
|
||||
const invoice = invoiceResponse.data.pr;
|
||||
|
||||
const enabled = await window?.webln?.enable();
|
||||
|
||||
console.log('webln enabled:', enabled);
|
||||
|
||||
const payInvoiceResponse = await window?.webln?.sendPayment(invoice);
|
||||
|
||||
console.log('payInvoiceResponse:', payInvoiceResponse);
|
||||
} else {
|
||||
console.error('Error fetching invoice');
|
||||
showToast('error', 'Error', 'Error fetching invoice');
|
||||
}
|
||||
}
|
||||
} else if (kind0?.lud06) {
|
||||
// handle lnurlpay
|
||||
} else {
|
||||
showToast('error', 'Error', 'User has no Lightning Address or LNURL');
|
||||
return;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
const fetchZapsForEvent = async (eventId) => {
|
||||
const filter = [{ kinds: [9735], "#e": [eventId] }];
|
||||
fetchEvents(filter, 'zaps', () => true);
|
||||
}
|
||||
|
||||
// Fetch resources, workshops, courses, and streams with appropriate filters and update functions
|
||||
const fetchResources = async () => {
|
||||
const filter = [{ kinds: [30023], authors: [AUTHOR_PUBKEY] }];
|
||||
const hasRequiredTags = async (eventData) => {
|
||||
const hasPlebDevs = eventData.some(([tag, value]) => tag === "t" && value === "plebdevs");
|
||||
const hasResource = eventData.some(([tag, value]) => tag === "t" && value === "resource");
|
||||
if (hasPlebDevs && hasResource) {
|
||||
const resourceId = eventData.find(([tag]) => tag === "d")?.[1];
|
||||
if (resourceId) {
|
||||
try {
|
||||
const response = await axios.get(`/api/resources/${resourceId}`);
|
||||
return response.status === 200;
|
||||
} catch (error) {
|
||||
// Handle 404 or other errors gracefully
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
fetchEvents(filter, 'resources', hasRequiredTags);
|
||||
};
|
||||
|
||||
const fetchWorkshops = async () => {
|
||||
const filter = [{ kinds: [30023], authors: [AUTHOR_PUBKEY] }];
|
||||
const hasRequiredTags = async (eventData) => {
|
||||
const hasPlebDevs = eventData.some(([tag, value]) => tag === "t" && value === "plebdevs");
|
||||
const hasWorkshop = eventData.some(([tag, value]) => tag === "t" && value === "workshop");
|
||||
if (hasPlebDevs && hasWorkshop) {
|
||||
const workshopId = eventData.find(([tag]) => tag === "d")?.[1];
|
||||
if (workshopId) {
|
||||
try {
|
||||
const response = await axios.get(`/api/resources/${workshopId}`);
|
||||
return response.status === 200;
|
||||
} catch (error) {
|
||||
// Handle 404 or other errors gracefully
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
fetchEvents(filter, 'workshops', hasRequiredTags);
|
||||
};
|
||||
|
||||
const fetchCourses = async () => {
|
||||
const filter = [{ kinds: [30023], authors: [AUTHOR_PUBKEY] }];
|
||||
const hasRequiredTags = async (eventData) => {
|
||||
const hasPlebDevs = eventData.some(([tag, value]) => tag === "t" && value === "plebdevs");
|
||||
const hasCourse = eventData.some(([tag, value]) => tag === "t" && value === "course");
|
||||
if (hasPlebDevs && hasCourse) {
|
||||
const courseId = eventData.find(([tag]) => tag === "d")?.[1];
|
||||
if (courseId) {
|
||||
// try {
|
||||
// const response = await axios.get(`/api/resources/${courseId}`);
|
||||
// return response.status === 200;
|
||||
// } catch (error) {
|
||||
// // Handle 404 or other errors gracefully
|
||||
// return false;
|
||||
// }
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
fetchEvents(filter, 'courses', hasRequiredTags);
|
||||
};
|
||||
|
||||
// const fetchStreams = () => {
|
||||
// const filter = [{kinds: [30311], authors: [AUTHOR_PUBKEY]}];
|
||||
// const hasRequiredTags = (eventData) => eventData.some(([tag, value]) => tag === "t" && value === "plebdevs");
|
||||
// fetchEvents(filter, 'streams', hasRequiredTags);
|
||||
// }
|
||||
|
||||
const fetchKind0 = async (criteria, params) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const events = [];
|
||||
const timeoutDuration = 1000;
|
||||
|
||||
const sub = pool.current.subscribeMany(relays, criteria, {
|
||||
...params,
|
||||
onevent: (event) => {
|
||||
events.push(event);
|
||||
},
|
||||
onerror: (error) => {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Set a timeout to sort and resolve with the most recent event
|
||||
setTimeout(() => {
|
||||
if (events.length === 0) {
|
||||
resolve(null); // or reject based on your needs
|
||||
} else {
|
||||
events.sort((a, b) => b.created_at - a.created_at); // Sort in descending order
|
||||
const mostRecentEventContent = JSON.parse(events[0].content);
|
||||
resolve(mostRecentEventContent);
|
||||
}
|
||||
}, timeoutDuration);
|
||||
});
|
||||
};
|
||||
|
||||
const fetchSingleEvent = async (id) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const sub = pool.current.subscribeMany(relays, [{ ids: [id] }], {
|
||||
onevent: (event) => {
|
||||
resolve(event);
|
||||
},
|
||||
onerror: (error) => {
|
||||
reject(error);
|
||||
},
|
||||
onclose: () => {
|
||||
// Handle connection closure and retry if needed
|
||||
console.log("Connection closed");
|
||||
// Implement retry logic here
|
||||
},
|
||||
oneose: () => {
|
||||
console.log("Subscription closed");
|
||||
sub.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const publishEvent = async (relay, signedEvent) => {
|
||||
console.log('publishing event to', relay);
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = 3000
|
||||
const wsRelay = new window.WebSocket(relay)
|
||||
let timer
|
||||
let isMessageSentSuccessfully = false
|
||||
|
||||
function timedout() {
|
||||
clearTimeout(timer)
|
||||
wsRelay.close()
|
||||
reject(new Error(`relay timeout for ${relay}`))
|
||||
}
|
||||
|
||||
timer = setTimeout(timedout, timeout)
|
||||
|
||||
wsRelay.onopen = function () {
|
||||
clearTimeout(timer)
|
||||
timer = setTimeout(timedout, timeout)
|
||||
wsRelay.send(JSON.stringify(['EVENT', signedEvent]))
|
||||
}
|
||||
|
||||
wsRelay.onmessage = function (msg) {
|
||||
const m = JSON.parse(msg.data)
|
||||
if (m[0] === 'OK') {
|
||||
isMessageSentSuccessfully = true
|
||||
clearTimeout(timer)
|
||||
wsRelay.close()
|
||||
console.log('Successfully sent event to', relay)
|
||||
resolve()
|
||||
}
|
||||
}
|
||||
|
||||
wsRelay.onerror = function (error) {
|
||||
clearTimeout(timer)
|
||||
console.log(error)
|
||||
reject(new Error(`relay error: Failed to send to ${relay}`))
|
||||
}
|
||||
|
||||
wsRelay.onclose = function () {
|
||||
clearTimeout(timer)
|
||||
if (!isMessageSentSuccessfully) {
|
||||
reject(new Error(`relay error: Failed to send to ${relay}`))
|
||||
}
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
|
||||
const publishAll = async (signedEvent) => {
|
||||
try {
|
||||
const promises = relays.map(relay => publishEvent(relay, signedEvent));
|
||||
const results = await Promise.allSettled(promises)
|
||||
const successfulRelays = []
|
||||
const failedRelays = []
|
||||
|
||||
results.forEach((result, i) => {
|
||||
if (result.status === 'fulfilled') {
|
||||
successfulRelays.push(relays[i])
|
||||
showToast('success', `published to ${relays[i]}`)
|
||||
} else {
|
||||
failedRelays.push(relays[i])
|
||||
showToast('error', `failed to publish to ${relays[i]}`)
|
||||
}
|
||||
})
|
||||
|
||||
return { successfulRelays, failedRelays }
|
||||
} catch (error) {
|
||||
console.error('Error publishing event:', error);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
getRelayStatuses();
|
||||
|
||||
const currentSubscriptions = subscriptions.current;
|
||||
|
||||
return () => {
|
||||
// Close all active subscriptions on cleanup
|
||||
currentSubscriptions.forEach((sub) => {
|
||||
sub.close();
|
||||
});
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
updateRelays,
|
||||
fetchSingleEvent,
|
||||
publishAll,
|
||||
fetchKind0,
|
||||
fetchResources,
|
||||
fetchCourses,
|
||||
fetchWorkshops,
|
||||
// fetchStreams,
|
||||
zapEvent,
|
||||
fetchZapsForEvent,
|
||||
getRelayStatuses,
|
||||
events
|
||||
};
|
||||
};
|
@ -196,7 +196,7 @@ export default function Details() {
|
||||
...draft.topics.map(topic => ['t', topic]),
|
||||
['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}`]] : []),
|
||||
...(draft?.price ? [['price', draft.price], ['location', `https://plebdevs.com/details/${draft.id}`]] : []),
|
||||
]
|
||||
};
|
||||
type = 'resource';
|
||||
|
@ -2,7 +2,6 @@ import React from "react";
|
||||
import { Button } from 'primereact/button';
|
||||
import { useLogin } from "@/hooks/useLogin";
|
||||
|
||||
|
||||
const Login = () => {
|
||||
const { nostrLogin, anonymousLogin } = useLogin();
|
||||
return (
|
||||
|
@ -1,46 +0,0 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import { useNostr } from "@/hooks/useNostr";
|
||||
import { parseEvent } from "@/utils/nostr";
|
||||
|
||||
|
||||
const Resource = () => {
|
||||
const [resource, setResource] = useState(null);
|
||||
|
||||
const router = useRouter();
|
||||
const { fetchSingleEvent } = useNostr();
|
||||
|
||||
const { slug } = router.query;
|
||||
|
||||
console.log('slug:', slug);
|
||||
|
||||
useEffect(() => {
|
||||
const getResource = async () => {
|
||||
if (slug) {
|
||||
const fetchedResource = await fetchSingleEvent(slug);
|
||||
console.log('fetchedResource:', fetchedResource);
|
||||
const formattedResource = parseEvent(fetchedResource);
|
||||
console.log('formattedResource:', formattedResource.summary);
|
||||
setResource(formattedResource);
|
||||
}
|
||||
};
|
||||
|
||||
if (slug && !resource) {
|
||||
getResource();
|
||||
}
|
||||
}, [slug]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col justify-center mx-12">
|
||||
<h1 className="my-6 text-3xl text-center font-bold">{resource?.title}</h1>
|
||||
<h2 className="text-lg text-center whitespace-pre-line">{resource?.summary}</h2>
|
||||
<div className="mx-auto my-6">
|
||||
{
|
||||
resource?.content && <MDDisplay source={resource.content} />
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Resource;
|
Loading…
x
Reference in New Issue
Block a user