chore: Updated UserRead schema to include pages_limit and pages_used fields

- Expanded installation options in README to include SurfSense Cloud as a new method.
- Updated UserRead schema to include pages_limit and pages_used fields.
- Added AnnouncementBanner component to the dashboard layout for improved user notifications.
- Refactored DashboardPage to utilize useUser hook for user state management.
- Integrated page usage display in AppSidebar to show user-specific page limits and usage.
- Removed deprecated apiClient code and replaced it with hooks for better API interaction.
This commit is contained in:
DESKTOP-RTLN3BA\$punk 2025-11-03 22:34:37 -08:00
parent a3a5b13f48
commit 4dd7e8fc1f
13 changed files with 456 additions and 339 deletions

View file

@ -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)

View file

@ -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):

View file

@ -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 (
<>
<AnnouncementBanner />
{children}
</>
);
}

View file

@ -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<User | null>(null);
const [isLoadingUser, setIsLoadingUser] = useState(true);
const [userError, setUserError] = useState<string | null>(null);
// Fetch user details
useEffect(() => {
const fetchUser = async () => {
try {
if (typeof window === "undefined") return;
try {
const userData = await apiClient.get<User>("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 = {

View file

@ -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 (
<div className="relative bg-gradient-to-r from-blue-600 to-blue-500 dark:from-blue-700 dark:to-blue-600 border-b border-blue-700 dark:border-blue-800">
<div className="container mx-auto px-4">
<div className="flex items-center justify-center gap-3 py-2.5">
<Info className="h-4 w-4 text-blue-50 flex-shrink-0" />
<p className="text-sm text-blue-50 text-center font-medium">
SurfSense is a work in progress.{" "}
<a
href="https://github.com/MODSetter/SurfSense/issues"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 underline decoration-blue-200 underline-offset-2 hover:decoration-white transition-colors"
>
Report issues on GitHub
<ExternalLink className="h-3 w-3" />
</a>
</p>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 shrink-0 text-blue-100 hover:text-white hover:bg-blue-700/50 dark:hover:bg-blue-800/50 absolute right-4"
onClick={() => setIsVisible(false)}
>
<X className="h-3.5 w-3.5" />
<span className="sr-only">Dismiss</span>
</Button>
</div>
</div>
</div>
);
}

View file

@ -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<SearchSpace | null>(null);
const [isLoadingChats, setIsLoadingChats] = useState(true);
const [isLoadingSearchSpace, setIsLoadingSearchSpace] = useState(true);
const [chatError, setChatError] = useState<string | null>(null);
const [searchSpaceError, setSearchSpaceError] = useState<string | null>(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<Chat[]>(
`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<SearchSpace>(
`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 <AppSidebar navSecondary={navSecondary} navMain={navMain} RecentChats={[]} />;
return (
<AppSidebar
navSecondary={navSecondary}
navMain={navMain}
RecentChats={[]}
pageUsage={pageUsage}
/>
);
}
return (
<>
<AppSidebar navSecondary={updatedNavSecondary} navMain={navMain} RecentChats={displayChats} />
<AppSidebar
navSecondary={updatedNavSecondary}
navMain={navMain}
RecentChats={displayChats}
pageUsage={pageUsage}
/>
{/* Delete Confirmation Dialog */}
<Dialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>

View file

@ -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<typeof Sidebar> {
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({
)}
</SidebarContent>
<SidebarFooter>
{pageUsage && (
<PageUsageDisplay pagesUsed={pageUsage.pagesUsed} pagesLimit={pageUsage.pagesLimit} />
)}
<NavSecondary items={processedNavSecondary} className="mt-auto" />
</SidebarFooter>
</Sidebar>

View file

@ -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 (
<SidebarGroup>
<SidebarGroupLabel className="group-data-[collapsible=icon]:hidden">
Page Usage
</SidebarGroupLabel>
<SidebarGroupContent>
<div className="space-y-2 px-2 py-2">
{isCollapsed ? (
// Show only a compact progress indicator when collapsed
<div className="flex justify-center">
<Progress value={usagePercentage} className="h-2 w-8" />
</div>
) : (
// Show full details when expanded
<>
<div className="flex justify-between items-center text-xs">
<span className="text-muted-foreground">
{pagesUsed.toLocaleString()} / {pagesLimit.toLocaleString()} pages
</span>
<span className="font-medium">{usagePercentage.toFixed(0)}%</span>
</div>
<Progress value={usagePercentage} className="h-2" />
<div className="flex items-start gap-2 pt-1">
<Mail className="h-3 w-3 text-muted-foreground mt-0.5 flex-shrink-0" />
<p className="text-[10px] text-muted-foreground leading-tight">
Contact{" "}
<a
href="mailto:rohan@surfsense.com"
className="text-primary hover:underline font-medium"
>
rohan@surfsense.com
</a>{" "}
to increase limits
</p>
</div>
</>
)}
</div>
</SidebarGroupContent>
</SidebarGroup>
);
}

View file

@ -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";

View file

@ -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<Chat[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 };
}

View file

@ -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<SearchSpace | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 };
}

View file

@ -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<User | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 };
}

View file

@ -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<Response> {
// 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<T>(path: string, options: RequestInit = {}): Promise<T> {
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<T>(path: string, data: any, options: RequestInit = {}): Promise<T> {
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<T>(path: string, data: any, options: RequestInit = {}): Promise<T> {
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<T>(path: string, options: RequestInit = {}): Promise<T> {
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();
},
};