diff --git a/README.md b/README.md index 9fe07eb35..a4442dcaf 100644 --- a/README.md +++ b/README.md @@ -141,19 +141,24 @@ Check out our public roadmap and contribute your ideas or feedback: ### Installation Options -SurfSense provides two installation methods: +SurfSense provides three options to get started: -1. **[Docker Installation](https://www.surfsense.net/docs/docker-installation)** - The easiest way to get SurfSense up and running with all dependencies containerized. +1. **[SurfSense Cloud](https://www.surfsense.com/login)** - The easiest way to try SurfSense without any setup. + - No installation required + - Instant access to all features + - Perfect for getting started quickly + +2. **[Docker Installation (Recommended for Self-Hosting)](https://www.surfsense.net/docs/docker-installation)** - Easy way to get SurfSense up and running with all dependencies containerized. - Includes pgAdmin for database management through a web UI - Supports environment variable customization via `.env` file - Flexible deployment options (full stack or core services only) - No need to manually edit configuration files between environments -2. **[Manual Installation (Recommended)](https://www.surfsense.net/docs/manual-installation)** - For users who prefer more control over their setup or need to customize their deployment. +3. **[Manual Installation](https://www.surfsense.net/docs/manual-installation)** - For users who prefer more control over their setup or need to customize their deployment. -Both installation guides include detailed OS-specific instructions for Windows, macOS, and Linux. +Docker and manual installation guides include detailed OS-specific instructions for Windows, macOS, and Linux. -Before installation, make sure to complete the [prerequisite setup steps](https://www.surfsense.net/docs/) including: +Before self-hosting installation, make sure to complete the [prerequisite setup steps](https://www.surfsense.net/docs/) including: - Auth setup - **File Processing ETL Service** (choose one): - Unstructured.io API key (supports 34+ formats) diff --git a/surfsense_backend/app/schemas/users.py b/surfsense_backend/app/schemas/users.py index de1169e4c..a8e0cfac8 100644 --- a/surfsense_backend/app/schemas/users.py +++ b/surfsense_backend/app/schemas/users.py @@ -4,7 +4,8 @@ from fastapi_users import schemas class UserRead(schemas.BaseUser[uuid.UUID]): - pass + pages_limit: int + pages_used: int class UserCreate(schemas.BaseUserCreate): diff --git a/surfsense_web/app/dashboard/layout.tsx b/surfsense_web/app/dashboard/layout.tsx index 55482d299..9b396e990 100644 --- a/surfsense_web/app/dashboard/layout.tsx +++ b/surfsense_web/app/dashboard/layout.tsx @@ -3,6 +3,7 @@ import { Loader2 } from "lucide-react"; import { useRouter } from "next/navigation"; import { useEffect, useState } from "react"; +import { AnnouncementBanner } from "@/components/announcement-banner"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; interface DashboardLayoutProps { @@ -40,5 +41,10 @@ export default function DashboardLayout({ children }: DashboardLayoutProps) { ); } - return <>{children}; + return ( + <> + + {children} + + ); } diff --git a/surfsense_web/app/dashboard/page.tsx b/surfsense_web/app/dashboard/page.tsx index 015cbd801..4c9e30232 100644 --- a/surfsense_web/app/dashboard/page.tsx +++ b/surfsense_web/app/dashboard/page.tsx @@ -6,7 +6,6 @@ import Image from "next/image"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; -import { useEffect, useState } from "react"; import { toast } from "sonner"; import { Logo } from "@/components/Logo"; import { ThemeTogglerComponent } from "@/components/theme/theme-toggle"; @@ -34,16 +33,8 @@ import { } from "@/components/ui/card"; import { Spotlight } from "@/components/ui/spotlight"; import { Tilt } from "@/components/ui/tilt"; +import { useUser } from "@/hooks"; import { useSearchSpaces } from "@/hooks/use-search-spaces"; -import { apiClient } from "@/lib/api"; - -interface User { - id: string; - email: string; - is_active: boolean; - is_superuser: boolean; - is_verified: boolean; -} /** * Formats a date string into a readable format @@ -163,35 +154,8 @@ const DashboardPage = () => { const { searchSpaces, loading, error, refreshSearchSpaces } = useSearchSpaces(); - // User state management - const [user, setUser] = useState(null); - const [isLoadingUser, setIsLoadingUser] = useState(true); - const [userError, setUserError] = useState(null); - // Fetch user details - useEffect(() => { - const fetchUser = async () => { - try { - if (typeof window === "undefined") return; - - try { - const userData = await apiClient.get("users/me"); - setUser(userData); - setUserError(null); - } catch (error) { - console.error("Error fetching user:", error); - setUserError(error instanceof Error ? error.message : "Unknown error occurred"); - } finally { - setIsLoadingUser(false); - } - } catch (error) { - console.error("Error in fetchUser:", error); - setIsLoadingUser(false); - } - }; - - fetchUser(); - }, []); + const { user, loading: isLoadingUser, error: userError } = useUser(); // Create user object for UserDropdown const customUser = { diff --git a/surfsense_web/components/announcement-banner.tsx b/surfsense_web/components/announcement-banner.tsx new file mode 100644 index 000000000..e9647085f --- /dev/null +++ b/surfsense_web/components/announcement-banner.tsx @@ -0,0 +1,42 @@ +"use client"; + +import { ExternalLink, Info, X } from "lucide-react"; +import { useState } from "react"; +import { Button } from "@/components/ui/button"; + +export function AnnouncementBanner() { + const [isVisible, setIsVisible] = useState(true); + + if (!isVisible) return null; + + return ( +
+
+
+ +

+ SurfSense is a work in progress.{" "} + + Report issues on GitHub + + +

+ +
+
+
+ ); +} diff --git a/surfsense_web/components/sidebar/AppSidebarProvider.tsx b/surfsense_web/components/sidebar/AppSidebarProvider.tsx index 18ec171f7..cfccf45a7 100644 --- a/surfsense_web/components/sidebar/AppSidebarProvider.tsx +++ b/surfsense_web/components/sidebar/AppSidebarProvider.tsx @@ -13,24 +13,7 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; -import { apiClient } from "@/lib/api"; - -interface Chat { - created_at: string; - id: number; - type: string; - title: string; - messages: string[]; - search_space_id: number; -} - -interface SearchSpace { - created_at: string; - id: number; - name: string; - description: string; - user_id: string; -} +import { useChats, useSearchSpace, useUser } from "@/hooks"; interface AppSidebarProviderProps { searchSpaceId: string; @@ -58,21 +41,25 @@ export function AppSidebarProvider({ }: AppSidebarProviderProps) { const t = useTranslations("dashboard"); const tCommon = useTranslations("common"); - const [recentChats, setRecentChats] = useState< - { - name: string; - url: string; - icon: string; - id: number; - search_space_id: number; - actions: { name: string; icon: string; onClick: () => void }[]; - }[] - >([]); - const [searchSpace, setSearchSpace] = useState(null); - const [isLoadingChats, setIsLoadingChats] = useState(true); - const [isLoadingSearchSpace, setIsLoadingSearchSpace] = useState(true); - const [chatError, setChatError] = useState(null); - const [searchSpaceError, setSearchSpaceError] = useState(null); + + // Use the new hooks + const { + chats, + loading: isLoadingChats, + error: chatError, + fetchChats: fetchRecentChats, + deleteChat, + } = useChats({ searchSpaceId, limit: 5, skip: 0 }); + + const { + searchSpace, + loading: isLoadingSearchSpace, + error: searchSpaceError, + fetchSearchSpace, + } = useSearchSpace({ searchSpaceId }); + + const { user } = useUser(); + const [showDeleteDialog, setShowDeleteDialog] = useState(false); const [chatToDelete, setChatToDelete] = useState<{ id: number; name: string } | null>(null); const [isDeleting, setIsDeleting] = useState(false); @@ -83,95 +70,32 @@ export function AppSidebarProvider({ setIsClient(true); }, []); - // Memoized fetch function for chats - const fetchRecentChats = useCallback(async () => { - try { - // Only run on client-side - if (typeof window === "undefined") return; - - const chats: Chat[] = await apiClient.get( - `api/v1/chats?limit=5&skip=0&search_space_id=${searchSpaceId}` - ); - - // Sort chats by created_at in descending order (newest first) - const sortedChats = chats.sort( - (a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime() - ); - - // Transform API response to the format expected by AppSidebar - const formattedChats = sortedChats.map((chat) => ({ - name: chat.title || `Chat ${chat.id}`, - url: `/dashboard/${chat.search_space_id}/researcher/${chat.id}`, - icon: "MessageCircleMore", - id: chat.id, - search_space_id: chat.search_space_id, - actions: [ - { - name: "Delete", - icon: "Trash2", - onClick: () => { - setChatToDelete({ id: chat.id, name: chat.title || `Chat ${chat.id}` }); - setShowDeleteDialog(true); - }, - }, - ], - })); - - setRecentChats(formattedChats); - setChatError(null); - } catch (error) { - console.error("Error fetching chats:", error); - setChatError(error instanceof Error ? error.message : "Unknown error occurred"); - setRecentChats([]); - } finally { - setIsLoadingChats(false); - } - }, [searchSpaceId]); - - // Memoized fetch function for search space - const fetchSearchSpace = useCallback(async () => { - try { - // Only run on client-side - if (typeof window === "undefined") return; - - const data: SearchSpace = await apiClient.get( - `api/v1/searchspaces/${searchSpaceId}` - ); - setSearchSpace(data); - setSearchSpaceError(null); - } catch (error) { - console.error("Error fetching search space:", error); - setSearchSpaceError(error instanceof Error ? error.message : "Unknown error occurred"); - } finally { - setIsLoadingSearchSpace(false); - } - }, [searchSpaceId]); - // Retry function const retryFetch = useCallback(() => { - setChatError(null); - setSearchSpaceError(null); - setIsLoadingChats(true); - setIsLoadingSearchSpace(true); fetchRecentChats(); fetchSearchSpace(); }, [fetchRecentChats, fetchSearchSpace]); - // Fetch recent chats - useEffect(() => { - fetchRecentChats(); - - // Set up a refresh interval (every 5 minutes) - const intervalId = setInterval(fetchRecentChats, 5 * 60 * 1000); - - // Clean up interval on component unmount - return () => clearInterval(intervalId); - }, [fetchRecentChats]); - - // Fetch search space details - useEffect(() => { - fetchSearchSpace(); - }, [fetchSearchSpace]); + // Transform API response to the format expected by AppSidebar + const recentChats = useMemo(() => { + return chats.map((chat) => ({ + name: chat.title || `Chat ${chat.id}`, + url: `/dashboard/${chat.search_space_id}/researcher/${chat.id}`, + icon: "MessageCircleMore", + id: chat.id, + search_space_id: chat.search_space_id, + actions: [ + { + name: "Delete", + icon: "Trash2", + onClick: () => { + setChatToDelete({ id: chat.id, name: chat.title || `Chat ${chat.id}` }); + setShowDeleteDialog(true); + }, + }, + ], + })); + }, [chats]); // Handle delete chat with better error handling const handleDeleteChat = useCallback(async () => { @@ -179,11 +103,7 @@ export function AppSidebarProvider({ try { setIsDeleting(true); - - await apiClient.delete(`api/v1/chats/${chatToDelete.id}`); - - // Update local state - setRecentChats((prev) => prev.filter((chat) => chat.id !== chatToDelete.id)); + await deleteChat(chatToDelete.id); } catch (error) { console.error("Error deleting chat:", error); // You could show a toast notification here @@ -192,7 +112,7 @@ export function AppSidebarProvider({ setShowDeleteDialog(false); setChatToDelete(null); } - }, [chatToDelete]); + }, [chatToDelete, deleteChat]); // Memoized fallback chats const fallbackChats = useMemo(() => { @@ -260,14 +180,34 @@ export function AppSidebarProvider({ tCommon, ]); + // Prepare page usage data + const pageUsage = user + ? { + pagesUsed: user.pages_used, + pagesLimit: user.pages_limit, + } + : undefined; + // Show loading state if not client-side if (!isClient) { - return ; + return ( + + ); } return ( <> - + {/* Delete Confirmation Dialog */} diff --git a/surfsense_web/components/sidebar/app-sidebar.tsx b/surfsense_web/components/sidebar/app-sidebar.tsx index 399ab8373..d2c3c4538 100644 --- a/surfsense_web/components/sidebar/app-sidebar.tsx +++ b/surfsense_web/components/sidebar/app-sidebar.tsx @@ -25,6 +25,7 @@ import { Logo } from "@/components/Logo"; import { NavMain } from "@/components/sidebar/nav-main"; import { NavProjects } from "@/components/sidebar/nav-projects"; import { NavSecondary } from "@/components/sidebar/nav-secondary"; +import { PageUsageDisplay } from "@/components/sidebar/page-usage-display"; import { Sidebar, SidebarContent, @@ -175,6 +176,10 @@ interface AppSidebarProps extends React.ComponentProps { email: string; avatar: string; }; + pageUsage?: { + pagesUsed: number; + pagesLimit: number; + }; } // Memoized AppSidebar component for better performance @@ -182,6 +187,7 @@ export const AppSidebar = memo(function AppSidebar({ navMain = defaultData.navMain, navSecondary = defaultData.navSecondary, RecentChats = defaultData.RecentChats, + pageUsage, ...props }: AppSidebarProps) { // Process navMain to resolve icon names to components @@ -246,6 +252,9 @@ export const AppSidebar = memo(function AppSidebar({ )} + {pageUsage && ( + + )} diff --git a/surfsense_web/components/sidebar/page-usage-display.tsx b/surfsense_web/components/sidebar/page-usage-display.tsx new file mode 100644 index 000000000..51e67d2f0 --- /dev/null +++ b/surfsense_web/components/sidebar/page-usage-display.tsx @@ -0,0 +1,63 @@ +"use client"; + +import { Mail } from "lucide-react"; +import { Progress } from "@/components/ui/progress"; +import { + SidebarGroup, + SidebarGroupContent, + SidebarGroupLabel, + useSidebar, +} from "@/components/ui/sidebar"; + +interface PageUsageDisplayProps { + pagesUsed: number; + pagesLimit: number; +} + +export function PageUsageDisplay({ pagesUsed, pagesLimit }: PageUsageDisplayProps) { + const { state } = useSidebar(); + const usagePercentage = (pagesUsed / pagesLimit) * 100; + const isCollapsed = state === "collapsed"; + + return ( + + + Page Usage + + +
+ {isCollapsed ? ( + // Show only a compact progress indicator when collapsed +
+ +
+ ) : ( + // Show full details when expanded + <> +
+ + {pagesUsed.toLocaleString()} / {pagesLimit.toLocaleString()} pages + + {usagePercentage.toFixed(0)}% +
+ +
+ +

+ Contact{" "} + + rohan@surfsense.com + {" "} + to increase limits +

+
+ + )} +
+
+
+ ); +} diff --git a/surfsense_web/hooks/index.ts b/surfsense_web/hooks/index.ts index 76d96f6c7..a1cece04d 100644 --- a/surfsense_web/hooks/index.ts +++ b/surfsense_web/hooks/index.ts @@ -1,3 +1,6 @@ +export * from "./use-chats"; export * from "./use-document-by-chunk"; export * from "./use-logs"; export * from "./use-search-source-connectors"; +export * from "./use-search-space"; +export * from "./use-user"; diff --git a/surfsense_web/hooks/use-chats.ts b/surfsense_web/hooks/use-chats.ts new file mode 100644 index 000000000..e6233e55b --- /dev/null +++ b/surfsense_web/hooks/use-chats.ts @@ -0,0 +1,124 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { toast } from "sonner"; + +interface Chat { + created_at: string; + id: number; + type: string; + title: string; + messages: string[]; + search_space_id: number; +} + +interface UseChatsOptions { + searchSpaceId: string | number; + limit?: number; + skip?: number; + autoFetch?: boolean; +} + +export function useChats({ + searchSpaceId, + limit = 5, + skip = 0, + autoFetch = true, +}: UseChatsOptions) { + const [chats, setChats] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchChats = useCallback(async () => { + try { + // Only run on client-side + if (typeof window === "undefined") return; + + setLoading(true); + const response = await fetch( + `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/chats?limit=${limit}&skip=${skip}&search_space_id=${searchSpaceId}`, + { + headers: { + Authorization: `Bearer ${localStorage.getItem("surfsense_bearer_token")}`, + }, + method: "GET", + } + ); + + if (response.status === 401) { + // Clear token and redirect to home + localStorage.removeItem("surfsense_bearer_token"); + window.location.href = "/"; + throw new Error("Unauthorized: Redirecting to login page"); + } + + if (!response.ok) { + throw new Error(`Failed to fetch chats: ${response.status}`); + } + + const data = await response.json(); + + // Sort chats by created_at in descending order (newest first) + const sortedChats = data.sort( + (a: Chat, b: Chat) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime() + ); + + setChats(sortedChats); + setError(null); + } catch (err: any) { + setError(err.message || "Failed to fetch chats"); + console.error("Error fetching chats:", err); + setChats([]); + } finally { + setLoading(false); + } + }, [searchSpaceId, limit, skip]); + + const deleteChat = useCallback(async (chatId: number) => { + try { + // Only run on client-side + if (typeof window === "undefined") return; + + const response = await fetch( + `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/chats/${chatId}`, + { + headers: { + Authorization: `Bearer ${localStorage.getItem("surfsense_bearer_token")}`, + }, + method: "DELETE", + } + ); + + if (response.status === 401) { + // Clear token and redirect to home + localStorage.removeItem("surfsense_bearer_token"); + window.location.href = "/"; + throw new Error("Unauthorized: Redirecting to login page"); + } + + if (!response.ok) { + throw new Error(`Failed to delete chat: ${response.status}`); + } + + // Update local state to remove the deleted chat + setChats((prev) => prev.filter((chat) => chat.id !== chatId)); + } catch (err: any) { + console.error("Error deleting chat:", err); + throw err; + } + }, []); + + useEffect(() => { + if (autoFetch) { + fetchChats(); + + // Set up a refresh interval (every 5 minutes) + const intervalId = setInterval(fetchChats, 5 * 60 * 1000); + + // Clean up interval on component unmount + return () => clearInterval(intervalId); + } + }, [autoFetch, fetchChats]); + + return { chats, loading, error, fetchChats, deleteChat }; +} diff --git a/surfsense_web/hooks/use-search-space.ts b/surfsense_web/hooks/use-search-space.ts new file mode 100644 index 000000000..2c512b954 --- /dev/null +++ b/surfsense_web/hooks/use-search-space.ts @@ -0,0 +1,69 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { toast } from "sonner"; + +interface SearchSpace { + created_at: string; + id: number; + name: string; + description: string; + user_id: string; +} + +interface UseSearchSpaceOptions { + searchSpaceId: string | number; + autoFetch?: boolean; +} + +export function useSearchSpace({ searchSpaceId, autoFetch = true }: UseSearchSpaceOptions) { + const [searchSpace, setSearchSpace] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchSearchSpace = useCallback(async () => { + try { + // Only run on client-side + if (typeof window === "undefined") return; + + setLoading(true); + const response = await fetch( + `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces/${searchSpaceId}`, + { + headers: { + Authorization: `Bearer ${localStorage.getItem("surfsense_bearer_token")}`, + }, + method: "GET", + } + ); + + if (response.status === 401) { + // Clear token and redirect to home + localStorage.removeItem("surfsense_bearer_token"); + window.location.href = "/"; + throw new Error("Unauthorized: Redirecting to login page"); + } + + if (!response.ok) { + throw new Error(`Failed to fetch search space: ${response.status}`); + } + + const data = await response.json(); + setSearchSpace(data); + setError(null); + } catch (err: any) { + setError(err.message || "Failed to fetch search space"); + console.error("Error fetching search space:", err); + } finally { + setLoading(false); + } + }, [searchSpaceId]); + + useEffect(() => { + if (autoFetch) { + fetchSearchSpace(); + } + }, [autoFetch, fetchSearchSpace]); + + return { searchSpace, loading, error, fetchSearchSpace }; +} diff --git a/surfsense_web/hooks/use-user.ts b/surfsense_web/hooks/use-user.ts new file mode 100644 index 000000000..23a23237b --- /dev/null +++ b/surfsense_web/hooks/use-user.ts @@ -0,0 +1,61 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { toast } from "sonner"; + +interface User { + id: string; + email: string; + is_active: boolean; + is_superuser: boolean; + is_verified: boolean; + pages_limit: number; + pages_used: number; +} + +export function useUser() { + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const fetchUser = async () => { + try { + // Only run on client-side + if (typeof window === "undefined") return; + + setLoading(true); + const response = await fetch(`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/users/me`, { + headers: { + Authorization: `Bearer ${localStorage.getItem("surfsense_bearer_token")}`, + }, + method: "GET", + }); + + if (response.status === 401) { + // Clear token and redirect to home + localStorage.removeItem("surfsense_bearer_token"); + window.location.href = "/"; + throw new Error("Unauthorized: Redirecting to login page"); + } + + if (!response.ok) { + throw new Error(`Failed to fetch user: ${response.status}`); + } + + const data = await response.json(); + setUser(data); + setError(null); + } catch (err: any) { + setError(err.message || "Failed to fetch user"); + console.error("Error fetching user:", err); + } finally { + setLoading(false); + } + }; + + fetchUser(); + }, []); + + return { user, loading, error }; +} diff --git a/surfsense_web/lib/api.ts b/surfsense_web/lib/api.ts deleted file mode 100644 index 6c52cac77..000000000 --- a/surfsense_web/lib/api.ts +++ /dev/null @@ -1,170 +0,0 @@ -import { toast } from "sonner"; - -/** - * Custom fetch wrapper that handles authentication and redirects to home page on 401 Unauthorized - * - * @param url - The URL to fetch - * @param options - Fetch options - * @returns The fetch response - */ -export async function fetchWithAuth(url: string, options: RequestInit = {}): Promise { - // Only run on client-side - if (typeof window === "undefined") { - return fetch(url, options); - } - - // Get token from localStorage - const token = localStorage.getItem("surfsense_bearer_token"); - - // Add authorization header if token exists - const headers = { - ...options.headers, - ...(token && { Authorization: `Bearer ${token}` }), - }; - - // Make the request - const response = await fetch(url, { - ...options, - headers, - }); - - // Handle 401 Unauthorized response - if (response.status === 401) { - // Show error toast - toast.error("Session expired. Please log in again."); - - // Clear token - localStorage.removeItem("surfsense_bearer_token"); - - // Redirect to home page - window.location.href = "/"; - - // Throw error to stop further processing - throw new Error("Unauthorized: Redirecting to login page"); - } - - return response; -} - -/** - * Get the full API URL - * - * @param path - The API path - * @returns The full API URL - */ -export function getApiUrl(path: string): string { - // Remove leading slash if present - const cleanPath = path.startsWith("/") ? path.slice(1) : path; - - // Get backend URL from environment variable - const baseUrl = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL; - - if (!baseUrl) { - console.error("NEXT_PUBLIC_FASTAPI_BACKEND_URL is not defined"); - return ""; - } - - // Combine base URL and path - return `${baseUrl}/${cleanPath}`; -} - -/** - * API client with methods for common operations - */ -export const apiClient = { - /** - * Make a GET request - * - * @param path - The API path - * @param options - Additional fetch options - * @returns The response data - */ - async get(path: string, options: RequestInit = {}): Promise { - const response = await fetchWithAuth(getApiUrl(path), { - method: "GET", - ...options, - }); - - if (!response.ok) { - const errorData = await response.json().catch(() => null); - throw new Error(`API error: ${response.status} ${errorData?.detail || response.statusText}`); - } - - return response.json(); - }, - - /** - * Make a POST request - * - * @param path - The API path - * @param data - The request body - * @param options - Additional fetch options - * @returns The response data - */ - async post(path: string, data: any, options: RequestInit = {}): Promise { - const response = await fetchWithAuth(getApiUrl(path), { - method: "POST", - headers: { - "Content-Type": "application/json", - ...options.headers, - }, - body: JSON.stringify(data), - ...options, - }); - - if (!response.ok) { - const errorData = await response.json().catch(() => null); - throw new Error(`API error: ${response.status} ${errorData?.detail || response.statusText}`); - } - - return response.json(); - }, - - /** - * Make a PUT request - * - * @param path - The API path - * @param data - The request body - * @param options - Additional fetch options - * @returns The response data - */ - async put(path: string, data: any, options: RequestInit = {}): Promise { - const response = await fetchWithAuth(getApiUrl(path), { - method: "PUT", - headers: { - "Content-Type": "application/json", - ...options.headers, - }, - body: JSON.stringify(data), - ...options, - }); - - if (!response.ok) { - const errorData = await response.json().catch(() => null); - throw new Error(`API error: ${response.status} ${errorData?.detail || response.statusText}`); - } - - return response.json(); - }, - - /** - * Make a DELETE request - * - * @param path - The API path - * @param options - Additional fetch options - * @returns The response data - */ - async delete(path: string, options: RequestInit = {}): Promise { - const response = await fetchWithAuth(getApiUrl(path), { - method: "DELETE", - ...options, - }); - - if (!response.ok) { - const errorData = await response.json().catch(() => null); - throw new Error(`API error: ${response.status} ${errorData?.detail || response.statusText}`); - } - - return response.json(); - }, -};