feat(search): implement backdrop blur and improve UX

- Add background blur effect to main content when search is active
- Implement click-outside behavior to clear search and remove blur
- Add smooth transition for blur effect
- Add main-content class for proper DOM targeting
- Improve search component interaction handling
This commit is contained in:
kiwihodl 2025-03-18 11:57:34 -05:00
parent c80722a3c1
commit 85a0756218
No known key found for this signature in database
GPG Key ID: A2878DC38F1E643C
3 changed files with 261 additions and 187 deletions

View File

@ -1,146 +1,200 @@
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';
import MessageDropdownItem from '@/components/content/dropdowns/MessageDropdownItem';
import { useContentSearch } from '@/hooks/useContentSearch';
import { useCommunitySearch } from '@/hooks/useCommunitySearch';
import { useRouter } from 'next/router';
import useWindowWidth from '@/hooks/useWindowWidth';
import styles from './searchbar.module.css';
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";
import MessageDropdownItem from "@/components/content/dropdowns/MessageDropdownItem";
import { useContentSearch } from "@/hooks/useContentSearch";
import { useCommunitySearch } from "@/hooks/useCommunitySearch";
import { useRouter } from "next/router";
import useWindowWidth from "@/hooks/useWindowWidth";
import styles from "./searchbar.module.css";
const SearchBar = () => {
const { searchContent, searchResults: contentResults } = useContentSearch();
const { searchCommunity, searchResults: communityResults } = useCommunitySearch();
const router = useRouter();
const windowWidth = useWindowWidth();
const [selectedSearchOption, setSelectedSearchOption] = useState({ name: 'Content', code: 'content', icon: 'pi pi-video' });
const searchOptions = [
{ name: 'Content', code: 'content', icon: 'pi pi-video' },
{ name: 'Community', code: 'community', icon: 'pi pi-users' },
];
const [searchTerm, setSearchTerm] = useState('');
const [searchResults, setSearchResults] = useState([]);
const op = useRef(null);
const { searchContent, searchResults: contentResults } = useContentSearch();
const { searchCommunity, searchResults: communityResults } =
useCommunitySearch();
const router = useRouter();
const windowWidth = useWindowWidth();
const [selectedSearchOption, setSelectedSearchOption] = useState({
name: "Content",
code: "content",
icon: "pi pi-video",
});
const searchOptions = [
{ name: "Content", code: "content", icon: "pi pi-video" },
{ name: "Community", code: "community", icon: "pi pi-users" },
];
const [searchTerm, setSearchTerm] = useState("");
const [searchResults, setSearchResults] = useState([]);
const op = useRef(null);
const [isSearchActive, setIsSearchActive] = useState(false);
const selectedOptionTemplate = (option, props) => {
if (!props?.placeholder) {
return (
<div className="flex items-center">
<i className={option.icon + ' mr-2'}></i>
<span>{option.code}</span>
</div>
);
}
return <i className={option.icon + ' text-transparent text-xs'} />
};
const selectedOptionTemplate = (option, props) => {
if (!props?.placeholder) {
return (
<div className="flex items-center">
<i className={option.icon + " mr-2"}></i>
<span>{option.code}</span>
</div>
);
}
return <i className={option.icon + " text-transparent text-xs"} />;
};
const handleSearch = (e) => {
const term = e.target.value;
setSearchTerm(term);
const handleSearch = (e) => {
const term = e.target.value;
setSearchTerm(term);
if (selectedSearchOption.code === 'content') {
searchContent(term);
setSearchResults(contentResults);
} else if (selectedSearchOption.code === 'community') {
searchCommunity(term);
setSearchResults(communityResults);
}
if (term.length > 2) {
op.current.show(e);
} else {
op.current.hide();
}
};
useEffect(() => {
if (selectedSearchOption.code === 'content') {
setSearchResults(contentResults);
} else if (selectedSearchOption.code === 'community') {
setSearchResults(communityResults);
}
}, [selectedSearchOption, contentResults, communityResults]);
const handleContentSelect = (content) => {
if (content?.type === 'course') {
router.push(`/course/${content.id}`);
} else {
router.push(`/details/${content.id}`);
}
setSearchTerm('');
searchContent('');
op.current.hide();
if (selectedSearchOption.code === "content") {
searchContent(term);
setSearchResults(contentResults);
} else if (selectedSearchOption.code === "community") {
searchCommunity(term);
setSearchResults(communityResults);
}
return (
<div className={`absolute ${windowWidth < 700 ? "left-[40%]" : "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'
}
}}
/>
if (term.length > 2) {
setIsSearchActive(true);
op.current.show(e);
} else {
setIsSearchActive(false);
op.current.hide();
}
};
<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'
}
}}
className={styles.dropdown}
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
/>
</IconField>
useEffect(() => {
if (selectedSearchOption.code === "content") {
setSearchResults(contentResults);
} else if (selectedSearchOption.code === "community") {
setSearchResults(communityResults);
}
}, [selectedSearchOption, contentResults, communityResults]);
<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>
)}
</OverlayPanel>
</div>
);
useEffect(() => {
const mainContent = document.querySelector(".main-content");
if (mainContent) {
if (isSearchActive) {
mainContent.classList.add(styles.blurredContent);
} else {
mainContent.classList.remove(styles.blurredContent);
}
}
}, [isSearchActive]);
useEffect(() => {
const handleClickOutside = (event) => {
const overlayElement = document.querySelector(".p-overlaypanel");
const searchElement = event.target.closest(".search-container");
if (
(!overlayElement || !overlayElement.contains(event.target)) &&
!searchElement
) {
setSearchTerm("");
setIsSearchActive(false);
setSearchResults([]);
op.current?.hide();
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, []);
const handleContentSelect = (content) => {
if (content?.type === "course") {
router.push(`/course/${content.id}`);
} else {
router.push(`/details/${content.id}`);
}
setSearchTerm("");
searchContent("");
setIsSearchActive(false);
op.current.hide();
};
return (
<div
className={`search-container absolute ${
windowWidth < 700 ? "left-[40%]" : "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",
},
}}
className={styles.dropdown}
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
/>
</IconField>
<OverlayPanel
ref={op}
className={`w-[600px] max-h-[70vh] overflow-y-auto ${styles.overlayPanel}`}
>
{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>
)}
</OverlayPanel>
</div>
);
};
export default SearchBar;

View File

@ -17,3 +17,23 @@
outline: none !important;
box-shadow: none !important;
} */
.dropdown {
background: rgba(0, 0, 0, 0.3) !important;
backdrop-filter: blur(8px) !important;
}
/* Add this to style the overlay panel */
.overlayPanel {
background: rgba(0, 0, 0, 0.75) !important;
border: 1px solid rgba(255, 255, 255, 0.1) !important;
}
.overlayPanelContent {
background: transparent !important;
}
.blurredContent {
filter: blur(8px);
transition: filter 0.2s ease-in-out;
}

View File

@ -1,61 +1,61 @@
import { PrimeReactProvider } from 'primereact/api';
import { useEffect, useState } from 'react';
import Navbar from '@/components/navbar/Navbar';
import { ToastProvider } from '@/hooks/useToast';
import { SessionProvider } from "next-auth/react"
import Layout from '@/components/Layout';
import '@/styles/globals.css'
import 'primereact/resources/themes/lara-dark-blue/theme.css'
import '@/styles/custom-theme.css'; // custom theme
import 'primereact/resources/primereact.min.css';
import 'primeicons/primeicons.css';
import { PrimeReactProvider } from "primereact/api";
import { useEffect, useState } from "react";
import Navbar from "@/components/navbar/Navbar";
import { ToastProvider } from "@/hooks/useToast";
import { SessionProvider } from "next-auth/react";
import Layout from "@/components/Layout";
import "@/styles/globals.css";
import "primereact/resources/themes/lara-dark-blue/theme.css";
import "@/styles/custom-theme.css"; // custom theme
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';
import {
QueryClient,
QueryClientProvider,
} from '@tanstack/react-query'
import BottomBar from '@/components/BottomBar';
import Sidebar from "@/components/sidebar/Sidebar";
import { useRouter } from "next/router";
import { NDKProvider } from "@/context/NDKContext";
import { Analytics } from "@vercel/analytics/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import BottomBar from "@/components/BottomBar";
const queryClient = new QueryClient()
const queryClient = new QueryClient();
export default function MyApp({
Component, pageProps: { session, ...pageProps }
Component,
pageProps: { session, ...pageProps },
}) {
const [isCourseView, setIsCourseView] = useState(false);
const router = useRouter();
const [isCourseView, setIsCourseView] = useState(false);
const router = useRouter();
useEffect(() => {
setIsCourseView(router.pathname.includes('course') && !router.pathname.includes('draft'));
}, [router.pathname]);
return (
<PrimeReactProvider>
<SessionProvider session={session}>
<NDKProvider>
<QueryClientProvider client={queryClient}>
<ToastProvider>
<Layout>
<div className="flex flex-col min-h-screen">
<Navbar />
<div className='flex'>
<Sidebar course={isCourseView} />
<div className='w-[100vw] pl-[14vw] max-sidebar:pl-0 pb-16 max-sidebar:pb-20'>
<Component {...pageProps} />
<Analytics />
</div>
</div>
<BottomBar />
</div>
</Layout>
</ToastProvider>
</QueryClientProvider>
</NDKProvider>
</SessionProvider>
</PrimeReactProvider>
useEffect(() => {
setIsCourseView(
router.pathname.includes("course") && !router.pathname.includes("draft")
);
}, [router.pathname]);
return (
<PrimeReactProvider>
<SessionProvider session={session}>
<NDKProvider>
<QueryClientProvider client={queryClient}>
<ToastProvider>
<Layout>
<div className="flex flex-col min-h-screen">
<Navbar />
<div className="flex">
<Sidebar course={isCourseView} />
<div className="main-content w-[100vw] pl-[14vw] max-sidebar:pl-0 pb-16 max-sidebar:pb-20">
<Component {...pageProps} />
<Analytics />
</div>
</div>
<BottomBar />
</div>
</Layout>
</ToastProvider>
</QueryClientProvider>
</NDKProvider>
</SessionProvider>
</PrimeReactProvider>
);
}