mirror of
https://github.com/AustinKelsay/plebdevs.git
synced 2025-04-19 10:51:20 +00:00
Fixed search experience for desktop and mobile, replaced hamburber with search icon on mobile
This commit is contained in:
parent
d3092f56af
commit
ccaca7b23e
@ -1,47 +1,81 @@
|
||||
import React, {useEffect} from "react";
|
||||
import React from "react";
|
||||
import Image from "next/image";
|
||||
import { useImageProxy } from "@/hooks/useImageProxy";
|
||||
import { formatUnixTimestamp } from "@/utils/time";
|
||||
import { Tag } from "primereact/tag";
|
||||
import { Message } from "primereact/message";
|
||||
import GenericButton from "@/components/buttons/GenericButton";
|
||||
import useWindowWidth from "@/hooks/useWindowWidth";
|
||||
import { BookOpen } from "lucide-react";
|
||||
|
||||
const ContentDropdownItem = ({ content, onSelect }) => {
|
||||
const { returnImageProxy } = useImageProxy();
|
||||
const windowWidth = useWindowWidth();
|
||||
const isMobile = windowWidth <= 600;
|
||||
|
||||
return (
|
||||
<div className="w-full border-t-2 border-gray-700 py-4">
|
||||
<div className="flex flex-row gap-4 p-2">
|
||||
<Image
|
||||
alt="content thumbnail"
|
||||
src={returnImageProxy(content?.image)}
|
||||
width={50}
|
||||
height={50}
|
||||
className="w-[100px] h-[100px] object-cover object-center border-round"
|
||||
/>
|
||||
<div className="flex-1 max-w-[80vw]">
|
||||
<div className="text-lg text-900 font-bold">{content?.title || content?.name}</div>
|
||||
<div className="w-full text-sm text-600 text-wrap line-clamp-2">{content?.summary || content?.description && (
|
||||
<div className="text-xl mt-4">
|
||||
{content?.summary?.split('\n').map((line, index) => (
|
||||
<p key={index}>{line}</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{content?.price && <div className="text-sm pt-6 text-gray-500">Price: {content.price}</div>}
|
||||
{content?.topics?.length > 0 && (
|
||||
<div className="text-sm pt-6 text-gray-500">
|
||||
{content.topics.map((topic) => (
|
||||
<Tag key={topic} value={topic} size="small" className="mr-2 text-[#f8f8ff]" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-sm pt-6 text-gray-500">
|
||||
{(content?.published_at || content?.created_at) ? `Published: ${formatUnixTimestamp(content?.published_at || content?.created_at)}` : "not yet published"}
|
||||
<div
|
||||
className="px-6 py-6 border-b border-gray-700 cursor-pointer"
|
||||
onClick={() => onSelect(content)}
|
||||
>
|
||||
<div className={`flex ${isMobile ? 'flex-col' : 'flex-row'} gap-4`}>
|
||||
<div className={`relative ${isMobile ? 'w-full h-40' : 'w-[160px] h-[90px]'} flex-shrink-0 overflow-hidden rounded-md`}>
|
||||
<Image
|
||||
alt="content thumbnail"
|
||||
src={returnImageProxy(content?.image)}
|
||||
width={isMobile ? 600 : 160}
|
||||
height={isMobile ? 240 : 90}
|
||||
className="w-full h-full object-cover object-center"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-primary/80 to-primary-foreground/50 opacity-60" />
|
||||
<div className="absolute bottom-2 left-2 flex gap-2">
|
||||
<BookOpen className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col justify-end">
|
||||
<GenericButton outlined size="small" label="Select" onClick={() => onSelect(content)} className="py-2" />
|
||||
|
||||
<div className="flex-1">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<h3 className="text-xl font-bold text-[#f8f8ff]">{content?.title || content?.name}</h3>
|
||||
|
||||
{content?.price > 0 ? (
|
||||
<Message severity="info" text={`${content.price} sats`} className="py-1 text-xs whitespace-nowrap" />
|
||||
) : (
|
||||
<Message severity="success" text="Free" className="py-1 text-xs whitespace-nowrap" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{content?.summary && (
|
||||
<p className="text-neutral-50/90 line-clamp-2 mb-3 text-sm">{content.summary}</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{content?.topics?.map((topic) => (
|
||||
<Tag key={topic} value={topic} className="px-2 py-1 text-sm text-[#f8f8ff]" />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center mt-3">
|
||||
<div className="text-sm text-gray-300">
|
||||
{(content?.published_at || content?.created_at)
|
||||
? `Published: ${formatUnixTimestamp(content?.published_at || content?.created_at)}`
|
||||
: "Not yet published"}
|
||||
</div>
|
||||
|
||||
{!isMobile && (
|
||||
<GenericButton
|
||||
outlined
|
||||
size="small"
|
||||
label="Start Learning"
|
||||
icon="pi pi-chevron-right"
|
||||
iconPos="right"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSelect(content);
|
||||
}}
|
||||
className="items-center py-1"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -1,87 +1,231 @@
|
||||
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 - profile already contains the formatted data
|
||||
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") {
|
||||
// Format Discord message
|
||||
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") {
|
||||
// Format StackerNews message
|
||||
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 <Image src={NostrIcon} alt="Nostr" width={16} height={16} className="mr-1" />;
|
||||
case 'discord':
|
||||
return <i className="pi pi-discord mr-1" />;
|
||||
case 'stackernews':
|
||||
return <i className="pi pi-bolt mr-1" />;
|
||||
default:
|
||||
return <i className="pi pi-globe mr-1" />;
|
||||
}
|
||||
}, [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 (
|
||||
<div className="flex flex-col">
|
||||
<div className="flex gap-3">
|
||||
<div className="w-10 h-10 rounded-full overflow-hidden bg-gray-700 flex-shrink-0 mt-1">
|
||||
{avatarUrl ? (
|
||||
<Image
|
||||
src={avatarUrl}
|
||||
alt="avatar"
|
||||
width={40}
|
||||
height={40}
|
||||
className="object-cover w-full h-full"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<i className="pi pi-user text-gray-400 text-xl" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="font-medium text-[#f8f8ff]">
|
||||
{authorName}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">
|
||||
{messageDate}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-neutral-50/90 whitespace-pre-wrap mb-3 line-clamp-3">{messageData.content}</p>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<div className="px-3 py-1 bg-blue-500 text-white rounded-full text-sm flex items-center">
|
||||
{getPlatformIcon()}
|
||||
{platform}
|
||||
</div>
|
||||
<div className="px-3 py-1 bg-gray-700 text-white rounded-full text-sm">
|
||||
plebdevs
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}, [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 (
|
||||
<div className="w-full border-t-2 border-gray-700 py-4">
|
||||
<div
|
||||
className="px-6 py-6 border-b border-gray-700 cursor-pointer"
|
||||
onClick={() => !loading && onSelect(messageData || messageRef)}
|
||||
>
|
||||
{loading ? (
|
||||
<div className='w-full h-full flex items-center justify-center'><ProgressSpinner /></div>
|
||||
<div className='w-full h-[100px] flex items-center justify-center'>
|
||||
<ProgressSpinner style={{ width: '40px', height: '40px' }} strokeWidth="4" />
|
||||
</div>
|
||||
) : (
|
||||
<CommunityMessage message={messageWithAuthor ? messageWithAuthor : message} platform={platform} />
|
||||
isMobile ? renderSimplifiedMessage() :
|
||||
<CommunityMessage
|
||||
message={finalMessage}
|
||||
platform={platform}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
@ -1,4 +1,4 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import Image from 'next/image';
|
||||
import UserAvatar from './user/UserAvatar';
|
||||
import { Menubar } from 'primereact/menubar';
|
||||
@ -10,14 +10,36 @@ import 'primeicons/primeicons.css';
|
||||
import { useNDKContext } from '@/context/NDKContext';
|
||||
import useWindowWidth from '@/hooks/useWindowWidth';
|
||||
|
||||
// todo: Dropdown on left becomes search bar on mobile (also about and subscribe are linked in user avatart dropdown on mobile)
|
||||
// MAYBE: Add a 4th button at the bottom that contains both about and subscribe
|
||||
const Navbar = () => {
|
||||
const router = useRouter();
|
||||
const windowWidth = useWindowWidth();
|
||||
const navbarHeight = '60px';
|
||||
const {ndk} = useNDKContext();
|
||||
const { ndk } = useNDKContext();
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [showMobileSearch, setShowMobileSearch] = useState(false);
|
||||
const menu = useRef(null);
|
||||
|
||||
// Debug Navbar mounting
|
||||
useEffect(() => {
|
||||
console.log("Navbar mounted, windowWidth:", windowWidth);
|
||||
}, [windowWidth]);
|
||||
|
||||
// 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',
|
||||
@ -41,54 +63,88 @@ const Navbar = () => {
|
||||
}
|
||||
];
|
||||
|
||||
const start = (
|
||||
<div className='flex items-center'>
|
||||
<div onClick={() => router.push('/')} className="flex flex-row items-center justify-center cursor-pointer">
|
||||
<Image
|
||||
alt="logo"
|
||||
src="/images/plebdevs-icon.png"
|
||||
width={50}
|
||||
height={50}
|
||||
className="rounded-full max-tab:hidden max-mob:hidden"
|
||||
/>
|
||||
<h1 className="text-white text-xl font-semibold max-tab:text-2xl max-mob:text-2xl pb-1 pl-2">PlebDevs</h1>
|
||||
</div>
|
||||
<div
|
||||
className={`ml-2 p-2 cursor-pointer transition-all duration-300 flex items-center justify-center ${isHovered ? 'bg-gray-700 rounded-full' : ''}`}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
onClick={(e) => menu.current.toggle(e)}
|
||||
style={{ width: '40px', height: '40px' }}
|
||||
>
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
{/* Show hamburger menu on mobile (< 600px) and up/down arrows on larger screens */}
|
||||
{windowWidth <= 600 ? (
|
||||
<i className="pi pi-bars text-white text-xl" />
|
||||
) : (
|
||||
<>
|
||||
<i className="pi pi-angle-up text-white text-base" />
|
||||
<i className="pi pi-angle-down text-white text-base" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Menu model={menuItems} popup ref={menu} />
|
||||
{ndk && windowWidth > 600 && <SearchBar />}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='w-[100vw] h-fit z-20'>
|
||||
<Menubar
|
||||
start={start}
|
||||
end={UserAvatar}
|
||||
className='px-10 py-8 bg-gray-800 border-t-0 border-l-0 border-r-0 rounded-none fixed z-10 w-[100vw] max-tab:px-[5%] max-mob:px-[5%]'
|
||||
style={{ height: navbarHeight }}
|
||||
/>
|
||||
<div className='px-10 py-8 bg-gray-800 border-t-0 border-l-0 border-r-0 rounded-none fixed z-10 w-[100vw] max-tab:px-[5%] max-mob:px-[5%] flex justify-between' style={{ height: navbarHeight }}>
|
||||
{/* Left section */}
|
||||
<div className='flex items-center flex-1'>
|
||||
<div onClick={() => router.push('/')} className="flex flex-row items-center justify-center cursor-pointer">
|
||||
<Image
|
||||
alt="logo"
|
||||
src="/images/plebdevs-icon.png"
|
||||
width={50}
|
||||
height={50}
|
||||
className="rounded-full max-tab:hidden max-mob:hidden"
|
||||
/>
|
||||
<h1 className="text-white text-xl font-semibold max-tab:text-2xl max-mob:text-2xl pb-1 pl-2">PlebDevs</h1>
|
||||
</div>
|
||||
{windowWidth > 600 ? (
|
||||
<div
|
||||
className={`ml-2 p-2 cursor-pointer transition-all duration-300 flex items-center justify-center ${isHovered ? 'bg-gray-700 rounded-full' : ''}`}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
onClick={(e) => menu.current.toggle(e)}
|
||||
style={{ width: '40px', height: '40px' }}
|
||||
>
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<i className="pi pi-angle-up text-white text-base" />
|
||||
<i className="pi pi-angle-down text-white text-base" />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="ml-2 p-2 cursor-pointer transition-all duration-300 flex items-center justify-center hover:bg-gray-700 rounded-full"
|
||||
onClick={() => setShowMobileSearch(!showMobileSearch)}
|
||||
style={{ width: '40px', height: '40px' }}
|
||||
>
|
||||
<i className="pi pi-search text-white text-xl" />
|
||||
</div>
|
||||
)}
|
||||
<Menu model={menuItems} popup ref={menu} />
|
||||
</div>
|
||||
|
||||
{/* Center section - Search */}
|
||||
{windowWidth > 600 && (
|
||||
<div className="flex items-center justify-center flex-1">
|
||||
<SearchBar isDesktopNav={true} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Right section - User Avatar */}
|
||||
<div className="flex items-center justify-end flex-1">
|
||||
<UserAvatar />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Placeholder div with the same height as the Navbar */}
|
||||
<div style={{ height: navbarHeight }}></div>
|
||||
|
||||
{/* Mobile Search Overlay */}
|
||||
{showMobileSearch && windowWidth <= 600 && (
|
||||
<div className="fixed inset-0 bg-gray-900 z-50 overflow-hidden navbar-mobile-search">
|
||||
<div className="h-full">
|
||||
<div className="sticky top-0 z-10 bg-gray-900">
|
||||
<div className="px-6 py-4 flex items-center justify-between border-b border-gray-700">
|
||||
<h2 className="text-white text-2xl font-semibold">Search</h2>
|
||||
<button
|
||||
onClick={() => setShowMobileSearch(false)}
|
||||
className="text-white hover:text-gray-300 p-2"
|
||||
>
|
||||
<i className="pi pi-times text-2xl" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="px-6 pt-4 pb-2">
|
||||
<SearchBar
|
||||
isMobileSearch={true}
|
||||
onCloseSearch={() => setShowMobileSearch(false)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@ -1,7 +1,5 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { InputText } from 'primereact/inputtext';
|
||||
import { InputIcon } from 'primereact/inputicon';
|
||||
import { IconField } from 'primereact/iconfield';
|
||||
import { Dropdown } from 'primereact/dropdown';
|
||||
import { OverlayPanel } from 'primereact/overlaypanel';
|
||||
import ContentDropdownItem from '@/components/content/dropdowns/ContentDropdownItem';
|
||||
@ -10,8 +8,9 @@ import { useContentSearch } from '@/hooks/useContentSearch';
|
||||
import { useCommunitySearch } from '@/hooks/useCommunitySearch';
|
||||
import { useRouter } from 'next/router';
|
||||
import useWindowWidth from '@/hooks/useWindowWidth';
|
||||
import { useNDKContext } from "@/context/NDKContext";
|
||||
|
||||
const SearchBar = () => {
|
||||
const SearchBar = ({ isMobileSearch, isDesktopNav, onCloseSearch }) => {
|
||||
const { searchContent, searchResults: contentResults } = useContentSearch();
|
||||
const { searchCommunity, searchResults: communityResults } = useCommunitySearch();
|
||||
const router = useRouter();
|
||||
@ -24,8 +23,23 @@ const SearchBar = () => {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [searchResults, setSearchResults] = useState([]);
|
||||
const op = useRef(null);
|
||||
const { ndk, reInitializeNDK } = useNDKContext();
|
||||
|
||||
// Debug effect to check component mounting and props
|
||||
useEffect(() => {
|
||||
console.log("SearchBar mounted with props:", { isMobileSearch, isDesktopNav });
|
||||
}, [isMobileSearch, isDesktopNav]);
|
||||
|
||||
const selectedOptionTemplate = (option, props) => {
|
||||
if (isDesktopNav) {
|
||||
// For desktop nav bar, just show the icon
|
||||
return (
|
||||
<div className="flex items-center justify-center">
|
||||
<i className={option.icon + " text-white text-lg"} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!props?.placeholder) {
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
@ -44,14 +58,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();
|
||||
}
|
||||
};
|
||||
@ -64,80 +78,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?.d || 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 <div className="p-4 text-center text-gray-400">No results found</div>;
|
||||
}
|
||||
|
||||
return searchResults.map((item, index) => (
|
||||
item.type === 'discord' || item.type === 'nostr' || item.type === 'stackernews' ? (
|
||||
<MessageDropdownItem
|
||||
key={index}
|
||||
message={item}
|
||||
onSelect={handleContentSelect}
|
||||
/>
|
||||
) : (
|
||||
<ContentDropdownItem
|
||||
key={index}
|
||||
content={item}
|
||||
onSelect={handleContentSelect}
|
||||
/>
|
||||
)
|
||||
));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`absolute ${windowWidth < 950 ? "left-[55%]" : "left-[50%]"} transform -translate-x-[50%]`}>
|
||||
<IconField iconPosition="left">
|
||||
<InputIcon className="pi pi-search"> </InputIcon>
|
||||
<InputText
|
||||
className={`${windowWidth > 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'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<Dropdown
|
||||
pt={{
|
||||
root: {
|
||||
className: 'border-none rounded-tl-none rounded-bl-none bg-gray-900/55 hover:bg-gray-900/30'
|
||||
},
|
||||
input: {
|
||||
className: 'mx-0 px-0 shadow-lg'
|
||||
}
|
||||
}}
|
||||
value={selectedSearchOption}
|
||||
onChange={(e) => setSelectedSearchOption(e.value)}
|
||||
options={searchOptions}
|
||||
optionLabel="name"
|
||||
placeholder="Search"
|
||||
dropdownIcon={
|
||||
<div className='w-full pr-2 flex flex-row items-center justify-between'>
|
||||
<i className={selectedSearchOption.icon + " text-white"} />
|
||||
<i className="pi pi-chevron-down" />
|
||||
<>
|
||||
<div className={`${isDesktopNav ? 'w-full max-w-md' : 'w-full'}`}>
|
||||
{isDesktopNav ? (
|
||||
// Desktop navbar search with integrated dropdown
|
||||
<div className="flex items-center bg-gray-900/55 rounded-lg">
|
||||
<div className="relative flex-1 flex items-center">
|
||||
<i className="pi pi-search text-gray-400 absolute left-4 z-10" />
|
||||
<InputText
|
||||
className="w-full bg-transparent pl-10"
|
||||
value={searchTerm}
|
||||
onChange={handleSearch}
|
||||
placeholder={`Search ${selectedSearchOption.name.toLowerCase()}`}
|
||||
pt={{
|
||||
root: {
|
||||
className: "border-none focus:ring-0 rounded-lg"
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center px-2 border-l border-gray-700 h-full">
|
||||
<Dropdown
|
||||
pt={{
|
||||
root: {
|
||||
className: 'border-none bg-transparent'
|
||||
},
|
||||
input: {
|
||||
className: 'mx-0 px-0'
|
||||
},
|
||||
trigger: {
|
||||
className: 'p-0'
|
||||
},
|
||||
panel: {
|
||||
className: 'min-w-[150px]'
|
||||
}
|
||||
}}
|
||||
value={selectedSearchOption}
|
||||
onChange={(e) => setSelectedSearchOption(e.value)}
|
||||
options={searchOptions}
|
||||
optionLabel="name"
|
||||
dropdownIcon={
|
||||
<i className="pi pi-chevron-down text-gray-400 ml-1" />
|
||||
}
|
||||
valueTemplate={selectedOptionTemplate}
|
||||
itemTemplate={(option) => (
|
||||
<div className="flex items-center py-1">
|
||||
<i className={option.icon + ' mr-2 text-lg'}></i>
|
||||
<span>{option.name}</span>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// Original search for other views
|
||||
<div className={`flex flex-col ${isMobileSearch ? 'gap-4' : ''}`}>
|
||||
<div className={`relative flex items-center ${isMobileSearch ? 'bg-gray-800 rounded-lg p-2' : ''}`}>
|
||||
<i className="pi pi-search text-gray-400 absolute left-4 z-10" />
|
||||
<InputText
|
||||
className={`
|
||||
${windowWidth > 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'}`
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
valueTemplate={selectedOptionTemplate}
|
||||
itemTemplate={selectedOptionTemplate}
|
||||
required
|
||||
/>
|
||||
</IconField>
|
||||
|
||||
<OverlayPanel ref={op} className="w-[600px] max-h-[70vh] overflow-y-auto">
|
||||
{searchResults.map((item, index) => (
|
||||
item.type === 'discord' || item.type === 'nostr' || item.type === 'stackernews' ? (
|
||||
<MessageDropdownItem
|
||||
key={index}
|
||||
message={item}
|
||||
onSelect={handleContentSelect}
|
||||
/>
|
||||
) : (
|
||||
<ContentDropdownItem
|
||||
key={index}
|
||||
content={item}
|
||||
onSelect={handleContentSelect}
|
||||
/>
|
||||
)
|
||||
))}
|
||||
{searchResults.length === 0 && searchTerm.length > 2 && (
|
||||
<div className="p-4 text-center">No results found</div>
|
||||
{isMobileSearch && (
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
{searchOptions.map((option) => (
|
||||
<button
|
||||
key={option.code}
|
||||
onClick={() => setSelectedSearchOption(option)}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-full ${
|
||||
selectedSearchOption.code === option.code
|
||||
? 'bg-gray-700 text-white'
|
||||
: 'bg-gray-800 text-gray-400'
|
||||
}`}
|
||||
>
|
||||
<i className={option.icon} />
|
||||
<span>{option.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isMobileSearch && (
|
||||
<Dropdown
|
||||
pt={{
|
||||
root: {
|
||||
className: 'border-none rounded-tl-none rounded-bl-none bg-gray-900/55 hover:bg-gray-900/30'
|
||||
},
|
||||
input: {
|
||||
className: 'mx-0 px-0 shadow-lg'
|
||||
}
|
||||
}}
|
||||
value={selectedSearchOption}
|
||||
onChange={(e) => setSelectedSearchOption(e.value)}
|
||||
options={searchOptions}
|
||||
optionLabel="name"
|
||||
placeholder="Search"
|
||||
dropdownIcon={
|
||||
<div className='w-full pr-2 flex flex-row items-center justify-between'>
|
||||
<i className={selectedSearchOption.icon + " text-white"} />
|
||||
<i className="pi pi-chevron-down" />
|
||||
</div>
|
||||
}
|
||||
valueTemplate={selectedOptionTemplate}
|
||||
itemTemplate={selectedOptionTemplate}
|
||||
required
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</OverlayPanel>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Desktop Search Results */}
|
||||
{!isMobileSearch && (
|
||||
<OverlayPanel
|
||||
ref={op}
|
||||
className="w-[600px] max-h-[70vh] overflow-y-auto"
|
||||
>
|
||||
{renderSearchResults()}
|
||||
</OverlayPanel>
|
||||
)}
|
||||
|
||||
{/* Mobile Search Results */}
|
||||
{isMobileSearch && searchTerm.length > 2 && (
|
||||
<div
|
||||
className="fixed inset-x-0 bottom-0 top-[165px] bg-gray-900 overflow-y-auto"
|
||||
style={{ touchAction: 'pan-y' }}
|
||||
>
|
||||
<div className="pb-20">
|
||||
{renderSearchResults()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -9,6 +9,7 @@ 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();
|
||||
|
Loading…
x
Reference in New Issue
Block a user