diff --git a/apps/masterbots.ai/app/(browse)/[category]/[threadId]/page.tsx b/apps/masterbots.ai/app/(browse)/[category]/[threadId]/page.tsx index 3910c808..7226f5a1 100644 --- a/apps/masterbots.ai/app/(browse)/[category]/[threadId]/page.tsx +++ b/apps/masterbots.ai/app/(browse)/[category]/[threadId]/page.tsx @@ -2,7 +2,7 @@ import { getCategories, getMessagePairs, getThread } from '@/services/hasura' import { ThreadAccordion } from '@/components/shared/thread-accordion' import { CategoryTabs } from '@/components/shared/category-tabs/category-tabs' -import { BrowseInput } from '@/components/shared/browse-input' +import { SearchInput } from '@/components/shared/search-input' export default async function ThreadPage({ params }: ThreadPageProps) { const categories = await getCategories() @@ -14,7 +14,7 @@ export default async function ThreadPage({ params }: ThreadPageProps) { return (
- + - c.name.toLowerCase().replace(/\s+/g, '_').replace(/\&/g, '_') === - params.category - ).categoryId - if (!categoryId) throw new Error('Category id not foud') + c => toSlug(c.name) === params.category + )?.categoryId + if (!categoryId) throw new Error('Category not foud') + + const query = searchParams.query ? decodeQuery(searchParams.query) : null const threads = await getBrowseThreads({ limit: 20, - categoryId + categoryId, + query }) return (
- - + +
) } + +interface CategoryPageProps { + params: { category: string } + searchParams?: { query: string } +} diff --git a/apps/masterbots.ai/app/(browse)/layout.tsx b/apps/masterbots.ai/app/(browse)/layout.tsx index da3e3bde..cafc347b 100644 --- a/apps/masterbots.ai/app/(browse)/layout.tsx +++ b/apps/masterbots.ai/app/(browse)/layout.tsx @@ -1,4 +1,3 @@ -import { BrowseProvider } from '@/hooks/use-browse' import FooterCT from '@/components/layout/footer-ct' interface BrowseLayoutProps { @@ -7,13 +6,11 @@ interface BrowseLayoutProps { export default async function BrowseLayout({ children }: BrowseLayoutProps) { return ( - -
-
- {children} - -
-
-
+
+
+ {children} + +
+
) } diff --git a/apps/masterbots.ai/app/(browse)/page.tsx b/apps/masterbots.ai/app/(browse)/page.tsx index 810b504d..4f87f8e9 100644 --- a/apps/masterbots.ai/app/(browse)/page.tsx +++ b/apps/masterbots.ai/app/(browse)/page.tsx @@ -1,18 +1,37 @@ import { ThreadList } from '@/components/shared/thread-list' import { CategoryTabs } from '@/components/shared/category-tabs/category-tabs' -import { BrowseInput } from '@/components/shared/browse-input' +import { SearchInput } from '@/components/shared/search-input' import { getBrowseThreads, getCategories } from '@/services/hasura' +import { Card } from '@/components/ui/card' +import { decodeQuery } from '@/lib/url' -export default async function BrowsePage() { +export default async function HomePage({ searchParams }: HomePageProps) { const categories = await getCategories() + const query = searchParams.query ? decodeQuery(searchParams.query) : null const threads = await getBrowseThreads({ - limit: 20 + limit: 20, + query }) + return (
- - + + + {threads?.length ? ( + + ) : ( + no results + )}
) } + +interface HomePageProps { + searchParams?: { query: string } +} diff --git a/apps/masterbots.ai/app/404/page.tsx b/apps/masterbots.ai/app/404/page.tsx new file mode 100644 index 00000000..9da8a557 --- /dev/null +++ b/apps/masterbots.ai/app/404/page.tsx @@ -0,0 +1,5 @@ +export default async function NotFoudPage() { + return ( +
NOT Found
+ ) +} diff --git a/apps/masterbots.ai/app/b/[id]/layout.tsx b/apps/masterbots.ai/app/b/[id]/layout.tsx index da3e3bde..e892b932 100644 --- a/apps/masterbots.ai/app/b/[id]/layout.tsx +++ b/apps/masterbots.ai/app/b/[id]/layout.tsx @@ -1,4 +1,3 @@ -import { BrowseProvider } from '@/hooks/use-browse' import FooterCT from '@/components/layout/footer-ct' interface BrowseLayoutProps { @@ -7,13 +6,11 @@ interface BrowseLayoutProps { export default async function BrowseLayout({ children }: BrowseLayoutProps) { return ( - -
-
- {children} - -
-
-
+
+
+ {children} + +
+
) } diff --git a/apps/masterbots.ai/app/b/[id]/page.tsx b/apps/masterbots.ai/app/b/[id]/page.tsx index 551d9860..d1ed8894 100644 --- a/apps/masterbots.ai/app/b/[id]/page.tsx +++ b/apps/masterbots.ai/app/b/[id]/page.tsx @@ -3,7 +3,7 @@ import { botNames } from '@/lib/bots-names' import { ThreadList } from '@/components/shared/thread-list' import AccountDetails from '@/components/shared/account-details' import { CategoryTabs } from '@/components/shared/category-tabs/category-tabs' -import { BrowseInput } from '@/components/shared/browse-input' +import { SearchInput } from '@/components/shared/search-input' export default async function BotThreadsPage({ params @@ -28,7 +28,7 @@ export default async function BotThreadsPage({ return (
- + -
- -
- {children} -
- -
+
+ +
+ {children} +
+
-
- +
+
) } diff --git a/apps/masterbots.ai/app/u/[slug]/page.tsx b/apps/masterbots.ai/app/u/[slug]/page.tsx index ab49fe90..10314fe3 100644 --- a/apps/masterbots.ai/app/u/[slug]/page.tsx +++ b/apps/masterbots.ai/app/u/[slug]/page.tsx @@ -6,7 +6,7 @@ import { import { ThreadList } from '@/components/shared/thread-list' import AccountDetails from '@/components/shared/account-details' import { CategoryTabs } from '@/components/shared/category-tabs/category-tabs' -import { BrowseInput } from '@/components/shared/browse-input' +import { SearchInput } from '@/components/shared/search-input' export default async function BotThreadsPage({ params @@ -23,7 +23,7 @@ export default async function BotThreadsPage({ return (
- + - {/* TODO: https://github.com/TheSGJ/nextjs-toploader/issues/66 */} - {/* */} -
-
- {children} - -
-
- +
+
+ {children} + +
+
) } diff --git a/apps/masterbots.ai/components/routes/c/chat-search-input.tsx b/apps/masterbots.ai/components/routes/c/chat-search-input.tsx index 20159105..1b7c55cd 100644 --- a/apps/masterbots.ai/components/routes/c/chat-search-input.tsx +++ b/apps/masterbots.ai/components/routes/c/chat-search-input.tsx @@ -18,10 +18,10 @@ export function ChatSearchInput({ }) { const { chatbot } = useParams() const { activeCategory } = useSidebar() - const [searchPlaceholder, setSearchPlaceholder] = React.useState< + const [queryPlaceholder, setSearchPlaceholder] = React.useState< string | null >(null) - const [keyword, changeKeyword] = React.useState('') + const [query, setKeyword] = React.useState('') const previousThread = React.useRef([]) const previousCategory = React.useRef(null) @@ -45,42 +45,42 @@ export function ChatSearchInput({ debounce(() => { setThreads && setThreads(prevState => { - // ? If there is no results on a search, we should keep the previous state - // ? and if not, the threads previous state before the search will be lost. + // ? If there is no results on a query, we should keep the previous state + // ? and if not, the threads previous state before the query will be lost. previousThread.current = !previousThread.current.length ? prevState : previousThread.current const previousThreadState = previousThread.current - if (!keyword) { + if (!query) { return previousThreadState } return previousThreadState.filter((thread: Thread) => thread.messages[0]?.content .toLowerCase() - .includes(keyword.toLowerCase()) + .includes(query.toLowerCase()) ) }) }, 230)() - }, [keyword]) + }, [query]) return (
{ - changeKeyword(e.target.value) + setKeyword(e.target.value) }} - placeholder={`Search any chat with ${searchPlaceholder ? searchPlaceholder : 'any bot category'}`} - value={keyword} + placeholder={`Search any chat with ${queryPlaceholder ? queryPlaceholder : 'any bot category'}`} + value={query} /> - {keyword ? ( + {query ? ( - ) : null} -
-
-

- Masterbots isn't infallible; verify crucial facts. Responses are for - educational use, not legal, medical, financial or specialized advice. -

-
-
- ) -} diff --git a/apps/masterbots.ai/components/shared/category-tabs/category-link.tsx b/apps/masterbots.ai/components/shared/category-tabs/category-link.tsx index f5969b4a..027e4cc7 100644 --- a/apps/masterbots.ai/components/shared/category-tabs/category-link.tsx +++ b/apps/masterbots.ai/components/shared/category-tabs/category-link.tsx @@ -1,46 +1,37 @@ import { motion } from 'framer-motion' import type { Category } from '@repo/mb-genql' import Link from 'next/link' +import { toSlug } from '@repo/mb-lib' export function CategoryLink({ category, - activeTab, - onClick, id }: { category: Category | 'all' - activeTab: null | number - onClick: () => void id: string }) { return ( - {((activeTab === null && category === 'all') || + {/* {((activeTab === null && category === 'all') || (category !== 'all' && activeTab === category.categoryId)) && ( - )} + )} */} {category === 'all' ? 'All' : category.name} ) diff --git a/apps/masterbots.ai/components/shared/category-tabs/category-tabs.tsx b/apps/masterbots.ai/components/shared/category-tabs/category-tabs.tsx index 0c7c8d6f..03591766 100644 --- a/apps/masterbots.ai/components/shared/category-tabs/category-tabs.tsx +++ b/apps/masterbots.ai/components/shared/category-tabs/category-tabs.tsx @@ -1,8 +1,7 @@ 'use client' import type { Category } from '@repo/mb-genql' -import { useEffect } from 'react' -import { useBrowse } from '@/hooks/use-browse' + import { CategoryLink } from './category-link' export function CategoryTabs({ @@ -12,57 +11,14 @@ export function CategoryTabs({ categories: Category[] initialCategory?: string }) { - const { tab: activeTab, changeTab: setActiveTab } = useBrowse() - useEffect(() => { - if (document) { - const element = document.getElementById( - `browse-category-tab__${activeTab?.toString()}` - ) - - if (element) { - element.scrollIntoView({ - behavior: 'smooth', - block: 'center', - inline: 'center' - }) - } - } - }) - - useEffect(() => { - if (initialCategory === 'all') { - setActiveTab(null) - } else { - setActiveTab( - categories.filter( - c => - c.name.toLowerCase().replace(/\s+/g, '_').replace(/\&/g, '_') === - initialCategory - )[0]?.categoryId - ) - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [initialCategory]) - return (
- { - setActiveTab(null) - }} - /> + {categories.map((category, key) => ( { - setActiveTab(category.categoryId) - }} /> ))}
diff --git a/apps/masterbots.ai/components/shared/search-input.tsx b/apps/masterbots.ai/components/shared/search-input.tsx new file mode 100644 index 00000000..6e4c564f --- /dev/null +++ b/apps/masterbots.ai/components/shared/search-input.tsx @@ -0,0 +1,61 @@ +'use client' + +import { useState } from 'react' + +import { Button } from '@/components/ui/button' +import { IconClose } from '@/components/ui/icons' +import { Input } from '@/components/ui/input' +import { usePathname } from 'next/navigation' +import { useRouter } from 'next/navigation' +import { encodeQuery } from '@/lib/url' + +export function SearchInput() { + const router = useRouter() + const pathname = usePathname() + const [query, setSearchQuery] = useState('') + + const handleSubmit = e => { + e.preventDefault() + router.refresh() + router.push(`${pathname}?query=${encodeQuery(query)}`) + router.refresh() + + // TODO: fix hydration. possible solution could dom key ref linked to search + // search is happening, it hits the server, but hydration not occuring, + } + + return ( +
+
+ { + setSearchQuery(e.target.value) + }} + placeholder="Search any chat with any Bot" + value={query || ''} + /> + {query ? ( + + ) : null} +
+ {/* */} +
+

+ Masterbots isn't infallible; verify crucial facts. Responses are for + educational use, not legal, medical, financial or specialized advice. +

+
+
+ ) +} diff --git a/apps/masterbots.ai/components/shared/thread-double-accordion.tsx b/apps/masterbots.ai/components/shared/thread-double-accordion.tsx index 94cb3395..7bf97551 100644 --- a/apps/masterbots.ai/components/shared/thread-double-accordion.tsx +++ b/apps/masterbots.ai/components/shared/thread-double-accordion.tsx @@ -12,7 +12,6 @@ import { AccordionTrigger } from '@/components/ui/accordion' import { useSetState } from 'react-use' -import { useEffect } from 'react' export function ThreadDoubleAccordion({ thread, diff --git a/apps/masterbots.ai/components/shared/thread-list.tsx b/apps/masterbots.ai/components/shared/thread-list.tsx index 6cf4a8d4..0caed832 100644 --- a/apps/masterbots.ai/components/shared/thread-list.tsx +++ b/apps/masterbots.ai/components/shared/thread-list.tsx @@ -1,12 +1,13 @@ 'use client' -import React, { useEffect, useRef, useState } from 'react' -import { debounce } from 'lodash' +import React, { useEffect, useRef } from 'react' import type { Thread } from '@repo/mb-genql' -import { useBrowse } from '@/hooks/use-browse' import { getBrowseThreads } from '@/services/hasura' import { ThreadDoubleAccordion } from './thread-double-accordion' import { ThreadDialog } from './thread-dialog' +import { usePathname, useSearchParams } from 'next/navigation' +import { GetBrowseThreadsParams } from '@/services/hasura/hasura.service.type' +import { useQuery } from '@tanstack/react-query' export function ThreadList({ initialThreads, @@ -15,72 +16,60 @@ export function ThreadList({ currentThread, dialog = false }: ThreadListProps) { - const { keyword } = useBrowse() - const [threads, setThreads] = useState(initialThreads) - const [filteredThreads, setFilteredThreads] = - useState(initialThreads) - const [loading, setLoading] = useState(false) + const searchParams = useSearchParams() + const queryKey = [usePathname() + searchParams.get('query')] const loadMoreRef = useRef(null) - const [hasMore, setHasMore] = useState(true) - - // load more threads for the category - const loadMore = async () => { - console.log('🟡 Loading More Content') - setLoading(true) - - const moreThreads = await getBrowseThreads({ - ...filter, - offset: filteredThreads.length, - limit: 25 - }) - - if (moreThreads.length === 0) setHasMore(false) - setThreads(prevState => [...prevState, ...moreThreads]) - setLoading(false) - } - - const verifyKeyword = () => { - if (!keyword) { - setFilteredThreads(threads) - } else { - debounce(() => { - setFilteredThreads( - threads.filter((thread: Thread) => - thread.messages[0]?.content - .toLowerCase() - .includes(keyword.toLowerCase()) - ) - ) - }, 230)() - } - } - - useEffect(() => { - verifyKeyword() - }, [keyword, threads, verifyKeyword]) + const threads = useQuery({ + queryKey, + queryFn: async () => { + const searchFilter = { + ...filter, + offset: threads.length, + limit: 25 + } + console.log(`🛜 Loading More Content for ${JSON.stringify(searchFilter)}`) + const moreThreads = await getBrowseThreads(searchFilter) + // concatenate newly loaded threads + return threads.concat(moreThreads) + }, + // data comes from nextjs server on first render + refetchOnMount: false, + initialData: initialThreads + }) // load mare item when it gets to the end useEffect(() => { if (!loadMoreRef.current) return const observer = new IntersectionObserver(([entry]) => { - if (hasMore && entry.isIntersecting && !loading) { - setTimeout(() => loadMore(), 150) + if (entry.isIntersecting && !threads.isLoading) { + // NOTE: I think this is not required + // setTimeout(() => threads.refetch(), 150) + threads.refetch() observer.unobserve(entry.target) } }) observer.observe(loadMoreRef.current) - return () => observer.disconnect() - }, [hasMore, loading, loadMore]) + return () => { + // always unsubscribe on component unmount + observer.disconnect() + } + }, [threads.isLoading]) + // ThreadDialog and ThreadDoubleAccordion can be used interchangeably const ThreadComponent = dialog ? ThreadDialog : ThreadDoubleAccordion return ( -
- {filteredThreads.map((thread: Thread, key) => ( +
+ {threads.data.map((thread: Thread) => ( void - changeTab: (tab: null | number) => void -} - -const BrowseContext = React.createContext( - undefined -) - -export function useBrowse() { - const context = React.useContext(BrowseContext) - if (!context) { - throw new Error('useBrowseContext must be used within a BrowseProvider') - } - return context -} - -interface BrowseProviderProps { - children: React.ReactNode -} - -export function BrowseProvider({ children }: BrowseProviderProps) { - const [keyword, setKeyword] = React.useState('') - const [tab, setTab] = React.useState(null) - - const changeTab = (tab: null | number) => { - setTab(tab) - } - - const changeKeyword = (keyword: string) => { - setKeyword(keyword) - } - - return ( - - {children} - - ) -} diff --git a/apps/masterbots.ai/lib/number.ts b/apps/masterbots.ai/lib/number.ts new file mode 100644 index 00000000..981b6142 --- /dev/null +++ b/apps/masterbots.ai/lib/number.ts @@ -0,0 +1,5 @@ +// Function to generate a random number as a string +export const generateRandomNumber = (length: number): string => { + const randomNumber = Math.floor(Math.random() * Math.pow(10, length)) + return randomNumber.toString().padStart(length, '0') +} \ No newline at end of file diff --git a/apps/masterbots.ai/lib/url.ts b/apps/masterbots.ai/lib/url.ts new file mode 100644 index 00000000..96fd0202 --- /dev/null +++ b/apps/masterbots.ai/lib/url.ts @@ -0,0 +1,33 @@ +import { z, ZodSchema } from 'zod' + +// Zod schema for validating slug strings +export const SlugSchema: ZodSchema = z.string() + .min(1) + .regex(/^[a-z0-9]+[a-z0-9+_-]*[a-z0-9]+$/, "Invalid slug format.") + +// Function to convert a username into a slug +export const toSlug = (username: string, separator: string): string => { + return username + .toLowerCase() + .replace(/&/g, '_') + .replace(/ & /g, '_') + .replace(/[^a-z0-9_]/g, separator) +} + +// Function to simulate converting a slug back to a username +export const fromSlug = (slug: string, separator: string): string => { + return slug + .replace(new RegExp(`[${separator}]+`, 'g'), ' ') + .replace(/_/g, '&') +} + +//Encodes a string for use in a URL, replacing spaces with the '+' character. +export const encodeQuery = (input: string): string => { + return encodeURIComponent(input).replace(/%20/g, '+') +} + +//Decodes a URL-encoded string, converting '+' back into spaces. + +export const decodeQuery = (input: string): string => { + return decodeURIComponent(input.replace(/\+/g, ' ')) +} diff --git a/apps/masterbots.ai/lib/username.ts b/apps/masterbots.ai/lib/username.ts new file mode 100644 index 00000000..b01470ff --- /dev/null +++ b/apps/masterbots.ai/lib/username.ts @@ -0,0 +1,24 @@ +import { z, ZodSchema } from 'zod' +import { generateRandomNumber } from './number' + +// Zod schema for validating usernames +export const UsernameSchema: ZodSchema = z.string() + .min(11, { message: "Username must be at least 11 characters long." }) + .max(20, { message: "Username must not exceed 20 characters." }) + .regex(/^[a-z0-9_]*$/, { message: "Username must contain only lowercase letters, numbers, and underscores." }) + +// Function to generate a username from an OAuth profile name +export const generateUsername = (name: string): string => { + if (!name) return `user_${generateRandomNumber(6)}` + + let username = name.toLowerCase().replace(/[^a-z0-9]/g, '_') + + if (username.length < 10) { + username += `_${generateRandomNumber(7)}` + } else if (username.length > 20) { + username = username.substring(0, 20) + } + + return UsernameSchema.parse(username) +} + diff --git a/apps/masterbots.ai/lib/utils.ts b/apps/masterbots.ai/lib/utils.ts index ccacf839..2c55ed30 100644 --- a/apps/masterbots.ai/lib/utils.ts +++ b/apps/masterbots.ai/lib/utils.ts @@ -11,28 +11,6 @@ export const nanoid = customAlphabet( 7 ) // 7-character random string -export async function fetcher( - input: RequestInfo, - init?: RequestInit -): Promise { - const res = await fetch(input, init) - - if (!res.ok) { - const json = await res.json() - if (json.error) { - const error = new Error(json.error) as Error & { - status: number - } - error.status = res.status - throw error - } else { - throw new Error('An unexpected error occurred') - } - } - - return res.json() -} - export function formatDate(input: string | number | Date): string { const date = new Date(input) return date.toLocaleDateString('en-US', { diff --git a/apps/masterbots.ai/services/hasura/hasura.service.ts b/apps/masterbots.ai/services/hasura/hasura.service.ts index aac03b46..98331d4e 100644 --- a/apps/masterbots.ai/services/hasura/hasura.service.ts +++ b/apps/masterbots.ai/services/hasura/hasura.service.ts @@ -350,13 +350,14 @@ export async function getChatbot({ export async function getBrowseThreads({ categoryId, - keyword, + query, chatbotName, userId, limit, offset, slug }: GetBrowseThreadsParams) { + const client = getHasuraClient({}) const { thread } = await client.query({ @@ -377,18 +378,18 @@ export async function getBrowseThreads({ ...everything, __args: { orderBy: [{ createdAt: 'ASC' }], - ...(keyword + ...(query ? { where: { _or: [ { content: { - _iregex: keyword + _iregex: query } }, { content: { - _eq: keyword + _eq: query } } ] diff --git a/apps/masterbots.ai/services/hasura/hasura.service.type.ts b/apps/masterbots.ai/services/hasura/hasura.service.type.ts index e6eccff9..da7eb610 100644 --- a/apps/masterbots.ai/services/hasura/hasura.service.type.ts +++ b/apps/masterbots.ai/services/hasura/hasura.service.type.ts @@ -13,7 +13,7 @@ export interface GetThreadsParams extends HasuraServiceParams { chatbotName?: string userId: string categoryId?: number | null - keyword?: string + query?: string limit?: number offset?: number } @@ -52,7 +52,7 @@ export interface GetChatbotParams extends HasuraServiceParams { export interface GetBrowseThreadsParams { categoryId?: number | null - keyword?: string + query?: string userId?: string chatbotName?: string slug?: string | null diff --git a/apps/masterbots.ai/lib/types.ts b/apps/masterbots.ai/types/chat.ts similarity index 100% rename from apps/masterbots.ai/lib/types.ts rename to apps/masterbots.ai/types/chat.ts diff --git a/bun.lockb b/bun.lockb index 4fe53473..351be7a1 100755 Binary files a/bun.lockb and b/bun.lockb differ