diff --git a/apps/masterbots.ai/app/layout.tsx b/apps/masterbots.ai/app/layout.tsx index 7c027b76..fe00e494 100644 --- a/apps/masterbots.ai/app/layout.tsx +++ b/apps/masterbots.ai/app/layout.tsx @@ -3,15 +3,17 @@ import { GeistSans } from 'geist/font/sans' import { Toaster } from 'react-hot-toast' import '@/app/globals.css' -import { cookies } from 'next/headers' -import dynamic from 'next/dynamic' -import { Metadata } from 'next/types' -import { objectToCamel } from 'ts-case-convert' import { Header } from '@/components/layout/header' import { Providers } from '@/components/layout/providers' -import { cn } from '@/lib/utils' import { GlobalStoreProvider } from '@/hooks/use-global-store' +import { cn } from '@/lib/utils' import { createSupabaseServerClient } from '@/services/supabase' +import { GoogleAnalytics } from '@next/third-parties/google' +import { MB } from '@repo/supabase' +import dynamic from 'next/dynamic' +import { cookies } from 'next/headers' +import { Metadata } from 'next/types' +import { objectToCamel } from 'ts-case-convert' async function getCookieData(): Promise<{ userProfile }> { const userProfile = cookies().get('userProfile')?.value || null @@ -41,7 +43,7 @@ export default async function RootLayout({ children }: RootLayoutProps) { + + + ) @@ -70,7 +75,11 @@ async function getGlobalData() { const categories = await supabase.from('category').select() const chatbot = await supabase.from('chatbot').select(`*, prompt(*)`) - return objectToCamel({ categories: categories.data, chatbots: chatbot.data }) + return objectToCamel({ + categories: categories.data, + // TODO: fix type... it shouldn't be unknown. Moving forward on other places. + chatbots: chatbot.data as unknown as MB.ChatbotWithPrompts[] + }) } export const viewport = { 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 c1fc0993..24a273af 100644 --- a/apps/masterbots.ai/components/shared/category-tabs/category-tabs.tsx +++ b/apps/masterbots.ai/components/shared/category-tabs/category-tabs.tsx @@ -1,4 +1,8 @@ +'use client' + +import { toSlug } from '@/lib/url-params' import { MB } from '@repo/supabase' +import { useEffect } from 'react' import { CategoryLink } from './category-link' export function CategoryTabs({ @@ -8,6 +12,26 @@ export function CategoryTabs({ categories: MB.Category[] initialCategory?: string }) { + useEffect(() => { + if (document) { + const element = document.getElementById( + `browse-category-tab__${initialCategory === 'all' + ? 'all' + : categories.filter(c => toSlug(c.name) === initialCategory)[0] + ?.categoryId + }` + ) + + if (element) { + element.scrollIntoView({ + behavior: 'smooth', + block: 'center', + inline: 'center' + }) + } + } + }, [initialCategory]) + return (
diff --git a/apps/masterbots.ai/components/shared/thread-accordion.client.tsx b/apps/masterbots.ai/components/shared/thread-accordion.client.tsx index 32b4696c..8c308a52 100644 --- a/apps/masterbots.ai/components/shared/thread-accordion.client.tsx +++ b/apps/masterbots.ai/components/shared/thread-accordion.client.tsx @@ -1,20 +1,19 @@ 'use client' -import { useEffect } from 'react' -import { useQuery } from '@tanstack/react-query' -import { usePathname, useRouter } from 'next/navigation' -import { MB } from '@repo/supabase' +import { getMessagePairs } from '@/app/actions' import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion' +import { getThreadLink } from '@/lib/threads' import { cn } from '@/lib/utils' -import { getMessagePairs } from '@/app/actions' -import { ThreadHeading } from './thread-heading' +import { MB } from '@repo/supabase' +import { useQuery } from '@tanstack/react-query' +import { usePathname, useRouter } from 'next/navigation' +import { useEffect } from 'react' import { BrowseChatMessage } from './thread-message' -import { getThreadLink } from '@/lib/threads' export function ThreadAccordionClient({ thread, @@ -56,7 +55,7 @@ export function ThreadAccordionClient({ return (
diff --git a/apps/masterbots.ai/components/shared/thread-list-accordion.tsx b/apps/masterbots.ai/components/shared/thread-list-accordion.tsx index 432e9ea2..15c24233 100644 --- a/apps/masterbots.ai/components/shared/thread-list-accordion.tsx +++ b/apps/masterbots.ai/components/shared/thread-list-accordion.tsx @@ -1,25 +1,62 @@ -import { DialogProps } from '@radix-ui/react-dialog' -import type { MB } from '@repo/supabase' -import { cn } from '@/lib/utils' +'use client' + import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion' +import { createMessagePairs } from '@/lib/threads' +import { cn, sleep } from '@/lib/utils' +import { DialogProps } from '@radix-ui/react-dialog' +import type { MB } from '@repo/supabase' +import { useSearchParams } from 'next/navigation' +import { useEffect, useRef } from 'react' +import { useSetState } from 'react-use' import { ThreadAccordionClient } from './thread-accordion.client' import { ThreadHeading } from './thread-heading' -import { createMessagePairs } from '@/lib/threads' export function ThreadListAccordion({ thread, - chat = false + chat = false, + isUser = false, + isBot = false }: ThreadListAccordionProps) { + const [state, setState] = useSetState({ + isOpen: false, + firstQuestion: + thread.messages.find(m => m.role === 'user')?.content || 'not found', + firstResponse: + thread.messages.find(m => m.role === 'assistant')?.content || 'not found' + }) + const searchParams = useSearchParams(); + const threadRef = useRef(null) + const handleThreadIdChange = async () => { + if (searchParams.get('threadId') === thread.threadId) { + await sleep(300) // animation time + threadRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }) + } else if (state.isOpen && searchParams.get('threadId')) { + setState({ isOpen: false }) + } + } + + useEffect(() => { + handleThreadIdChange() + }, [searchParams]) + return ( - - + { + setState({ isOpen: v[0] === 'pair-1' }) + }} + type="multiple" + > + {/* Frist level question and excerpt visible on lists */} +
@@ -50,6 +84,8 @@ export function ThreadListAccordion({ } interface ThreadListAccordionProps extends DialogProps { - thread: MB.ThreadFull + thread: MB.Thread chat?: boolean + isUser?: boolean + isBot?: boolean } diff --git a/apps/masterbots.ai/components/shared/thread-list-reload.tsx b/apps/masterbots.ai/components/shared/thread-list-reload.tsx index b5aa4888..0027bd73 100644 --- a/apps/masterbots.ai/components/shared/thread-list-reload.tsx +++ b/apps/masterbots.ai/components/shared/thread-list-reload.tsx @@ -1,8 +1,13 @@ 'use client' -import React, { useEffect, useRef, useState } from 'react' -import { useSearchParams, useRouter } from 'next/navigation' - +import { useRouter, useSearchParams } from 'next/navigation' +import { useEffect, useRef, useState } from 'react' + +// ! This component is not necessary to be split this way, only functionality. +// ! Though functionality looks correct, this causes an error in the app, a infinite loop very easy +// ! I suggest to only split the code in the same file, or use a different approach to split the code. +// * Search params might look fine but if an user goes (or saves) a link with a page number, it will be lost the first items that are on smaller page number +// * E.g.: If I go to /?page=2, I will lose the first items that are on page 1... This is not a good user experience export function ThreadListReload() { const params = useSearchParams() const router = useRouter() diff --git a/apps/masterbots.ai/components/shared/thread-list.tsx b/apps/masterbots.ai/components/shared/thread-list.tsx index 6c1c409f..8e700a62 100644 --- a/apps/masterbots.ai/components/shared/thread-list.tsx +++ b/apps/masterbots.ai/components/shared/thread-list.tsx @@ -1,14 +1,101 @@ +import { getThreads } from '@/app/actions' import { MB } from '@repo/supabase' +import { useInfiniteQuery, useQueryClient } from '@tanstack/react-query' +import { flatten, uniq } from 'lodash' +import { usePathname, useSearchParams } from 'next/navigation' +import { useEffect, useRef, useState } from 'react' import { ThreadDialog } from './thread-dialog' import { ThreadListAccordion } from './thread-list-accordion' import { ThreadListChatItem } from './thread-list-chat-item' -import { ThreadListReload } from './thread-list-reload' +const limit = 20 export function ThreadList({ initialThreads, chat = false, - dialog = false + dialog = false, + isUser = false, + isBot = false }: ThreadListProps) { + const searchParams = useSearchParams() + const query = searchParams.get('query') + const loadMoreRef = useRef(null) + const queryClient = useQueryClient() + const [showSkeleton, setShowSkeleton] = useState(false) + + const queryKey = [usePathname(), query] + const [lastQueryKey, setLastQueryKey] = useState(queryKey) + const { isFetchingNextPage, fetchNextPage, data, refetch } = useInfiniteQuery( + { + queryKey, + queryFn: async (props) => { + const query = await getThreads({ + page: props.pageParam - 1, + }) + return query.data + }, + initialData: { pages: [initialThreads], pageParams: [1] }, + initialPageParam: 2, + getNextPageParam: ((_a, _b, lastPageParam) => { + return lastPageParam + 1 + }), + enabled: false + } + ) + + useEffect(() => { + refetch() + }, [refetch, query]) + + // load mare item when it gets to the end + // TODO: read count from database + useEffect(() => { + // only load add observer if we get at least iquals to limit on initialThreads + // TODO: get thread count from server db query + if ( + !loadMoreRef.current || + data.pages[data.pages.length - 1].length < limit + ) + return + const observer = new IntersectionObserver(([entry]) => { + if (entry.isIntersecting && !isFetchingNextPage) { + setTimeout(() => { + fetchNextPage() + }, 150) + observer.unobserve(entry.target) + } + }) + + observer.observe(loadMoreRef.current) + + return () => { + // always unsubscribe on component unmount + observer.disconnect() + } + }, [isFetchingNextPage, fetchNextPage]) + + useEffect(() => { + const queryKeyString = JSON.stringify(queryKey) + const lastQueryKeyString = JSON.stringify(lastQueryKey) + console.log( + queryKeyString === lastQueryKeyString, + queryKeyString, + lastQueryKeyString + ) + if (queryKeyString === lastQueryKeyString) return + console.log('queryKey changed, resetting query client ...') + // Invalidate and refetch the query to reset state + // we need this cos nextjs wont hydrate this client component, only src + setShowSkeleton(true) + setLastQueryKey(queryKey) + + queryClient.invalidateQueries({ queryKey }).then(() => { + queryClient.refetchQueries({ queryKey }).then(() => { + setShowSkeleton(false) + refetch() + }) + }) + }, [queryKey, setLastQueryKey, lastQueryKey, setShowSkeleton, refetch]) + // ThreadList can displays the rigth list item based on the context // ThreadListChatItem is next shallow link for chat ui lists // ThreadDialog is user preference @@ -18,22 +105,30 @@ export function ThreadList({ : dialog ? ThreadDialog : ThreadListAccordion + const threads = uniq(flatten(data.pages)) return (
- {!initialThreads.length ? ( -
No threads founds
- ) : ( - initialThreads.map(thread => ( + {!threads.length ?
No threads founds
: null} + {showSkeleton ? ' Loading ...' : ''} + {!showSkeleton && + threads.map((thread: MB.ThreadFull) => ( - )) - )} - + ))} +
+ {isFetchingNextPage && ( +
+
+
+ )} +
) } @@ -42,4 +137,6 @@ interface ThreadListProps { initialThreads: MB.ThreadFull[] chat?: boolean dialog?: boolean + isUser?: boolean + isBot?: boolean } diff --git a/apps/masterbots.ai/components/ui/accordion.tsx b/apps/masterbots.ai/components/ui/accordion.tsx index 74fabea5..e4881c5a 100644 --- a/apps/masterbots.ai/components/ui/accordion.tsx +++ b/apps/masterbots.ai/components/ui/accordion.tsx @@ -1,12 +1,16 @@ 'use client' -import * as React from 'react' +import { cn } from '@/lib/utils' import * as AccordionPrimitive from '@radix-ui/react-accordion' import { ChevronDown } from 'lucide-react' -import { cn } from '@/lib/utils' +import * as React from 'react' const Accordion = AccordionPrimitive.Root +interface AccordionTriggerProps extends React.ComponentPropsWithoutRef { + isSticky?: boolean +} + const AccordionItem = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef @@ -21,12 +25,12 @@ AccordionItem.displayName = 'AccordionItem' const AccordionTrigger = React.forwardRef< React.ElementRef, - React.ComponentPropsWithoutRef + AccordionTriggerProps >(({ className, children, ...props }, ref) => ( svg]:rotate-180', + 'flex flex-1 items-center justify-between py-4 font-medium transition-all [&[data-state=open]>svg]:rotate-180', className )} ref={ref} @@ -48,10 +52,11 @@ const AccordionContent = React.forwardRef< ref={ref} {...props} > -
{children}
+
{children}
)) AccordionContent.displayName = AccordionPrimitive.Content.displayName -export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } +export { Accordion, AccordionContent, AccordionItem, AccordionTrigger } + diff --git a/apps/masterbots.ai/package.json b/apps/masterbots.ai/package.json index 8c26c901..787ac927 100644 --- a/apps/masterbots.ai/package.json +++ b/apps/masterbots.ai/package.json @@ -16,6 +16,7 @@ "dependencies": { "@blockmatic/hooks-utils": "^3.0.0", "@hookform/resolvers": "^3.3.4", + "@next/third-parties": "^14.2.3", "@radix-ui/react-accordion": "^1.1.2", "@radix-ui/react-alert-dialog": "^1.0.5", "@radix-ui/react-avatar": "^1.0.4", diff --git a/bun.lockb b/bun.lockb index 5ab3f772..74964c53 100755 Binary files a/bun.lockb and b/bun.lockb differ