- {(content?.published_at || content?.created_at) ? `Published: ${formatUnixTimestamp(content?.published_at || content?.created_at)}` : "not yet published"}
+
onSelect(content)}
+ >
+
+
-
-
onSelect(content)} className="py-2" />
+
+
+
+
{content?.title || content?.name}
+
+ {content?.price > 0 ? (
+
+ ) : (
+
+ )}
+
+
+ {content?.summary && (
+
{content.summary}
+ )}
+
+
+ {content?.topics?.map((topic) => (
+
+ ))}
+
+
+
+
+ {(content?.published_at || content?.created_at)
+ ? `Published: ${formatUnixTimestamp(content?.published_at || content?.created_at)}`
+ : "Not yet published"}
+
+
+ {!isMobile && (
+
{
+ e.stopPropagation();
+ onSelect(content);
+ }}
+ className="items-center py-1"
+ />
+ )}
+
diff --git a/src/components/content/dropdowns/MessageDropdownItem.js b/src/components/content/dropdowns/MessageDropdownItem.js
index ce6b0bd..7f94590 100644
--- a/src/components/content/dropdowns/MessageDropdownItem.js
+++ b/src/components/content/dropdowns/MessageDropdownItem.js
@@ -1,87 +1,229 @@
-import React, { useState, useEffect } from "react";
+import React, { useState, useEffect, useCallback, useMemo } from "react";
import CommunityMessage from "@/components/feeds/messages/CommunityMessage";
-import { parseMessageEvent, findKind0Fields } from "@/utils/nostr";
+import { parseMessageEvent } from "@/utils/nostr";
import { ProgressSpinner } from 'primereact/progressspinner';
import { useNDKContext } from "@/context/NDKContext";
+import useWindowWidth from "@/hooks/useWindowWidth";
+import Image from "next/image";
+import { formatTimestampToHowLongAgo } from "@/utils/time";
+import NostrIcon from '/public/images/nostr.png';
+import { useImageProxy } from "@/hooks/useImageProxy";
const MessageDropdownItem = ({ message, onSelect }) => {
- const { ndk, addSigner } = useNDKContext();
- const [author, setAuthor] = useState(null);
- const [formattedMessage, setFormattedMessage] = useState(null);
- const [messageWithAuthor, setMessageWithAuthor] = useState(null);
+ const { ndk } = useNDKContext();
+ const [messageData, setMessageData] = useState(null);
const [loading, setLoading] = useState(true);
const [platform, setPlatform] = useState(null);
+ const windowWidth = useWindowWidth();
+ const isMobile = windowWidth <= 600;
+ const { returnImageProxy } = useImageProxy();
- const determinePlatform = () => {
- if (message?.channel) {
- return "discord";
- } else if (message?.kind) {
- return "nostr";
- } else {
- return "stackernews";
+ // Stable reference to message to prevent infinite loops
+ const messageRef = useMemo(() => message, [message?.id]);
+
+ // Determine the platform once when component mounts or message changes
+ const determinePlatform = useCallback(() => {
+ if (messageRef?.channel) return "discord";
+ if (messageRef?.kind) return "nostr";
+ return "stackernews";
+ }, [messageRef]);
+
+ // Memoize the fetchNostrAuthor function
+ const fetchNostrAuthor = useCallback(async (pubkey) => {
+ if (!ndk || !pubkey) return null;
+
+ try {
+ await ndk.connect();
+ const user = await ndk.getUser({ pubkey });
+ const profile = await user.fetchProfile();
+
+ // Return the parsed profile data directly - it already contains what we need
+ return profile;
+ } catch (error) {
+ console.error("Error fetching Nostr author:", error);
+ return null;
}
- }
+ }, [ndk]);
+ // Process message based on platform type
useEffect(() => {
- setPlatform(determinePlatform(message));
- }, [message]);
+ // Prevent execution if no message data or already loaded
+ if (!messageRef || messageData) return;
- useEffect(() => {
- if (platform === "nostr") {
- const event = parseMessageEvent(message);
- setFormattedMessage(event);
-
- const getAuthor = async () => {
- try {
- await ndk.connect();
- const author = await ndk.getUser({ pubkey: message.pubkey });
- if (author && author?.content) {
- const authorFields = findKind0Fields(JSON.parse(author.content));
- if (authorFields) {
- setAuthor(authorFields);
- }
- } else if (author?._pubkey) {
- setAuthor(author?._pubkey);
+ const currentPlatform = determinePlatform();
+ setPlatform(currentPlatform);
+
+ let isMounted = true;
+
+ const processMessage = async () => {
+ try {
+ if (currentPlatform === "nostr") {
+ // Format Nostr message
+ const parsedMessage = parseMessageEvent(messageRef);
+
+ // Fetch author data for Nostr messages
+ let authorData = null;
+ if (messageRef?.pubkey) {
+ authorData = await fetchNostrAuthor(messageRef.pubkey);
}
- } catch (error) {
- console.error(error);
+
+ // Extract author details with fallbacks
+ const authorName = authorData?.name || authorData?.displayName || "Unknown User";
+ const authorPicture = authorData?.picture || authorData?.image || null;
+
+ // Only update state if component is still mounted
+ if (isMounted) {
+ setMessageData({
+ ...parsedMessage,
+ timestamp: messageRef.created_at || Math.floor(Date.now() / 1000),
+ channel: "plebdevs",
+ author: authorName,
+ avatar: authorPicture,
+ avatarProxy: authorPicture ? returnImageProxy(authorPicture) : null,
+ type: "nostr",
+ id: messageRef.id
+ });
+ }
+
+ } else if (currentPlatform === "discord") {
+ const avatarUrl = messageRef?.author?.avatar
+ ? `https://cdn.discordapp.com/avatars/${messageRef.author.id}/${messageRef.author.avatar}.png`
+ : null;
+
+ if (isMounted) {
+ setMessageData({
+ content: messageRef?.content,
+ author: messageRef?.author?.username || "Unknown User",
+ timestamp: messageRef?.timestamp ? Math.floor(messageRef.timestamp / 1000) : Math.floor(Date.now() / 1000),
+ avatar: avatarUrl,
+ avatarProxy: avatarUrl ? returnImageProxy(avatarUrl) : null,
+ channel: messageRef?.channel || "discord",
+ type: "discord",
+ id: messageRef.id
+ });
+ }
+
+ } else if (currentPlatform === "stackernews") {
+ if (isMounted) {
+ setMessageData({
+ content: messageRef?.title,
+ author: messageRef?.user?.name || "Unknown User",
+ timestamp: messageRef?.created_at ? Math.floor(Date.parse(messageRef.created_at) / 1000) : Math.floor(Date.now() / 1000),
+ avatar: "https://pbs.twimg.com/profile_images/1403162883941359619/oca7LMQ2_400x400.png",
+ avatarProxy: returnImageProxy("https://pbs.twimg.com/profile_images/1403162883941359619/oca7LMQ2_400x400.png"),
+ channel: "~devs",
+ type: "stackernews",
+ id: messageRef.id
+ });
+ }
+ }
+ } catch (error) {
+ console.error("Error processing message:", error);
+ } finally {
+ if (isMounted) {
+ setLoading(false);
}
}
- getAuthor();
- } else if (platform === "stackernews") {
- setMessageWithAuthor({
- content: message?.title,
- author: message?.user?.name,
- timestamp: message?.created_at ? Date.parse(message.created_at) : null,
- avatar: "https://pbs.twimg.com/profile_images/1403162883941359619/oca7LMQ2_400x400.png",
- channel: "~devs",
- ...message
- })
- }
- else {
- setLoading(false);
- }
- }, [ndk, message, platform]);
+ };
+
+ processMessage();
+
+ // Cleanup function to prevent state updates after unmount
+ return () => {
+ isMounted = false;
+ };
+ }, [messageRef, determinePlatform, fetchNostrAuthor, returnImageProxy, messageData]);
- useEffect(() => {
- if (author && formattedMessage && platform === 'nostr') {
- const body = {
- ...formattedMessage,
- timestamp: formattedMessage.created_at || message.created_at || message.timestamp,
- channel: "plebdevs",
- author: author
- };
- setMessageWithAuthor(body);
- setLoading(false);
+ const getPlatformIcon = useCallback(() => {
+ switch (platform) {
+ case 'nostr':
+ return
;
+ case 'discord':
+ return
;
+ case 'stackernews':
+ return
;
+ default:
+ return
;
}
- }, [author, formattedMessage, message, platform]);
+ }, [platform]);
+
+ // Create a simplified view for mobile search results
+ const renderSimplifiedMessage = useCallback(() => {
+ if (!messageData) return null;
+
+ const authorName = messageData.author || "Unknown User";
+ const avatarUrl = messageData.avatarProxy || returnImageProxy(messageData.avatar);
+ const messageDate = messageData.timestamp ? formatTimestampToHowLongAgo(messageData.timestamp) : '';
+
+ return (
+
+
+
+ {avatarUrl ? (
+
+ ) : (
+
+
+
+ )}
+
+
+
+
+
+ {authorName}
+
+
+ {messageDate}
+
+
+
{messageData.content}
+
+
+
+ {getPlatformIcon()}
+ {platform}
+
+
+ plebdevs
+
+
+
+
+
+ );
+ }, [messageData, returnImageProxy, getPlatformIcon, platform]);
+
+ // Memoize the final message object to pass to CommunityMessage
+ const finalMessage = useMemo(() => {
+ if (!messageData) return null;
+ return {
+ ...messageData,
+ avatar: messageData?.avatarProxy || returnImageProxy(messageData?.avatar)
+ };
+ }, [messageData, returnImageProxy]);
return (
-
+
!loading && onSelect(messageData || messageRef)}
+ >
{loading ? (
-
+
) : (
-
+ isMobile ? renderSimplifiedMessage() :
+
)}
);
diff --git a/src/components/feeds/DiscordFeed.js b/src/components/feeds/DiscordFeed.js
index dfe28b7..c5b5ba4 100644
--- a/src/components/feeds/DiscordFeed.js
+++ b/src/components/feeds/DiscordFeed.js
@@ -15,7 +15,7 @@ const DiscordFeed = ({ searchQuery }) => {
if (!data) return [];
return data
.filter(message =>
- message.content.toLowerCase().includes(searchQuery.toLowerCase())
+ searchQuery ? message.content.toLowerCase().includes(searchQuery.toLowerCase()) : true
)
.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
}, [data, searchQuery]);
@@ -33,8 +33,8 @@ const DiscordFeed = ({ searchQuery }) => {
}
return (
-
-
+
+
{filteredData.length > 0 ? (
filteredData.map(message => (
{
const dateB = b.type === 'nostr' ? b.created_at * 1000 : new Date(b.timestamp || b.createdAt);
return dateB - dateA;
}).filter(item => {
+ if (!searchQuery) return true;
const searchLower = searchQuery.toLowerCase();
if (item.type === 'discord' || item.type === 'nostr') {
return item.content.toLowerCase().includes(searchLower);
@@ -108,8 +109,8 @@ const GlobalFeed = ({searchQuery}) => {
});
return (
-
-
+
+
{combinedFeed.length > 0 ? (
combinedFeed.map(item => (
{
const [message, setMessage] = useState('');
- const [collapsed, setCollapsed] = useState(true);
+ const [isSubmitting, setIsSubmitting] = useState(false);
const { ndk, addSigner } = useNDKContext();
const { showToast } = useToast();
const { data: session } = useSession();
+ const pool = useRef(null);
+
+ // Initialize pool when needed
+ const getPool = async () => {
+ if (!pool.current) {
+ pool.current = new SimplePool();
+ }
+ return pool.current;
+ };
+
+ const publishToRelay = async (relay, event, currentPool) => {
+ try {
+ // Wait for relay connection
+ await currentPool.ensureRelay(relay);
+ await currentPool.publish([relay], event);
+ return true;
+ } catch (err) {
+ console.warn(`Failed to publish to ${relay}:`, err);
+ return false;
+ }
+ };
const handleSubmit = async () => {
- if (session && session?.user && session.user?.privkey) {
- handleManualSubmit(session.user.privkey);
- } else {
- handleExtensionSubmit();
+ if (!message.trim()) return;
+ if (isSubmitting) return;
+
+ try {
+ setIsSubmitting(true);
+ if (session && session?.user && session.user?.privkey) {
+ await handleManualSubmit(session.user.privkey);
+ } else {
+ await handleExtensionSubmit();
+ }
+ } catch (error) {
+ console.error("Error submitting message:", error);
+ showToast('error', 'Error', 'There was an error sending your message. Please try again.');
+ } finally {
+ setIsSubmitting(false);
}
}
const handleExtensionSubmit = async () => {
- if (!message.trim() || !ndk) return;
+ if (!ndk) return;
try {
if (!ndk.signer) {
@@ -39,10 +70,10 @@ const MessageInput = () => {
await event.publish();
showToast('success', 'Message Sent', 'Your message has been sent to the PlebDevs community.');
- setMessage(''); // Clear the input after successful publish
+ setMessage('');
} catch (error) {
console.error("Error publishing message:", error);
- showToast('error', 'Error', 'There was an error sending your message. Please try again.');
+ throw error;
}
};
@@ -55,66 +86,62 @@ const MessageInput = () => {
['t', 'plebdevs']
],
content: message,
- }, privkey)
+ }, privkey);
let isGood = verifyEvent(event);
+ if (!isGood) {
+ throw new Error('Event verification failed');
+ }
- if (isGood) {
- const pool = new SimplePool();
- const published = await pool.publish(appConfig.defaultRelayUrls, event);
- if (published) {
+ try {
+ const currentPool = await getPool();
+ let publishedToAny = false;
+
+ // Try to publish to each relay sequentially
+ for (const relay of appConfig.defaultRelayUrls) {
+ const success = await publishToRelay(relay, event, currentPool);
+ if (success) {
+ publishedToAny = true;
+ break; // Stop after first successful publish
+ }
+ }
+
+ if (publishedToAny) {
showToast('success', 'Message Sent', 'Your message has been sent to the PlebDevs community.');
setMessage('');
} else {
- showToast('error', 'Error', 'There was an error sending your message. Please try again.');
+ throw new Error('Failed to publish to any relay');
}
- } else {
- showToast('error', 'Error', 'There was an error sending your message. Please try again.');
+ } catch (err) {
+ console.error("Publishing error:", err);
+ throw err;
}
} catch (error) {
console.error("Error finalizing event:", error);
- showToast('error', 'Error', 'There was an error sending your message. Please try again.');
+ throw error;
}
}
- const headerTemplate = (options) => {
- return (
-
-
-
New Message
-
- );
- };
-
return (
- setCollapsed(e.value)}
- className="w-full"
- >
-
- setMessage(e.target.value)}
- rows={2}
- cols={10}
- autoResize
- placeholder="Type your message here..."
- className="w-full"
- />
-
-
-
-
-
+
+ setMessage(e.target.value)}
+ rows={1}
+ autoResize
+ placeholder="Type your message here..."
+ className="flex-1 bg-[#1e2732] border-[#2e3b4e] rounded-lg"
+ disabled={isSubmitting}
+ />
+
+
);
};
diff --git a/src/components/feeds/NostrFeed.js b/src/components/feeds/NostrFeed.js
index fca1298..817e0e5 100644
--- a/src/components/feeds/NostrFeed.js
+++ b/src/components/feeds/NostrFeed.js
@@ -72,13 +72,13 @@ const NostrFeed = ({ searchQuery }) => {
const filteredNotes = communityNotes
.filter(message =>
- message.content.toLowerCase().includes(searchQuery.toLowerCase())
+ searchQuery ? message.content.toLowerCase().includes(searchQuery.toLowerCase()) : true
)
.sort((a, b) => b.created_at - a.created_at);
return (
-
-
+
+
{filteredNotes.length > 0 ? (
filteredNotes.map(message => (
{
const filteredItems = items
.filter(item =>
- item.title.toLowerCase().includes(searchQuery.toLowerCase())
+ searchQuery ? item.title.toLowerCase().includes(searchQuery.toLowerCase()) : true
)
.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
return (
-
-
+
+
{filteredItems && filteredItems.length > 0 ? (
filteredItems.map(item => (
{
}
}}
>
- {highlightText(message.content, searchQuery)}
+
+ {searchQuery ? highlightText(message.content, searchQuery) : message.content}
+
);
};
diff --git a/src/components/menutab/CommunityMenuTab.js b/src/components/menutab/CommunityMenuTab.js
index 3493343..08bf9bf 100644
--- a/src/components/menutab/CommunityMenuTab.js
+++ b/src/components/menutab/CommunityMenuTab.js
@@ -52,9 +52,9 @@ const CommunityMenuTab = ({ selectedTopic, onTabChange }) => {
activeIndex={allItems.indexOf(selectedTopic)}
onTabChange={(e) => onTabChange(allItems[e.index])}
pt={{
- menu: { className: 'bg-transparent border-none ml-2 my-4 py-1' },
+ menu: { className: 'bg-transparent border-none my-1 py-1' },
action: ({ context, parent }) => ({
- className: 'cursor-pointer select-none flex items-center relative no-underline overflow-hidden border-b-2 p-2 font-bold rounded-t-lg',
+ className: 'cursor-pointer select-none flex items-center relative no-underline overflow-hidden border-b-2 p-2 pl-[4px] font-bold rounded-t-lg',
style: { top: '2px' }
}),
menuitem: { className: 'mr-0' }
diff --git a/src/components/navbar/Navbar.js b/src/components/navbar/Navbar.js
index b7c65a9..bee4a62 100644
--- a/src/components/navbar/Navbar.js
+++ b/src/components/navbar/Navbar.js
@@ -1,48 +1,142 @@
-import React from 'react';
+import React, { useState, useRef, useEffect } from 'react';
import Image from 'next/image';
import UserAvatar from './user/UserAvatar';
-import { Menubar } from 'primereact/menubar';
+import { Menu } from 'primereact/menu';
import { useRouter } from 'next/router';
import SearchBar from '../search/SearchBar';
+import { useSession } from 'next-auth/react';
import 'primereact/resources/primereact.min.css';
import 'primeicons/primeicons.css';
-import { useNDKContext } from '@/context/NDKContext';
import useWindowWidth from '@/hooks/useWindowWidth';
const Navbar = () => {
const router = useRouter();
const windowWidth = useWindowWidth();
const navbarHeight = '60px';
- const {ndk} = useNDKContext();
+ const { data: session } = useSession();
+ const [isHovered, setIsHovered] = useState(false);
+ const [showMobileSearch, setShowMobileSearch] = useState(false);
+ const menu = useRef(null);
- const start = (
-
-
router.push('/')} className="flex flex-row items-center justify-center cursor-pointer">
-
-
PlebDevs
-
- {ndk && windowWidth > 600 &&
}
-
- );
+ // Lock/unlock body scroll when mobile search is shown/hidden
+ useEffect(() => {
+ if (showMobileSearch && windowWidth <= 600) {
+ document.body.style.overflow = 'hidden';
+ } else {
+ document.body.style.overflow = 'unset';
+ }
+
+ // Cleanup effect
+ return () => {
+ document.body.style.overflow = 'unset';
+ };
+ }, [showMobileSearch, windowWidth]);
+
+ const menuItems = [
+ {
+ label: 'Content',
+ icon: 'pi pi-play-circle',
+ command: () => router.push('/content?tag=all')
+ },
+ {
+ label: 'Feeds',
+ icon: 'pi pi-comments',
+ command: () => router.push('/feed?channel=global')
+ },
+ {
+ label: 'Subscribe',
+ icon: 'pi pi-star',
+ command: () => session?.user ? router.push('/profile?tab=subscribe') : router.push('/about')
+ },
+ {
+ label: 'About',
+ icon: 'pi pi-info-circle',
+ command: () => router.push('/about')
+ }
+ ];
return (
<>
-
+
+ {/* Left section */}
+
+
router.push('/')} className="flex flex-row items-center justify-center cursor-pointer hover:opacity-80">
+
+
PlebDevs
+
+ {windowWidth > 600 ? (
+
setIsHovered(true)}
+ onMouseLeave={() => setIsHovered(false)}
+ onClick={(e) => menu.current.toggle(e)}
+ style={{ width: '40px', height: '40px' }}
+ >
+
+
+
+
+
+ ) : (
+
setShowMobileSearch(!showMobileSearch)}
+ style={{ width: '40px', height: '40px' }}
+ >
+
+
+ )}
+
+
+
+ {/* Center section - Search */}
+ {windowWidth > 600 && (
+
+
+
+ )}
+
+ {/* Right section - User Avatar */}
+
+
+
+
+
{/* Placeholder div with the same height as the Navbar */}
+
+ {/* Mobile Search Overlay */}
+ {showMobileSearch && windowWidth <= 600 && (
+
+
+
+
+
Search
+
+
+
+ setShowMobileSearch(false)}
+ />
+
+
+
+
+ )}
>
);
};
diff --git a/src/components/navbar/navbar.module.css b/src/components/navbar/navbar.module.css
index af80b05..9888b5c 100644
--- a/src/components/navbar/navbar.module.css
+++ b/src/components/navbar/navbar.module.css
@@ -10,7 +10,6 @@
.logo {
border-radius: 50%;
- margin-right: 8px;
height: 50px;
width: 50px;
}
diff --git a/src/components/navbar/user/UserAvatar.js b/src/components/navbar/user/UserAvatar.js
index 4d056f7..790f527 100644
--- a/src/components/navbar/user/UserAvatar.js
+++ b/src/components/navbar/user/UserAvatar.js
@@ -1,13 +1,11 @@
import React, { useRef, useState, useEffect } from 'react';
import Image from 'next/image';
-import { Avatar } from 'primereact/avatar';
import { useRouter } from 'next/router';
import { useImageProxy } from '@/hooks/useImageProxy';
import GenericButton from '@/components/buttons/GenericButton';
import { Menu } from 'primereact/menu';
import useWindowWidth from '@/hooks/useWindowWidth';
import { useSession, signOut } from 'next-auth/react';
-import { ProgressSpinner } from 'primereact/progressspinner';
import { useIsAdmin } from '@/hooks/useIsAdmin';
import 'primereact/resources/primereact.min.css';
import 'primeicons/primeicons.css';
@@ -70,7 +68,7 @@ const UserAvatar = () => {
// Only show the "Create" option for admin users
...(isAdmin ? [{
label: 'Create',
- icon: 'pi pi-book',
+ icon: 'pi pi-file-edit',
command: () => router.push('/create')
}] : []),
{
@@ -84,14 +82,6 @@ const UserAvatar = () => {
userAvatar = (
<>
-
router.push('/about')}
- size={windowWidth < 768 ? 'small' : 'normal'}
- />
menu.current.toggle(event)} className={`flex flex-row items-center justify-between cursor-pointer hover:opacity-75`}>
{
} else {
userAvatar = (
-
router.push('/about')}
- size={windowWidth < 768 ? 'small' : 'normal'}
- />
-
Relays
Manage your connected relays
className="flex-1"
/>
};
return (
-
+
{
const isError = coursesError || documentsError || videosError || draftsError || contentIdsError || courseDraftsError;
return (
-
+
{
windowWidth < 768 && (
My Content
diff --git a/src/components/profile/UserProfile.js b/src/components/profile/UserProfile.js
index 9050878..5b2b3aa 100644
--- a/src/components/profile/UserProfile.js
+++ b/src/components/profile/UserProfile.js
@@ -10,6 +10,7 @@ import UserProgress from "@/components/profile/progress/UserProgress";
import UserProgressTable from '@/components/profile/DataTables/UserProgressTable';
import UserPurchaseTable from '@/components/profile/DataTables/UserPurchaseTable';
import BitcoinLightningCard from '@/components/profile/BitcoinLightningCard';
+import UserAccountLinking from "@/components/profile/UserAccountLinking";
const UserProfile = () => {
const windowWidth = useWindowWidth();
@@ -32,7 +33,7 @@ const UserProfile = () => {
return (
user && (
-
+
{
windowWidth < 768 && (
Profile
@@ -42,6 +43,7 @@ const UserProfile = () => {
{user && }
+ {user && }
diff --git a/src/components/profile/UserProfileCard.js b/src/components/profile/UserProfileCard.js
index af37210..4ace6bd 100644
--- a/src/components/profile/UserProfileCard.js
+++ b/src/components/profile/UserProfileCard.js
@@ -2,19 +2,24 @@ import React, { useRef, useState } from 'react';
import Image from 'next/image';
import { Menu } from 'primereact/menu';
import { Tooltip } from 'primereact/tooltip';
+import { Dialog } from 'primereact/dialog';
import { nip19 } from 'nostr-tools';
import { useImageProxy } from '@/hooks/useImageProxy';
import { useToast } from '@/hooks/useToast';
import UserBadges from '@/components/profile/UserBadges';
import useWindowWidth from '@/hooks/useWindowWidth';
import MoreInfo from '@/components/MoreInfo';
+import UserRelaysTable from '@/components/profile/DataTables/UserRelaysTable';
+import { useNDKContext } from "@/context/NDKContext";
const UserProfileCard = ({ user }) => {
const [showBadges, setShowBadges] = useState(false);
+ const [showRelaysModal, setShowRelaysModal] = useState(false);
const menu = useRef(null);
const { showToast } = useToast();
const { returnImageProxy } = useImageProxy();
const windowWidth = useWindowWidth();
+ const { ndk, userRelays, setUserRelays, reInitializeNDK } = useNDKContext();
const copyToClipboard = (text) => {
navigator.clipboard.writeText(text);
@@ -43,6 +48,11 @@ const UserProfileCard = ({ user }) => {
label: 'Open Nostr Profile',
icon: 'pi pi-external-link',
command: () => window.open(`https://nostr.com/${nip19.npubEncode(user?.pubkey)}`, '_blank')
+ },
+ {
+ label: 'Manage Relays',
+ icon: 'pi pi-server',
+ command: () => setShowRelaysModal(true)
}
];
@@ -282,6 +292,20 @@ const UserProfileCard = ({ user }) => {
visible={showBadges}
onHide={() => setShowBadges(false)}
/>
+
>
);
};
diff --git a/src/components/profile/UserSettings.js b/src/components/profile/UserSettings.js
deleted file mode 100644
index dea0d09..0000000
--- a/src/components/profile/UserSettings.js
+++ /dev/null
@@ -1,47 +0,0 @@
-import React, { useState, useEffect } from "react";
-import UserProfileCard from "@/components/profile/UserProfileCard";
-import { useSession } from 'next-auth/react';
-import { useNDKContext } from "@/context/NDKContext";
-import useWindowWidth from "@/hooks/useWindowWidth";
-import UserRelaysTable from "@/components/profile/DataTables/UserRelaysTable";
-import UserAccountLinking from "@/components/profile/UserAccountLinking";
-const UserSettings = () => {
- const [user, setUser] = useState(null);
- const { ndk, userRelays, setUserRelays, reInitializeNDK } = useNDKContext();
- const { data: session } = useSession();
- const windowWidth = useWindowWidth();
-
- useEffect(() => {
- if (session?.user) {
- setUser(session.user);
- }
- }, [session]);
-
- return (
- user && (
-
- {windowWidth < 768 && (
-
Settings
- )}
-
-
-
-
- {user && }
-
-
-
-
-
-
-
- )
- );
-};
-
-export default UserSettings;
diff --git a/src/components/profile/subscription/UserSubscription.js b/src/components/profile/subscription/UserSubscription.js
index c89a4f1..59358f8 100644
--- a/src/components/profile/subscription/UserSubscription.js
+++ b/src/components/profile/subscription/UserSubscription.js
@@ -93,7 +93,7 @@ const UserSubscription = () => {
};
return (
-
+
{windowWidth < 768 && (
Subscription Management
)}
@@ -117,7 +117,7 @@ const UserSubscription = () => {
{!subscribed && (
{
+const SearchBar = ({ isMobileSearch, isDesktopNav, onCloseSearch }) => {
const { searchContent, searchResults: contentResults } = useContentSearch();
const { searchCommunity, searchResults: communityResults } = useCommunitySearch();
const router = useRouter();
@@ -25,8 +23,18 @@ const SearchBar = () => {
const [searchTerm, setSearchTerm] = useState('');
const [searchResults, setSearchResults] = useState([]);
const op = useRef(null);
+ const { ndk, reInitializeNDK } = useNDKContext();
const selectedOptionTemplate = (option, props) => {
+ if (isDesktopNav) {
+ // For desktop nav bar, just show the icon
+ return (
+
+
+
+ );
+ }
+
if (!props?.placeholder) {
return (
@@ -45,14 +53,14 @@ const SearchBar = () => {
if (selectedSearchOption.code === 'content') {
searchContent(term);
setSearchResults(contentResults);
- } else if (selectedSearchOption.code === 'community') {
+ } else if (selectedSearchOption.code === 'community' && ndk) {
searchCommunity(term);
setSearchResults(communityResults);
}
- if (term.length > 2) {
+ if (!isMobileSearch && term.length > 2) {
op.current.show(e);
- } else {
+ } else if (!isMobileSearch) {
op.current.hide();
}
};
@@ -65,81 +73,230 @@ const SearchBar = () => {
}
}, [selectedSearchOption, contentResults, communityResults]);
+ useEffect(() => {
+ const handleError = (event) => {
+ if (event.message && event.message.includes('wss://relay.devs.tools')) {
+ console.warn('Nostr relay connection error detected, reinitializing NDK');
+ reInitializeNDK();
+ }
+ };
+
+ window.addEventListener('error', handleError);
+
+ return () => {
+ window.removeEventListener('error', handleError);
+ };
+ }, [reInitializeNDK]);
+
const handleContentSelect = (content) => {
- if (content?.type === 'course') {
- router.push(`/course/${content.id}`);
- } else {
- router.push(`/details/${content.id}`);
+ if (selectedSearchOption.code === 'content') {
+ if (content?.type === 'course') {
+ router.push(`/course/${content?.d || content?.id}`);
+ } else {
+ router.push(`/details/${content.id}`);
+ }
+ } else if (selectedSearchOption.code === 'community') {
+ if (content.type === 'discord') {
+ router.push('/feed?channel=discord');
+ } else if (content.type === 'nostr') {
+ router.push('/feed?channel=nostr');
+ } else if (content.type === 'stackernews') {
+ router.push('/feed?channel=stackernews');
+ } else {
+ router.push('/feed?channel=global');
+ }
}
+
setSearchTerm('');
searchContent('');
- op.current.hide();
+ searchCommunity('');
+ setSearchResults([]);
+
+ if (op.current) {
+ op.current.hide();
+ }
+
+ if (isMobileSearch && onCloseSearch) {
+ onCloseSearch();
+ } else if (isMobileSearch && window.parent) {
+ const navbar = document.querySelector('.navbar-mobile-search');
+ if (navbar) {
+ const closeButton = navbar.querySelector('button');
+ if (closeButton) {
+ closeButton.click();
+ }
+ }
+ }
}
+ const renderSearchResults = () => {
+ if (searchResults.length === 0 && searchTerm.length > 2) {
+ return
No results found
;
+ }
+
+ return searchResults.map((item, index) => (
+ item.type === 'discord' || item.type === 'nostr' || item.type === 'stackernews' ? (
+
+ ) : (
+
+ )
+ ));
+ };
+
return (
-
-
-
- 845 ? 'w-[300px]' : 'w-[160px]'}`}
- value={searchTerm}
- onChange={handleSearch}
- placeholder={`Search ${selectedSearchOption.name.toLowerCase()}`}
- pt={{
- root: {
- className: 'border-none rounded-tr-none rounded-br-none focus:border-none focus:ring-0 pr-0'
- }
- }}
- />
-
- setSelectedSearchOption(e.value)}
- options={searchOptions}
- optionLabel="name"
- placeholder="Search"
- dropdownIcon={
-
-
-
+ <>
+
+ {isDesktopNav ? (
+ // Desktop navbar search with integrated dropdown
+
+
+
+
+
+
+
+
setSelectedSearchOption(e.value)}
+ options={searchOptions}
+ optionLabel="name"
+ dropdownIcon={
+
+ }
+ valueTemplate={selectedOptionTemplate}
+ itemTemplate={(option) => (
+
+
+ {option.name}
+
+ )}
+ />
+
+
+ ) : (
+ // Original search for other views
+
+
+
+ 845 ? 'w-[300px]' : (isMobileSearch || windowWidth <= 600) ? 'w-full' : 'w-[160px]'}
+ ${isMobileSearch ? 'bg-transparent border-none pl-12 text-lg' : ''}
+ `}
+ value={searchTerm}
+ onChange={handleSearch}
+ placeholder={`Search ${selectedSearchOption.name.toLowerCase()}`}
+ pt={{
+ root: {
+ className: `${isMobileSearch ? 'focus:ring-0' : 'border-none rounded-tr-none rounded-br-none focus:border-none focus:ring-0 pr-0'}`
+ }
+ }}
+ />
- }
- valueTemplate={selectedOptionTemplate}
- itemTemplate={selectedOptionTemplate}
- required
- />
-
-
- {searchResults.map((item, index) => (
- item.type === 'discord' || item.type === 'nostr' || item.type === 'stackernews' ? (
-
- ) : (
-
- )
- ))}
- {searchResults.length === 0 && searchTerm.length > 2 && (
- No results found
+ {isMobileSearch && (
+
+ {searchOptions.map((option) => (
+
+ ))}
+
+ )}
+
+ {!isMobileSearch && (
+ setSelectedSearchOption(e.value)}
+ options={searchOptions}
+ optionLabel="name"
+ placeholder="Search"
+ dropdownIcon={
+
+
+
+
+ }
+ valueTemplate={selectedOptionTemplate}
+ itemTemplate={selectedOptionTemplate}
+ required
+ />
+ )}
+
)}
-
-
+
+
+ {/* Desktop Search Results */}
+ {!isMobileSearch && (
+
+ {renderSearchResults()}
+
+ )}
+
+ {/* Mobile Search Results */}
+ {isMobileSearch && searchTerm.length > 2 && (
+
+
+ {renderSearchResults()}
+
+
+ )}
+ >
);
};
diff --git a/src/components/search/searchbar.module.css b/src/components/search/searchbar.module.css
deleted file mode 100644
index f9dd596..0000000
--- a/src/components/search/searchbar.module.css
+++ /dev/null
@@ -1,19 +0,0 @@
-/* .dropdown {
- border: none !important;
- outline: none !important;
- box-shadow: none !important;
-}
-
-.dropdown:focus,
-.dropdown:active,
-.dropdown:hover {
- border: none !important;
- outline: none !important;
- box-shadow: none !important;
-} */
-
-/* Override any potential global styles */
-/* .dropdown * {
- outline: none !important;
- box-shadow: none !important;
-} */
diff --git a/src/components/sidebar/Sidebar.js b/src/components/sidebar/Sidebar.js
deleted file mode 100644
index 38c7576..0000000
--- a/src/components/sidebar/Sidebar.js
+++ /dev/null
@@ -1,151 +0,0 @@
-import React, { useState, useEffect, useCallback } from 'react';
-import { useRouter } from 'next/router';
-import { useSession, signOut } from 'next-auth/react';
-import { useIsAdmin } from '@/hooks/useIsAdmin';
-import { nip19 } from 'nostr-tools';
-import { useToast } from '@/hooks/useToast';
-import { useNDKContext } from '@/context/NDKContext';
-import 'primeicons/primeicons.css';
-import styles from "./sidebar.module.css";
-import { Divider } from 'primereact/divider';
-
-const Sidebar = ({ course = false }) => {
- const { isAdmin } = useIsAdmin();
- const [lessons, setLessons] = useState([]);
- const router = useRouter();
- const { showToast } = useToast();
- const { ndk, addSigner } = useNDKContext();
-
- // Helper function to determine if the path matches the current route
- const isActive = (path) => {
- if (path === '/content') {
- return router.pathname === '/content';
- }
- if (path === '/feed') {
- return router.pathname === '/feed';
- }
- return router.asPath === path;
- };
-
- const { data: session } = useSession();
-
- useEffect(() => {
- if (router.isReady) {
- const { slug } = router.query;
-
- try {
- if (slug && course) {
- const { data } = nip19.decode(slug)
-
- if (!data) {
- showToast('error', 'Error', 'Course not found');
- return;
- }
-
- const id = data?.identifier;
- const fetchCourse = async (id) => {
- try {
- await ndk.connect();
-
- const filter = {
- '#d': [id]
- }
-
- const event = await ndk.fetchEvent(filter);
-
- if (event) {
- // all a tags are lessons
- const lessons = event.tags.filter(tag => tag[0] === 'a');
- const uniqueLessons = [...new Set(lessons.map(lesson => lesson[1]))];
- setLessons(uniqueLessons);
- }
- } catch (error) {
- console.error('Error fetching event:', error);
- }
- };
- if (ndk && id) {
- fetchCourse(id);
- }
- }
- } catch (err) {
- console.error(err);
- }
- }
- }, [router.isReady, router.query, ndk, course]);
-
- const scrollToLesson = useCallback((index) => {
- const lessonElement = document.getElementById(`lesson-${index}`);
- if (lessonElement) {
- lessonElement.scrollIntoView({ behavior: 'smooth' });
- }
- }, []);
-
- useEffect(() => {
- if (router.isReady && router.query.active) {
- const activeIndex = parseInt(router.query.active);
- scrollToLesson(activeIndex);
- }
- }, [router.isReady, router.query.active, scrollToLesson]);
-
- return (
-
-
- {course && lessons.length > 0 && (
-
-
router.push('/')} className="w-full flex flex-row items-center cursor-pointer py-2 my-2 hover:bg-gray-700 rounded-lg">
-
Home
-
- {lessons.map((lesson, index) => (
-
{
- router.push(`/course/${router?.query?.slug}?active=${index}`, undefined, { shallow: true });
- scrollToLesson(index);
- }}
- className={`w-full flex flex-row items-center cursor-pointer py-2 my-2 hover:bg-gray-700 rounded-lg ${isActive(`/course/${router?.query?.slug}?active=${index}`) ? 'bg-gray-700' : ''}`}
- >
-
Lesson {index + 1}
-
- ))}
-
- )}
- {!course && (
-
-
router.push('/')} className={`w-full flex flex-row items-center cursor-pointer py-2 my-2 hover:bg-gray-700 rounded-lg ${isActive('/') ? 'bg-gray-700' : ''}`}>
-
Home
-
-
router.push('/content?tag=all')} className={`w-full flex flex-row items-center cursor-pointer py-2 my-2 hover:bg-gray-700 rounded-lg ${isActive('/content') || router.pathname === '/content' ? 'bg-gray-700' : ''}`}>
-
Content
-
-
router.push('/feed?channel=global')} className={`w-full flex flex-row items-center cursor-pointer py-2 my-2 hover:bg-gray-700 rounded-lg ${isActive('/feed') ? 'bg-gray-700' : ''}`}>
-
Feeds
-
- {isAdmin && (
-
router.push('/create')} className={`w-full flex flex-row items-center cursor-pointer py-2 my-2 hover:bg-gray-700 rounded-lg ${isActive('/create') ? 'bg-gray-700' : ''}`}>
-
Create
-
- )}
-
session ? router.push('/profile?tab=subscribe') : router.push('/subscribe')} className={`w-full flex flex-row items-center cursor-pointer py-2 my-2 hover:bg-gray-700 rounded-lg ${isActive('/profile?tab=subscribe') || isActive('/subscribe') ? 'bg-gray-700' : ''}`}>
-
Subscribe
-
-
- )}
-
-
-
-
router.push('/profile?tab=settings')} className={`w-full flex flex-row items-center cursor-pointer py-2 my-2 hover:bg-gray-700 rounded-lg ${isActive('/profile?tab=settings') ? 'bg-gray-700' : ''}`}>
-
Settings
-
-
session ? signOut() : router.push('/auth/signin')} className={`w-full flex flex-row items-center cursor-pointer py-2 my-2 hover:bg-gray-700 rounded-lg ${isActive('/auth/signin') ? 'bg-gray-700' : ''}`}>
-
{session ? 'Logout' : 'Login'}
-
- {/* todo: have to add this extra button to push the sidebar to the right space but it doesnt seem to cause any negative side effects? */}
-
-
-
- );
-};
-
-export default Sidebar;
diff --git a/src/components/sidebar/sidebar.module.css b/src/components/sidebar/sidebar.module.css
deleted file mode 100644
index fa0da44..0000000
--- a/src/components/sidebar/sidebar.module.css
+++ /dev/null
@@ -1,13 +0,0 @@
-.p-accordion .p-accordion-content {
- border: none !important;
- padding-top: 0px !important;
- padding-bottom: 0px !important;
-}
-.p-accordion .p-accordion-header-link {
- border: none !important;
- padding-bottom: 12px !important;
- padding-top: 12px !important;
- margin-bottom: 8px !important;
- border-bottom-left-radius: 7px !important;
- border-bottom-right-radius: 7px !important;
-}
\ No newline at end of file
diff --git a/src/config/appConfig.js b/src/config/appConfig.js
index 784cb30..98cefb8 100644
--- a/src/config/appConfig.js
+++ b/src/config/appConfig.js
@@ -9,7 +9,7 @@ const appConfig = {
"wss://purplerelay.com/",
"wss://relay.devs.tools/"
],
- authorPubkeys: ["f33c8a9617cb15f705fc70cd461cfd6eaf22f9e24c33eabad981648e5ec6f741", "c67cd3e1a83daa56cff16f635db2fdb9ed9619300298d4701a58e68e84098345"],
+ authorPubkeys: ["f33c8a9617cb15f705fc70cd461cfd6eaf22f9e24c33eabad981648e5ec6f741", "c67cd3e1a83daa56cff16f635db2fdb9ed9619300298d4701a58e68e84098345", "6260f29fa75c91aaa292f082e5e87b438d2ab4fdf96af398567b01802ee2fcd4"],
customLightningAddresses: [
{
// todo remove need for lowercase
diff --git a/src/pages/_app.js b/src/pages/_app.js
index 496dc50..71cfd61 100644
--- a/src/pages/_app.js
+++ b/src/pages/_app.js
@@ -11,7 +11,6 @@ import 'primereact/resources/primereact.min.css';
import 'primeicons/primeicons.css';
import "@uiw/react-md-editor/markdown-editor.css";
import "@uiw/react-markdown-preview/markdown.css";
-import Sidebar from '@/components/sidebar/Sidebar';
import { useRouter } from 'next/router';
import { NDKProvider } from '@/context/NDKContext';
import { Analytics } from '@vercel/analytics/react';
@@ -26,13 +25,8 @@ const queryClient = new QueryClient()
export default function MyApp({
Component, pageProps: { session, ...pageProps }
}) {
- const [isCourseView, setIsCourseView] = useState(false);
const router = useRouter();
- useEffect(() => {
- setIsCourseView(router.pathname.includes('course') && !router.pathname.includes('draft'));
- }, [router.pathname]);
-
return (
@@ -42,13 +36,10 @@ export default function MyApp({
diff --git a/src/pages/about.js b/src/pages/about.js
index 715c6ff..bf47495 100644
--- a/src/pages/about.js
+++ b/src/pages/about.js
@@ -1,19 +1,169 @@
-import React from 'react';
+import React, { useState, useRef, useEffect } from 'react';
+import { useSession } from 'next-auth/react';
+import { useRouter } from 'next/router';
import Image from 'next/image';
import NostrIcon from '../../public/images/nostr.png';
import { Card } from 'primereact/card';
-import { Message } from 'primereact/message';
import { useToast } from "@/hooks/useToast";
import useWindowWidth from "@/hooks/useWindowWidth";
import GenericButton from '@/components/buttons/GenericButton';
import InteractivePromotionalCarousel from '@/components/content/carousels/InteractivePromotionalCarousel';
+import axios from 'axios';
+import { Menu } from "primereact/menu";
+import { ProgressSpinner } from 'primereact/progressspinner';
+import SubscriptionPaymentButtons from '@/components/bitcoinConnect/SubscriptionPaymentButton';
+import CalendlyEmbed from '@/components/profile/subscription/CalendlyEmbed';
+import CancelSubscription from '@/components/profile/subscription/CancelSubscription';
+import RenewSubscription from '@/components/profile/subscription/RenewSubscription';
+import Nip05Form from '@/components/profile/subscription/Nip05Form';
+import LightningAddressForm from '@/components/profile/subscription/LightningAddressForm';
+import MoreInfo from '@/components/MoreInfo';
const AboutPage = () => {
+ const { data: session, update } = useSession();
const { showToast } = useToast();
+ const router = useRouter();
const windowWidth = useWindowWidth();
+ const menu = useRef(null);
- const isTabView = windowWidth <= 1360;
- const isMobile = windowWidth < 768;
+ const [user, setUser] = useState(null);
+ const [isProcessing, setIsProcessing] = useState(false);
+ const [subscribed, setSubscribed] = useState(false);
+ const [subscribedUntil, setSubscribedUntil] = useState(null);
+ const [subscriptionExpiredAt, setSubscriptionExpiredAt] = useState(null);
+ const [calendlyVisible, setCalendlyVisible] = useState(false);
+ const [lightningAddressVisible, setLightningAddressVisible] = useState(false);
+ const [nip05Visible, setNip05Visible] = useState(false);
+ const [cancelSubscriptionVisible, setCancelSubscriptionVisible] = useState(false);
+ const [renewSubscriptionVisible, setRenewSubscriptionVisible] = useState(false);
+
+ const isTabView = windowWidth <= 1160;
+ const isMobile = windowWidth < 668;
+
+ // FAQ content for the modal
+ const faqContent = (
+
+
+
How does the subscription work?
+
Think of the subscriptions as a Patreon-type model. You pay a monthly fee and in return you get access to premium features and all of the paid content. You can cancel at any time.
+
+
+
What are the benefits of a subscription?
+
The subscription gives you access to all of the premium features and all of the paid content. You can cancel at any time.
+
+
+
How much does the subscription cost?
+
The subscription is 50,000 sats per month.
+
+
+
How do I Subscribe? (Pay as you go)
+
The pay as you go subscription is a one-time payment that gives you access to all of the premium features for one month. You will need to manually renew your subscription every month.
+
+
+
How do I Subscribe? (Recurring)
+
The recurring subscription option allows you to submit a Nostr Wallet Connect URI that will be used to automatically send the subscription fee every month. You can cancel at any time.
+
+
+
Can I cancel my subscription?
+
Yes, you can cancel your subscription at any time. Your access will remain active until the end of the current billing period.
+
+
+
What happens if I don't renew my subscription?
+
If you don't renew your subscription, your access to 1:1 calendar and paid content will be removed. However, you will still have access to your PlebDevs Lightning Address, NIP-05, and any content that you paid for.
+
+
+
What is Nostr Wallet Connect?
+
Nostr Wallet Connect is a Nostr-based authentication method that allows you to connect your Nostr wallet to the PlebDevs platform. This will allow you to subscribe to the platform in an auto recurring manner which still gives you full control over your wallet and the ability to cancel at any time from your wallet.
+
+
+ );
+
+ useEffect(() => {
+ if (session && session?.user) {
+ setUser(session.user);
+ }
+ }, [session])
+
+ useEffect(() => {
+ if (user && user.role) {
+ setSubscribed(user.role.subscribed);
+ const subscribedAt = new Date(user.role.lastPaymentAt);
+ const subscribedUntil = new Date(subscribedAt.getTime() + 31 * 24 * 60 * 60 * 1000);
+ setSubscribedUntil(subscribedUntil);
+ if (user.role.subscriptionExpiredAt) {
+ const expiredAt = new Date(user.role.subscriptionExpiredAt)
+ setSubscriptionExpiredAt(expiredAt);
+ }
+ }
+ }, [user]);
+
+ const handleSubscriptionSuccess = async (response) => {
+ setIsProcessing(true);
+ try {
+ const apiResponse = await axios.put('/api/users/subscription', {
+ userId: session.user.id,
+ isSubscribed: true,
+ });
+ if (apiResponse.data) {
+ await update();
+ showToast('success', 'Subscription Successful', 'Your subscription has been activated.');
+ } else {
+ throw new Error('Failed to update subscription status');
+ }
+ } catch (error) {
+ console.error('Subscription update error:', error);
+ showToast('error', 'Subscription Update Failed', `Error: ${error.message}`);
+ } finally {
+ setIsProcessing(false);
+ }
+ };
+
+ const handleSubscriptionError = (error) => {
+ console.error('Subscription error:', error);
+ showToast('error', 'Subscription Failed', `An error occurred: ${error.message}`);
+ setIsProcessing(false);
+ };
+
+ const handleRecurringSubscriptionSuccess = async () => {
+ setIsProcessing(true);
+ try {
+ await update();
+ showToast('success', 'Recurring Subscription Activated', 'Your recurring subscription has been set up successfully.');
+ } catch (error) {
+ console.error('Session update error:', error);
+ showToast('error', 'Session Update Failed', `Error: ${error.message}`);
+ } finally {
+ setIsProcessing(false);
+ }
+ };
+
+ const menuItems = [
+ {
+ label: "Schedule 1:1",
+ icon: "pi pi-calendar",
+ command: () => setCalendlyVisible(true),
+ },
+ {
+ label: session?.user?.platformLightningAddress ? "Update PlebDevs Lightning Address" : "Claim PlebDevs Lightning Address",
+ icon: "pi pi-bolt",
+ command: () => setLightningAddressVisible(true),
+ },
+ {
+ label: session?.user?.platformNip05?.name ? "Update PlebDevs Nostr NIP-05" : "Claim PlebDevs Nostr NIP-05",
+ icon: "pi pi-at",
+ command: () => setNip05Visible(true),
+ },
+ {
+ label: "Renew Subscription",
+ icon: "pi pi-sync",
+ command: () => setRenewSubscriptionVisible(true),
+ },
+ {
+ label: "Cancel Subscription",
+ icon: "pi pi-trash",
+ command: () => setCancelSubscriptionVisible(true),
+ },
+ ];
const copyToClipboard = async (text) => {
try {
@@ -32,9 +182,171 @@ const AboutPage = () => {
};
return (
-
+
-
+
+ {/* For non-logged in users */}
+ {!session?.user && (
+ <>
+
+
+ The PlebDevs subscription unlocks all paid content, grants access to our 1:1 calendar for tutoring, support, and mentorship, and grants you your own personal plebdevs.com Lightning Address and Nostr NIP-05 identity.
+
+
+ Subscribe monthly with a pay-as-you-go option or set up an auto-recurring subscription using Nostr Wallet Connect.
+
+
+
+ Login to start your subscription!
+ router.push('/auth/signin')} className='text-[#f8f8ff] w-fit' rounded icon="pi pi-user" />
+
+ >
+ )}
+
+ {/* Subscription Card */}
+
+ Subscribe to PlebDevs
+
+
+ }
+ >
+ {!isProcessing ? (
+
+ {/* Only show premium benefits when not subscribed or session doesn't exist */}
+ {(!session?.user || (session?.user && !subscribed)) && (
+ <>
+
+
Unlock Premium Benefits
+
Subscribe now and elevate your development journey!
+
+
+
+
+ Access ALL current and future PlebDevs content
+
+
+
+ Personal mentorship & guidance and access to exclusive 1:1 booking calendar
+
+
+
+ Claim your own personal plebdevs.com Lightning Address
+
+
+
+ Claim your own personal plebdevs.com Nostr NIP-05 identity
+
+
+ >
+ )}
+
+ {/* Status Messages */}
+ {session && session?.user ? (
+ <>
+ {subscribed && !user?.role?.nwc && (
+
+
+
+ Subscribed!
+
+
Thank you for your support 🎉
+
Pay-as-you-go subscription must be manually renewed on {subscribedUntil?.toLocaleDateString()}
+
+ )}
+ {subscribed && user?.role?.nwc && (
+
+
+
+ Subscribed!
+
+
Thank you for your support 🎉
+
Recurring subscription will AUTO renew on {subscribedUntil?.toLocaleDateString()}
+
+ )}
+ {(!subscribed && !subscriptionExpiredAt) && (
+
+
+
+ You currently have no active subscription
+
+
+ Subscribe below to unlock all premium features and content.
+
+
+ )}
+ {subscriptionExpiredAt && (
+
+
+
+ Your subscription expired on {subscriptionExpiredAt.toLocaleDateString()}
+
+
+ Renew below to continue enjoying all premium benefits.
+
+
+ )}
+ >
+ ) : (
+
+
+
+ Login to manage your subscription
+
+
+ Sign in to access subscription features and management.
+
+
+ )}
+
+
+ {/* Payment Buttons */}
+ {(!session?.user || (session?.user && !subscribed)) && (
+
+ )}
+
+ ) : (
+
+
+
Processing subscription...
+
+ )}
+
+
+ {/* Subscription Management */}
+ {session?.user && subscribed && (
+ <>
+
+
+ setCalendlyVisible(true)} />
+ setNip05Visible(true)} />
+ } onClick={() => setLightningAddressVisible(true)} />
+
+
+
+
+ setRenewSubscriptionVisible(true)} />
+ setCancelSubscriptionVisible(true)} />
+
+
+ >
+ )}
+
+
@@ -134,7 +446,7 @@ const AboutPage = () => {
-
+
{
/>
+
+ {/* Dialog Components */}
+ setCalendlyVisible(false)}
+ userId={session?.user?.id}
+ userName={session?.user?.username || user?.kind0?.username}
+ userEmail={session?.user?.email}
+ />
+ setCancelSubscriptionVisible(false)}
+ />
+ setRenewSubscriptionVisible(false)}
+ subscribedUntil={subscribedUntil}
+ />
+ setNip05Visible(false)}
+ />
+ setLightningAddressVisible(false)}
+ />
);
};
diff --git a/src/pages/content/index.js b/src/pages/content/index.js
index 661b868..3bcd077 100644
--- a/src/pages/content/index.js
+++ b/src/pages/content/index.js
@@ -58,9 +58,9 @@ const MenuTab = ({ items, selectedTopic, onTabChange }) => {
activeIndex={allItems.indexOf(selectedTopic)}
onTabChange={(e) => onTabChange(allItems[e.index])}
pt={{
- menu: { className: 'bg-transparent border-none ml-2 my-4 py-1' },
+ menu: { className: 'bg-transparent border-none my-2 py-1' },
action: ({ context, parent }) => ({
- className: 'cursor-pointer select-none flex items-center relative no-underline overflow-hidden border-b-2 p-2 font-bold rounded-t-lg',
+ className: 'cursor-pointer select-none flex items-center relative no-underline overflow-hidden border-b-2 p-2 pl-1 font-bold rounded-t-lg',
style: { top: '2px' }
}),
menuitem: { className: 'mr-0' }
@@ -156,7 +156,7 @@ const ContentPage = () => {
router.push(`/content${queryParam}`, undefined, { shallow: true });
filterContent(newTopic, allContent);
};
-
+
const renderCarousels = () => {
return (
{
};
return (
-
-
-
All Content
+
+
+
All Content
{renderCarousels()}
diff --git a/src/pages/feed.js b/src/pages/feed.js
index 59f2ab7..44da461 100644
--- a/src/pages/feed.js
+++ b/src/pages/feed.js
@@ -1,6 +1,5 @@
import React, { useState, useEffect } from 'react';
import Image from 'next/image';
-import { InputText } from 'primereact/inputtext';
import CommunityMenuTab from '@/components/menutab/CommunityMenuTab';
import NostrFeed from '@/components/feeds/NostrFeed';
import DiscordFeed from '@/components/feeds/DiscordFeed';
@@ -17,7 +16,6 @@ const Feed = () => {
const [selectedTopic, setSelectedTopic] = useState('global');
const [title, setTitle] = useState('Community');
const allTopics = ['global', 'nostr', 'discord', 'stackernews'];
- const [searchQuery, setSearchQuery] = useState('');
const router = useRouter();
@@ -30,7 +28,6 @@ const Feed = () => {
setTitle(router.query.channel);
}, [router.query.channel]);
- // initialize the selected topic to the query parameter
useEffect(() => {
setSelectedTopic(router.query.channel);
}, [router.query.channel]);
@@ -51,57 +48,44 @@ const Feed = () => {
};
return (
-
-
-
-
Feeds
-
-
-
-
setSearchQuery(e.target.value)}
- placeholder="Search"
- icon="pi pi-search"
- className="w-fit"
- />
+
+
+
+
+
Feeds
+
+
+ {selectedTopic === 'nostr' && (
+
+
+
+ )}
- {selectedTopic === 'nostr' && (
-
- )}
- {
- selectedTopic === 'global' &&
- }
- {
- selectedTopic === 'nostr' &&
- }
- {
- selectedTopic === 'discord' &&
- }
- {
- selectedTopic === 'stackernews' &&
- }
+
+ {selectedTopic === 'global' && }
+ {selectedTopic === 'nostr' && }
+ {selectedTopic === 'discord' && }
+ {selectedTopic === 'stackernews' && }
+
);
};
diff --git a/src/pages/index.js b/src/pages/index.js
index 573f1e2..9630c00 100644
--- a/src/pages/index.js
+++ b/src/pages/index.js
@@ -1,25 +1,191 @@
import Head from 'next/head';
-import React from 'react';
+import React, { useEffect, useState } from 'react';
import CoursesCarousel from '@/components/content/carousels/CoursesCarousel';
import VideosCarousel from '@/components/content/carousels/VideosCarousel';
import DocumentsCarousel from '@/components/content/carousels/DocumentsCarousel';
+import { parseEvent, parseCourseEvent } from '@/utils/nostr';
+import { useDocuments } from '@/hooks/nostr/useDocuments';
+import { useVideos } from '@/hooks/nostr/useVideos';
+import { useCourses } from '@/hooks/nostr/useCourses';
+import { TabMenu } from 'primereact/tabmenu';
+import 'primeicons/primeicons.css';
+import GenericButton from '@/components/buttons/GenericButton';
+import { useRouter } from 'next/router';
import HeroBanner from '@/components/banner/HeroBanner';
+const MenuTab = ({ selectedTopic, onTabChange, allTopics }) => {
+ const router = useRouter();
+
+ // Define the hardcoded priority items that always appear first
+ const priorityItems = ['Top', 'Courses', 'Videos', 'Documents', 'Free', 'Paid'];
+ // Items that should be filtered out from topics
+ const blacklistedItems = ['document', 'video', 'course'];
+
+ // Get dynamic topics, excluding hardcoded and blacklisted items
+ const otherItems = allTopics.filter(item =>
+ !priorityItems.includes(item) &&
+ !blacklistedItems.includes(item)
+ );
+
+ // Only take the first 4 dynamic topics to keep the menu clean
+ // Additional topics will be accessible through the "More" page
+ const limitedOtherItems = otherItems.slice(0, 8);
+
+ // Combine all menu items: priority items + up to 4 dynamic topics + More
+ const allItems = [...priorityItems, ...limitedOtherItems, 'More'];
+
+ const menuItems = allItems.map((item) => {
+ let icon = 'pi pi-tag';
+ if (item === 'Top') icon = 'pi pi-star';
+ else if (item === 'Documents') icon = 'pi pi-file';
+ else if (item === 'Videos') icon = 'pi pi-video';
+ else if (item === 'Courses') icon = 'pi pi-desktop';
+ else if (item === 'Free') icon = 'pi pi-lock-open';
+ else if (item === 'Paid') icon = 'pi pi-lock';
+ else if (item === 'More') icon = 'pi pi-ellipsis-h';
+
+ const isMore = item === 'More';
+ const path = isMore ? '/content?tag=all' : item === 'Top' ? '/' : `/content?tag=${item.toLowerCase()}`;
+
+ return {
+ label: (
+ {
+ onTabChange(item);
+ router.push(path);
+ }}
+ outlined={selectedTopic !== item}
+ rounded
+ size='small'
+ label={item}
+ icon={icon}
+ />
+ ),
+ command: () => {
+ onTabChange(item);
+ router.push(path);
+ }
+ };
+ });
+
+ return (
+
+ onTabChange(allItems[e.index])}
+ pt={{
+ menu: { className: 'bg-transparent border-none my-2 mb-4' },
+ action: ({ context, parent }) => ({
+ className: 'cursor-pointer select-none flex items-center relative no-underline overflow-hidden border-b-2 p-2 font-bold rounded-t-lg',
+ style: { top: '2px' }
+ }),
+ menuitem: { className: 'mr-0' }
+ }}
+ />
+
+ );
+}
+
export default function Home() {
- return (
- <>
-
- PlebDevs
-
-
-
-
-
-
-
-
-
-
- >
- );
+ const router = useRouter();
+ const { documents, documentsLoading } = useDocuments();
+ const { videos, videosLoading } = useVideos();
+ const { courses, coursesLoading } = useCourses();
+
+ const [processedDocuments, setProcessedDocuments] = useState([]);
+ const [processedVideos, setProcessedVideos] = useState([]);
+ const [processedCourses, setProcessedCourses] = useState([]);
+ const [allContent, setAllContent] = useState([]);
+ const [allTopics, setAllTopics] = useState([]);
+ const [selectedTopic, setSelectedTopic] = useState('Top');
+
+ useEffect(() => {
+ if (documents && !documentsLoading) {
+ const processedDocuments = documents.map(document => ({...parseEvent(document), type: 'document'}));
+ setProcessedDocuments(processedDocuments);
+ }
+ }, [documents, documentsLoading]);
+
+ useEffect(() => {
+ if (videos && !videosLoading) {
+ const processedVideos = videos.map(video => ({...parseEvent(video), type: 'video'}));
+ setProcessedVideos(processedVideos);
+ }
+ }, [videos, videosLoading]);
+
+ useEffect(() => {
+ if (courses && !coursesLoading) {
+ const processedCourses = courses.map(course => ({...parseCourseEvent(course), type: 'course'}));
+ setProcessedCourses(processedCourses);
+ }
+ }, [courses, coursesLoading]);
+
+ useEffect(() => {
+ const allContent = [...processedDocuments, ...processedVideos, ...processedCourses];
+ setAllContent(allContent);
+
+ const uniqueTopics = new Set(allContent.map(item => item.topics).flat());
+ const otherTopics = Array.from(uniqueTopics).filter(topic =>
+ !['Top', 'Courses', 'Videos', 'Documents', 'Free', 'Paid', 'More'].includes(topic) &&
+ !['document', 'video', 'course'].includes(topic)
+ );
+ setAllTopics(otherTopics);
+
+ filterContent(selectedTopic, allContent);
+ }, [processedDocuments, processedVideos, processedCourses, selectedTopic]);
+
+ const filterContent = (topic, content) => {
+ let filtered = content;
+ if (topic !== 'Top' && topic !== 'More') {
+ const topicLower = topic.toLowerCase();
+ if (['courses', 'videos', 'documents'].includes(topicLower)) {
+ filtered = content.filter(item => item.type === topicLower.slice(0, -1));
+ } else if (topicLower === 'free') {
+ filtered = content.filter(item => !item.price || Number(item.price) === 0);
+ } else if (topicLower === 'paid') {
+ filtered = content.filter(item => item.price && Number(item.price) > 0);
+ } else {
+ filtered = content.filter(item => item.topics && item.topics.includes(topicLower));
+ }
+ }
+ };
+
+ const handleTopicChange = (newTopic) => {
+ setSelectedTopic(newTopic);
+ if (newTopic === 'More') {
+ router.push('/content?tag=all');
+ } else {
+ filterContent(newTopic, allContent);
+ router.push(newTopic === 'Top' ? '/' : `/content?tag=${newTopic.toLowerCase()}`);
+ }
+ };
+
+ return (
+ <>
+
+ PlebDevs
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ );
}
\ No newline at end of file
diff --git a/src/pages/profile.js b/src/pages/profile.js
index 182f860..935f457 100644
--- a/src/pages/profile.js
+++ b/src/pages/profile.js
@@ -1,7 +1,6 @@
import React, { useState, useEffect } from "react";
import { TabView, TabPanel } from "primereact/tabview";
import UserProfile from "@/components/profile/UserProfile";
-import UserSettings from "@/components/profile/UserSettings";
import UserContent from "@/components/profile/UserContent";
import UserSubscription from "@/components/profile/subscription/UserSubscription";
import { useRouter } from "next/router";
@@ -9,13 +8,14 @@ import { useSession } from "next-auth/react";
import { useIsAdmin } from "@/hooks/useIsAdmin";
import { ProgressSpinner } from "primereact/progressspinner";
+//todo: Link below connect wallet, relays hidden in ... (open modal)
const Profile = () => {
const router = useRouter();
const { data: session, status } = useSession();
const [activeTab, setActiveTab] = useState(0);
const {isAdmin, isLoading} = useIsAdmin();
- const tabs = ["profile", "settings", "content", "subscribe"];
+ const tabs = ["profile", "content", "subscribe"];
useEffect(() => {
const { tab } = router.query;
@@ -53,7 +53,7 @@ const Profile = () => {
if (!session) return null;
return (
-
+
{
}}>
-
-
-
{isAdmin && (
{
- const { data: session, update } = useSession();
- const { showToast } = useToast();
- const router = useRouter();
- const menu = useRef(null);
- const windowWidth = useWindowWidth();
- const [user, setUser] = useState(null);
- const [isProcessing, setIsProcessing] = useState(false);
- const [subscribed, setSubscribed] = useState(false);
- const [subscribedUntil, setSubscribedUntil] = useState(null);
- const [subscriptionExpiredAt, setSubscriptionExpiredAt] = useState(null);
- const [calendlyVisible, setCalendlyVisible] = useState(false);
- const [lightningAddressVisible, setLightningAddressVisible] = useState(false);
- const [nip05Visible, setNip05Visible] = useState(false);
- const [cancelSubscriptionVisible, setCancelSubscriptionVisible] = useState(false);
- const [renewSubscriptionVisible, setRenewSubscriptionVisible] = useState(false);
-
- useEffect(() => {
- if (session && session?.user) {
- setUser(session.user);
- }
- }, [session])
-
- useEffect(() => {
- if (user && user.role) {
- setSubscribed(user.role.subscribed);
- const subscribedAt = new Date(user.role.lastPaymentAt);
- const subscribedUntil = new Date(subscribedAt.getTime() + 31 * 24 * 60 * 60 * 1000);
- setSubscribedUntil(subscribedUntil);
- if (user.role.subscriptionExpiredAt) {
- const expiredAt = new Date(user.role.subscriptionExpiredAt)
- setSubscriptionExpiredAt(expiredAt);
- }
- }
- }, [user]);
-
- const handleSubscriptionSuccess = async (response) => {
- setIsProcessing(true);
- try {
- const apiResponse = await axios.put('/api/users/subscription', {
- userId: session.user.id,
- isSubscribed: true,
- });
- if (apiResponse.data) {
- await update();
- showToast('success', 'Subscription Successful', 'Your subscription has been activated.');
- } else {
- throw new Error('Failed to update subscription status');
- }
- } catch (error) {
- console.error('Subscription update error:', error);
- showToast('error', 'Subscription Update Failed', `Error: ${error.message}`);
- } finally {
- setIsProcessing(false);
- }
- };
-
- const handleSubscriptionError = (error) => {
- console.error('Subscription error:', error);
- showToast('error', 'Subscription Failed', `An error occurred: ${error.message}`);
- setIsProcessing(false);
- };
-
- const handleRecurringSubscriptionSuccess = async () => {
- setIsProcessing(true);
- try {
- await update();
- showToast('success', 'Recurring Subscription Activated', 'Your recurring subscription has been set up successfully.');
- } catch (error) {
- console.error('Session update error:', error);
- showToast('error', 'Session Update Failed', `Error: ${error.message}`);
- } finally {
- setIsProcessing(false);
- }
- };
-
- const menuItems = [
- {
- label: "Schedule 1:1",
- icon: "pi pi-calendar",
- command: () => setCalendlyVisible(true),
- },
- {
- label: session?.user?.platformLightningAddress ? "Update PlebDevs Lightning Address" : "Claim PlebDevs Lightning Address",
- icon: "pi pi-bolt",
- command: () => setLightningAddressVisible(true),
- },
- {
- label: session?.user?.platformNip05?.name ? "Update PlebDevs Nostr NIP-05" : "Claim PlebDevs Nostr NIP-05",
- icon: "pi pi-at",
- command: () => setNip05Visible(true),
- },
- {
- label: "Renew Subscription",
- icon: "pi pi-sync",
- command: () => setRenewSubscriptionVisible(true),
- },
- {
- label: "Cancel Subscription",
- icon: "pi pi-trash",
- command: () => setCancelSubscriptionVisible(true),
- },
- ];
-
- return (
-
- {windowWidth < 768 && (
-
Subscription Management
- )}
-
- {session && session?.user ? (
- <>
- {subscribed && !user?.role?.nwc && (
-
-
-
Thank you for your support 🎉
-
Pay-as-you-go subscription must be manually renewed on {subscribedUntil.toLocaleDateString()}
-
- )}
- {subscribed && user?.role?.nwc && (
-
-
-
Thank you for your support 🎉
-
Recurring subscription will AUTO renew on {subscribedUntil.toLocaleDateString()}
-
- )}
- {(!subscribed && !subscriptionExpiredAt) && (
-
-
-
- )}
- {subscriptionExpiredAt && (
-
-
-
- )}
- >
- ) : (
-
-
-
- )}
-
-
- {!session?.user && (
- <>
-
-
- The PlebDevs subscription unlocks all paid content, grants access to our 1:1 calendar for tutoring, support, and mentorship, and grants you your own personal plebdevs.com Lightning Address and Nostr NIP-05 identity.
-
-
- Subscribe monthly with a pay-as-you-go option or set up an auto-recurring subscription using Nostr Wallet Connect.
-
-
-
- Login to start your subscription!
- router.push('/auth/signin')} className='text-[#f8f8ff] w-fit' rounded icon="pi pi-user" />
-
- >
- )}
-
-
- {isProcessing ? (
-
-
-
Processing subscription...
-
- ) : (
-
-
-
Unlock Premium Benefits
-
Subscribe now and elevate your development journey!
-
-
-
-
- Access ALL current and future PlebDevs content
-
-
-
- Personal mentorship & guidance and access to exclusive 1:1 booking calendar
-
-
-
- Claim your own personal plebdevs.com Lightning Address
-
-
-
- Claim your own personal plebdevs.com Nostr NIP-05 identity
-
-
-
-
- )}
-
-
- {session?.user && subscribed && (
- <>
-
-
- setCalendlyVisible(true)} />
- setNip05Visible(true)} />
- } onClick={() => setLightningAddressVisible(true)} />
-
-
-
-
- setRenewSubscriptionVisible(true)} />
- setCancelSubscriptionVisible(true)} />
-
-
- >
- )}
-
-
-
-
-
How does the subscription work?
-
Think of the subscriptions as a Patreon-type model. You pay a monthly fee and in return you get access to premium features and all of the paid content. You can cancel at any time.
-
-
-
What are the benefits of a subscription?
-
The subscription gives you access to all of the premium features and all of the paid content. You can cancel at any time.
-
-
-
How much does the subscription cost?
-
The subscription is 50,000 sats per month.
-
-
-
How do I Subscribe? (Pay as you go)
-
The pay as you go subscription is a one-time payment that gives you access to all of the premium features for one month. You will need to manually renew your subscription every month.
-
-
-
How do I Subscribe? (Recurring)
-
The recurring subscription option allows you to submit a Nostr Wallet Connect URI that will be used to automatically send the subscription fee every month. You can cancel at any time.
-
-
-
Can I cancel my subscription?
-
Yes, you can cancel your subscription at any time. Your access will remain active until the end of the current billing period.
-
-
-
What happens if I don't renew my subscription?
-
If you don't renew your subscription, your access to 1:1 calendar and paid content will be removed. However, you will still have access to your PlebDevs Lightning Address, NIP-05, and any content that you paid for.
-
-
-
What is Nostr Wallet Connect?
-
Nostr Wallet Connect is a Nostr-based authentication method that allows you to connect your Nostr wallet to the PlebDevs platform. This will allow you to subscribe to the platform in an auto recurring manner which still gives you full control over your wallet and the ability to cancel at any time from your wallet.
-
-
-
-
-
setCalendlyVisible(false)}
- userId={session?.user?.id}
- userName={session?.user?.username || user?.kind0?.username}
- userEmail={session?.user?.email}
- />
- setCancelSubscriptionVisible(false)}
- />
- setRenewSubscriptionVisible(false)}
- subscribedUntil={subscribedUntil}
- />
- setNip05Visible(false)}
- />
- setLightningAddressVisible(false)}
- />
-
- );
-};
-
-export default Subscribe;