diff --git a/.eslintignore b/.eslintignore index e8fa391f..4f390e67 100644 --- a/.eslintignore +++ b/.eslintignore @@ -3,4 +3,4 @@ dist coverage .github .husky -*.config.js \ No newline at end of file +*.config.js diff --git a/frontend/.eslintrc.json b/frontend/.eslintrc.json index e0063112..fc16ff28 100644 --- a/frontend/.eslintrc.json +++ b/frontend/.eslintrc.json @@ -1,4 +1,8 @@ { "extends": "next/core-web-vitals", - "ignorePatterns": ["src/graphql/type.ts", "src/graphql/**/*"] + "ignorePatterns": [ + "src/graphql/type.ts", + "src/graphql/**/*", + "src/components/ui/**/*" + ] } diff --git a/frontend/next.config.mjs b/frontend/next.config.mjs index f6f90c43..4c44b9e9 100644 --- a/frontend/next.config.mjs +++ b/frontend/next.config.mjs @@ -1,29 +1,28 @@ /** @type {import('next').NextConfig} */ const nextConfig = { output: 'standalone', + reactStrictMode: true, webpack: (config, { isServer }) => { - // Fixes npm packages that depend on `fs` module - if (!isServer) { - config.resolve.fallback = { - ...config.resolve.fallback, // if you miss it, all the other options in fallback, specified - // by next.js will be dropped. Doesn't make much sense, but how it is - fs: false, // the solution - module: false, - perf_hooks: false, - }; - } - - return config - }, - typescript: { - // !! WARN !! - // Dangerously allow production builds to successfully complete even if - // your project has type errors. - // !! WARN !! - ignoreBuildErrors: true, - }, -}; - + // Fixes npm packages that depend on `fs` module + if (!isServer) { + config.resolve.fallback = { + ...config.resolve.fallback, // if you miss it, all the other options in fallback, specified + // by next.js will be dropped. Doesn't make much sense, but how it is + fs: false, // the solution + module: false, + perf_hooks: false, + }; + } + return config; + }, + typescript: { + // !! WARN !! + // Dangerously allow production builds to successfully complete even if + // your project has type errors. + // !! WARN !! + ignoreBuildErrors: true, + }, +}; export default nextConfig; diff --git a/frontend/src/app/AuthProvider.tsx b/frontend/src/app/AuthProvider.tsx deleted file mode 100644 index 34ad52f7..00000000 --- a/frontend/src/app/AuthProvider.tsx +++ /dev/null @@ -1,64 +0,0 @@ -'use client'; -import { usePathname, useRouter } from 'next/navigation'; -import { useQuery } from '@apollo/client'; -import { CHECK_TOKEN_QUERY } from '@/graphql/request'; -import { LocalStore } from '@/lib/storage'; -import { useEffect, useState } from 'react'; - -interface AuthProviderProps { - children: React.ReactNode; -} - -export const AuthProvider = ({ children }: AuthProviderProps) => { - const router = useRouter(); - const pathname = usePathname(); - const [isChecking, setIsChecking] = useState(true); - const publicRoutes = ['/login', '/register']; - - const { refetch: checkToken } = useQuery(CHECK_TOKEN_QUERY, { - skip: true, - }); - - useEffect(() => { - const validateToken = async () => { - const token = localStorage.getItem(LocalStore.accessToken); - - if (!token && !publicRoutes.includes(pathname)) { - router.push('/login'); - setIsChecking(false); - return; - } - - if (!token) { - setIsChecking(false); - return; - } - - try { - const { data } = await checkToken({ - input: { token }, - }); - - if (!data?.checkToken && !publicRoutes.includes(pathname)) { - localStorage.removeItem(LocalStore.accessToken); - router.push('/login'); - } - } catch (error) { - if (!publicRoutes.includes(pathname)) { - localStorage.removeItem(LocalStore.accessToken); - router.push('/login'); - } - } finally { - setIsChecking(false); - } - }; - - validateToken(); - }, [pathname]); - - if (isChecking) { - return null; - } - - return children; -}; diff --git a/frontend/src/app/api/chat/route.ts b/frontend/src/app/api/chat/route.ts deleted file mode 100644 index fd703555..00000000 --- a/frontend/src/app/api/chat/route.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { createOllama } from 'ollama-ai-provider'; -import { - streamText, - convertToCoreMessages, - CoreMessage, - UserContent, -} from 'ai'; - -export const runtime = 'edge'; -export const dynamic = 'force-dynamic'; - -export async function POST(req: Request) { - // Destructure request data - const { messages, selectedModel, data } = await req.json(); - - const initialMessages = messages.slice(0, -1); - const currentMessage = messages[messages.length - 1]; - - const ollama = createOllama({}); - - // Build message content array directly - const messageContent: UserContent = [ - { type: 'text', text: currentMessage.content }, - ]; - - // Add images if they exist - data?.images?.forEach((imageUrl: string) => { - const image = new URL(imageUrl); - messageContent.push({ type: 'image', image }); - }); - - // Stream text using the ollama model - const result = await streamText({ - model: ollama(selectedModel), - messages: [ - ...convertToCoreMessages(initialMessages), - { role: 'user', content: messageContent }, - ], - }); - - return result.toDataStreamResponse(); -} diff --git a/frontend/src/app/api/model/route.ts b/frontend/src/app/api/model/route.ts deleted file mode 100644 index ccc24928..00000000 --- a/frontend/src/app/api/model/route.ts +++ /dev/null @@ -1,47 +0,0 @@ -export async function POST(req: Request) { - const { name } = await req.json(); - - const ollamaUrl = - process.env.NEXT_PUBLIC_OLLAMA_URL || 'http://localhost:11434'; - - const response = await fetch(ollamaUrl + '/api/pull', { - method: 'POST', - body: JSON.stringify({ name }), - }); - - // Create a new ReadableStream from the response body - const stream = new ReadableStream({ - start(controller) { - if (!response.body) { - controller.close(); - return; - } - const reader = response.body.getReader(); - - function pump() { - reader - .read() - .then(({ done, value }) => { - if (done) { - controller.close(); - return; - } - // Enqueue the chunk of data to the controller - controller.enqueue(value); - pump(); - }) - .catch((error) => { - console.error('Error reading response body:', error); - controller.error(error); - }); - } - - pump(); - }, - }); - - // Set response headers and return the stream - const headers = new Headers(response.headers); - headers.set('Content-Type', 'application/json'); - return new Response(stream, { headers }); -} diff --git a/frontend/src/app/api/tags/route.ts b/frontend/src/app/api/tags/route.ts deleted file mode 100644 index 60567837..00000000 --- a/frontend/src/app/api/tags/route.ts +++ /dev/null @@ -1,9 +0,0 @@ -export const dynamic = 'force-dynamic'; -export const revalidate = 0; - -export async function GET(req: Request) { - const OLLAMA_URL = - process.env.NEXT_PUBLIC_OLLAMA_URL || 'http://localhost:11434'; - const res = await fetch(OLLAMA_URL + '/api/tags'); - return new Response(res.body, res); -} diff --git a/frontend/src/app/providers/AuthProvider.tsx b/frontend/src/app/providers/AuthProvider.tsx new file mode 100644 index 00000000..60547973 --- /dev/null +++ b/frontend/src/app/providers/AuthProvider.tsx @@ -0,0 +1,128 @@ +import { usePathname, useRouter } from 'next/navigation'; +import { useQuery } from '@apollo/client'; +import { CHECK_TOKEN_QUERY } from '@/graphql/request'; +import { LocalStore } from '@/lib/storage'; +import { useEffect, useState, useRef } from 'react'; +import { useTheme } from 'next-themes'; +import { Loader2 } from 'lucide-react'; +import { LoadingPage } from '@/components/global-loading'; + +const VALIDATION_TIMEOUT = 5000; + +interface AuthProviderProps { + children: React.ReactNode; +} + +export const AuthProvider = ({ children }: AuthProviderProps) => { + const router = useRouter(); + const pathname = usePathname(); + const [isAuthorized, setIsAuthorized] = useState(false); + const [isChecking, setIsChecking] = useState(true); + const publicRoutes = ['/login', '/register']; + const isRedirectingRef = useRef(false); + const timeoutRef = useRef(); + + const { refetch: checkToken } = useQuery(CHECK_TOKEN_QUERY, { + skip: true, + }); + + useEffect(() => { + let isMounted = true; + + const validateToken = async () => { + if (isRedirectingRef.current) { + return; + } + + if (publicRoutes.includes(pathname)) { + if (isMounted) { + setIsAuthorized(true); + setIsChecking(false); + } + return; + } + + if (isMounted) { + setIsChecking(true); + } + + const token = localStorage.getItem(LocalStore.accessToken); + + if (!token) { + isRedirectingRef.current = true; + router.replace('/login'); + if (isMounted) { + setIsChecking(false); + } + return; + } + + timeoutRef.current = setTimeout(() => { + if (isMounted && !isRedirectingRef.current) { + console.error('Token validation timeout'); + localStorage.removeItem(LocalStore.accessToken); + isRedirectingRef.current = true; + router.replace('/login'); + setIsChecking(false); + } + }, VALIDATION_TIMEOUT); + + try { + const { data } = await checkToken({ + input: { token }, + }); + + if (isMounted) { + if (!data?.checkToken) { + localStorage.removeItem(LocalStore.accessToken); + isRedirectingRef.current = true; + router.replace('/login'); + setIsAuthorized(false); + } else { + setIsAuthorized(true); + } + } + } catch (error) { + if (isMounted && !isRedirectingRef.current) { + console.error('Token validation error:', error); + localStorage.removeItem(LocalStore.accessToken); + isRedirectingRef.current = true; + router.replace('/login'); + setIsAuthorized(false); + } + } finally { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + if (isMounted) { + setIsChecking(false); + } + } + }; + + validateToken(); + + return () => { + isMounted = false; + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + }; + }, [pathname]); + + useEffect(() => { + if (publicRoutes.includes(pathname)) { + isRedirectingRef.current = false; + } + }, [pathname]); + + if (publicRoutes.includes(pathname)) { + return children; + } + + if (isChecking) { + return ; + } + + return isAuthorized ? children : ; +}; diff --git a/frontend/src/app/providers/BaseProvider.tsx b/frontend/src/app/providers/BaseProvider.tsx index 0018595e..fc97ad34 100644 --- a/frontend/src/app/providers/BaseProvider.tsx +++ b/frontend/src/app/providers/BaseProvider.tsx @@ -4,7 +4,7 @@ import client from '@/lib/client'; import { ApolloProvider } from '@apollo/client'; import { ThemeProvider } from 'next-themes'; import { Toaster } from 'sonner'; -import { AuthProvider } from '../AuthProvider'; +import { AuthProvider } from './AuthProvider'; interface ProvidersProps { children: React.ReactNode; @@ -13,13 +13,13 @@ interface ProvidersProps { // Base Provider for the app export function BaseProviders({ children }: ProvidersProps) { return ( - - - + + + {children} - - - + + + ); } diff --git a/frontend/src/components/global-loading.tsx b/frontend/src/components/global-loading.tsx new file mode 100644 index 00000000..9a171d3f --- /dev/null +++ b/frontend/src/components/global-loading.tsx @@ -0,0 +1,30 @@ +import { Loader2 } from 'lucide-react'; +import { useTheme } from 'next-themes'; + +export const LoadingPage = () => { + const { resolvedTheme } = useTheme(); + const isDark = resolvedTheme === 'dark'; + + return ( +
+
+ +

+ Loading... +

+
+
+ ); +};