mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-06-20 21:18:13 +02:00
merge: upstream/dev with migration renumbering
This commit is contained in:
commit
a7145b2c63
176 changed files with 8791 additions and 3608 deletions
|
|
@ -1,14 +1,18 @@
|
|||
"use client";
|
||||
|
||||
import { usePathname } from "next/navigation";
|
||||
import { FooterNew } from "@/components/homepage/footer-new";
|
||||
import { Navbar } from "@/components/homepage/navbar";
|
||||
|
||||
export default function HomePageLayout({ children }: { children: React.ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
const isAuthPage = pathname === "/login" || pathname === "/register";
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-linear-to-b from-gray-50 to-gray-100 text-gray-900 dark:from-black dark:to-gray-900 dark:text-white overflow-x-hidden">
|
||||
<Navbar />
|
||||
{children}
|
||||
<FooterNew />
|
||||
{!isAuthPage && <FooterNew />}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { useTranslations } from "next-intl";
|
|||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { loginMutationAtom } from "@/atoms/auth/auth-mutation.atoms";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { getAuthErrorDetails, isNetworkError, shouldRetry } from "@/lib/auth-errors";
|
||||
import { AUTH_TYPE } from "@/lib/env-config";
|
||||
import { ValidationError } from "@/lib/error";
|
||||
|
|
@ -42,9 +43,6 @@ export function LocalLoginForm() {
|
|||
// Track login attempt
|
||||
trackLoginAttempt("local");
|
||||
|
||||
// Show loading toast
|
||||
const loadingToast = toast.loading(tCommon("loading"));
|
||||
|
||||
try {
|
||||
const data = await login({
|
||||
username,
|
||||
|
|
@ -62,8 +60,7 @@ export function LocalLoginForm() {
|
|||
|
||||
// Success toast
|
||||
toast.success(t("login_success"), {
|
||||
id: loadingToast,
|
||||
description: "Redirecting to dashboard...",
|
||||
description: "Redirecting to dashboard",
|
||||
duration: 2000,
|
||||
});
|
||||
|
||||
|
|
@ -76,7 +73,6 @@ export function LocalLoginForm() {
|
|||
trackLoginFailure("local", err.message);
|
||||
setError({ title: err.name, message: err.message });
|
||||
toast.error(err.name, {
|
||||
id: loadingToast,
|
||||
description: err.message,
|
||||
duration: 6000,
|
||||
});
|
||||
|
|
@ -106,7 +102,6 @@ export function LocalLoginForm() {
|
|||
|
||||
// Show error toast with conditional retry action
|
||||
const toastOptions: any = {
|
||||
id: loadingToast,
|
||||
description: errorDetails.description,
|
||||
duration: 6000,
|
||||
};
|
||||
|
|
@ -244,9 +239,16 @@ export function LocalLoginForm() {
|
|||
<button
|
||||
type="submit"
|
||||
disabled={isLoggingIn}
|
||||
className="w-full rounded-md bg-blue-600 px-4 py-1.5 md:py-2 text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 transition-all text-sm md:text-base"
|
||||
className="w-full rounded-md bg-blue-600 px-4 py-1.5 md:py-2 text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 transition-all text-sm md:text-base flex items-center justify-center gap-2"
|
||||
>
|
||||
{isLoggingIn ? tCommon("loading") : t("sign_in")}
|
||||
{isLoggingIn ? (
|
||||
<>
|
||||
<Spinner size="sm" className="text-white" />
|
||||
<span>{t("signing_in")}</span>
|
||||
</>
|
||||
) : (
|
||||
t("sign_in")
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
"use client";
|
||||
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Suspense, useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Logo } from "@/components/Logo";
|
||||
import { useGlobalLoadingEffect } from "@/hooks/use-global-loading";
|
||||
import { getAuthErrorDetails, shouldRetry } from "@/lib/auth-errors";
|
||||
import { AUTH_TYPE } from "@/lib/env-config";
|
||||
import { AmbientBackground } from "./AmbientBackground";
|
||||
|
|
@ -66,7 +66,11 @@ function LoginContent() {
|
|||
});
|
||||
|
||||
// Show toast with conditional retry action
|
||||
const toastOptions: any = {
|
||||
const toastOptions: {
|
||||
description: string;
|
||||
duration: number;
|
||||
action?: { label: string; onClick: () => void };
|
||||
} = {
|
||||
description: errorDescription,
|
||||
duration: 6000,
|
||||
};
|
||||
|
|
@ -95,20 +99,12 @@ function LoginContent() {
|
|||
setIsLoading(false);
|
||||
}, [searchParams, t, tCommon]);
|
||||
|
||||
// Show loading state while determining auth type
|
||||
// Use global loading screen for auth type determination - spinner animation won't reset
|
||||
useGlobalLoadingEffect(isLoading, tCommon("loading"), "login");
|
||||
|
||||
// Show nothing while loading - the GlobalLoadingProvider handles the loading UI
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="relative w-full overflow-hidden">
|
||||
<AmbientBackground />
|
||||
<div className="mx-auto flex h-screen max-w-lg flex-col items-center justify-center">
|
||||
<Logo className="rounded-md" />
|
||||
<div className="mt-8 flex items-center space-x-2">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
<span className="text-muted-foreground">{tCommon("loading")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (authType === "GOOGLE") {
|
||||
|
|
@ -189,23 +185,10 @@ function LoginContent() {
|
|||
);
|
||||
}
|
||||
|
||||
// Loading fallback for Suspense
|
||||
const LoadingFallback = () => (
|
||||
<div className="relative w-full overflow-hidden">
|
||||
<AmbientBackground />
|
||||
<div className="mx-auto flex h-screen max-w-lg flex-col items-center justify-center">
|
||||
<Logo className="rounded-md" />
|
||||
<div className="mt-8 flex items-center space-x-2">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
<span className="text-muted-foreground">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default function LoginPage() {
|
||||
// Suspense fallback returns null - the GlobalLoadingProvider handles the loading UI
|
||||
return (
|
||||
<Suspense fallback={<LoadingFallback />}>
|
||||
<Suspense fallback={null}>
|
||||
<LoginContent />
|
||||
</Suspense>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { useEffect, useState } from "react";
|
|||
import { toast } from "sonner";
|
||||
import { registerMutationAtom } from "@/atoms/auth/auth-mutation.atoms";
|
||||
import { Logo } from "@/components/Logo";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { getAuthErrorDetails, isNetworkError, shouldRetry } from "@/lib/auth-errors";
|
||||
import { AUTH_TYPE } from "@/lib/env-config";
|
||||
import { AppError, ValidationError } from "@/lib/error";
|
||||
|
|
@ -60,9 +61,6 @@ export default function RegisterPage() {
|
|||
// Track registration attempt
|
||||
trackRegistrationAttempt();
|
||||
|
||||
// Show loading toast
|
||||
const loadingToast = toast.loading(t("creating_account"));
|
||||
|
||||
try {
|
||||
await register({
|
||||
email,
|
||||
|
|
@ -77,7 +75,6 @@ export default function RegisterPage() {
|
|||
|
||||
// Success toast
|
||||
toast.success(t("register_success"), {
|
||||
id: loadingToast,
|
||||
description: t("redirecting_login"),
|
||||
duration: 2000,
|
||||
});
|
||||
|
|
@ -95,7 +92,6 @@ export default function RegisterPage() {
|
|||
trackRegistrationFailure("Registration disabled");
|
||||
setError({ title: "Registration is disabled", message: friendlyMessage });
|
||||
toast.error("Registration is disabled", {
|
||||
id: loadingToast,
|
||||
description: friendlyMessage,
|
||||
duration: 6000,
|
||||
});
|
||||
|
|
@ -109,7 +105,6 @@ export default function RegisterPage() {
|
|||
trackRegistrationFailure(err.message);
|
||||
setError({ title: err.name, message: err.message });
|
||||
toast.error(err.name, {
|
||||
id: loadingToast,
|
||||
description: err.message,
|
||||
duration: 6000,
|
||||
});
|
||||
|
|
@ -137,7 +132,6 @@ export default function RegisterPage() {
|
|||
|
||||
// Show error toast with conditional retry action
|
||||
const toastOptions: any = {
|
||||
id: loadingToast,
|
||||
description: errorDetails.description,
|
||||
duration: 6000,
|
||||
};
|
||||
|
|
@ -295,9 +289,16 @@ export default function RegisterPage() {
|
|||
<button
|
||||
type="submit"
|
||||
disabled={isRegistering}
|
||||
className="w-full rounded-md bg-blue-600 px-4 py-1.5 md:py-2 text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 transition-all text-sm md:text-base"
|
||||
className="w-full rounded-md bg-blue-600 px-4 py-1.5 md:py-2 text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 transition-all text-sm md:text-base flex items-center justify-center gap-2"
|
||||
>
|
||||
{isRegistering ? t("creating_account_btn") : t("register")}
|
||||
{isRegistering ? (
|
||||
<>
|
||||
<Spinner size="sm" className="text-white" />
|
||||
<span>{t("creating_account_btn")}</span>
|
||||
</>
|
||||
) : (
|
||||
t("register")
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
|
|
|
|||
14
surfsense_web/app/auth/callback/loading.tsx
Normal file
14
surfsense_web/app/auth/callback/loading.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useGlobalLoadingEffect } from "@/hooks/use-global-loading";
|
||||
|
||||
export default function AuthCallbackLoading() {
|
||||
const t = useTranslations("auth");
|
||||
|
||||
// Use global loading - spinner animation won't reset when page transitions
|
||||
useGlobalLoadingEffect(true, t("processing_authentication"), "default");
|
||||
|
||||
// Return null - the GlobalLoadingProvider handles the loading UI
|
||||
return null;
|
||||
}
|
||||
|
|
@ -1,23 +1,18 @@
|
|||
"use client";
|
||||
|
||||
import { Suspense } from "react";
|
||||
import TokenHandler from "@/components/TokenHandler";
|
||||
|
||||
export default function AuthCallbackPage() {
|
||||
// Suspense fallback returns null - the GlobalLoadingProvider handles the loading UI
|
||||
// TokenHandler uses useGlobalLoadingEffect to show the loading screen
|
||||
return (
|
||||
<div className="container mx-auto p-4">
|
||||
<h1 className="text-2xl font-bold mb-4">Authentication Callback</h1>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex items-center justify-center min-h-[200px]">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"></div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<TokenHandler
|
||||
redirectPath="/dashboard"
|
||||
tokenParamName="token"
|
||||
storageKey="surfsense_bearer_token"
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
<Suspense fallback={null}>
|
||||
<TokenHandler
|
||||
redirectPath="/dashboard"
|
||||
tokenParamName="token"
|
||||
storageKey="surfsense_bearer_token"
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { useAtomValue, useSetAtom } from "jotai";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useParams, usePathname, useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import type React from "react";
|
||||
|
|
@ -19,6 +18,7 @@ import { DashboardBreadcrumb } from "@/components/dashboard-breadcrumb";
|
|||
import { LayoutDataProvider } from "@/components/layout";
|
||||
import { OnboardingTour } from "@/components/onboarding-tour";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { useGlobalLoadingEffect } from "@/hooks/use-global-loading";
|
||||
|
||||
export function DashboardClientLayout({
|
||||
children,
|
||||
|
|
@ -146,31 +146,22 @@ export function DashboardClientLayout({
|
|||
setActiveSearchSpaceIdState(activeSeacrhSpaceId);
|
||||
}, [search_space_id, setActiveSearchSpaceIdState]);
|
||||
|
||||
if (
|
||||
// Determine if we should show loading
|
||||
const shouldShowLoading =
|
||||
(!hasCheckedOnboarding &&
|
||||
(loading || accessLoading || globalConfigsLoading) &&
|
||||
!isOnboardingPage) ||
|
||||
isAutoConfiguring
|
||||
) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen space-y-4">
|
||||
<Card className="w-[350px] bg-background/60 backdrop-blur-sm">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-xl font-medium">
|
||||
{isAutoConfiguring ? "Setting up AI..." : t("loading_config")}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{isAutoConfiguring
|
||||
? "Auto-configuring with available settings"
|
||||
: t("checking_llm_prefs")}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex justify-center py-6">
|
||||
<Loader2 className="h-12 w-12 text-primary animate-spin" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
isAutoConfiguring;
|
||||
|
||||
// Use global loading screen - spinner animation won't reset
|
||||
useGlobalLoadingEffect(
|
||||
shouldShowLoading,
|
||||
isAutoConfiguring ? t("setting_up_ai") : t("checking_llm_prefs"),
|
||||
"default"
|
||||
);
|
||||
|
||||
if (shouldShowLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (error && !hasCheckedOnboarding && !isOnboardingPage) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { ChevronDown, ChevronUp, FileX, Loader2, Plus } from "lucide-react";
|
||||
import { ChevronDown, ChevronUp, FileX, Plus } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
|
@ -9,6 +9,7 @@ import { useDocumentUploadDialog } from "@/components/assistant-ui/document-uplo
|
|||
import { DocumentViewer } from "@/components/document-viewer";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
|
|
@ -114,7 +115,7 @@ export function DocumentsTableShell({
|
|||
{loading ? (
|
||||
<div className="flex h-[400px] w-full items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
<Spinner size="lg" className="text-primary" />
|
||||
<p className="text-sm text-muted-foreground">{t("loading")}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -209,7 +209,7 @@ export function RowActions({
|
|||
disabled={isDeleting}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{isDeleting ? "Deleting..." : "Delete"}
|
||||
{isDeleting ? "Deleting" : "Delete"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useAtom } from "jotai";
|
||||
import { AlertCircle, ArrowLeft, FileText, Loader2, Save } from "lucide-react";
|
||||
import { AlertCircle, ArrowLeft, FileText, Save } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
|
@ -21,6 +20,7 @@ import {
|
|||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { notesApiService } from "@/lib/apis/notes-api.service";
|
||||
import { authenticatedFetch, getBearerToken, redirectToLogin } from "@/lib/auth-utils";
|
||||
|
||||
|
|
@ -78,7 +78,6 @@ function extractTitleFromBlockNote(blocknoteDocument: BlockNoteDocument): string
|
|||
export default function EditorPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const documentId = params.documentId as string;
|
||||
const searchSpaceId = Number(params.search_space_id);
|
||||
const isNewNote = documentId === "new";
|
||||
|
|
@ -349,8 +348,8 @@ export default function EditorPage() {
|
|||
<div className="flex items-center justify-center min-h-[400px] p-6">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<Loader2 className="h-12 w-12 text-primary animate-spin mb-4" />
|
||||
<p className="text-muted-foreground">Loading editor...</p>
|
||||
<Spinner size="xl" className="text-primary mb-4" />
|
||||
<p className="text-muted-foreground">Loading editor</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
|
@ -437,7 +436,7 @@ export default function EditorPage() {
|
|||
>
|
||||
{saving ? (
|
||||
<>
|
||||
<Loader2 className="h-3.5 w-3.5 md:h-4 md:w-4 animate-spin" />
|
||||
<Spinner size="sm" className="h-3.5 w-3.5 md:h-4 md:w-4" />
|
||||
<span className="text-xs md:text-sm">{isNewNote ? "Creating" : "Saving"}</span>
|
||||
</>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -0,0 +1,210 @@
|
|||
"use client";
|
||||
|
||||
import { IconCalendar, IconMailFilled } from "@tabler/icons-react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Check, ExternalLink, Gift, Loader2, Mail, Star } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import Link from "next/link";
|
||||
import { useEffect } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import type { IncentiveTaskInfo } from "@/contracts/types/incentive-tasks.types";
|
||||
import { incentiveTasksApiService } from "@/lib/apis/incentive-tasks-api.service";
|
||||
import {
|
||||
trackIncentiveContactOpened,
|
||||
trackIncentivePageViewed,
|
||||
trackIncentiveTaskClicked,
|
||||
trackIncentiveTaskCompleted,
|
||||
} from "@/lib/posthog/events";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export default function MorePagesPage() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Track page view on mount
|
||||
useEffect(() => {
|
||||
trackIncentivePageViewed();
|
||||
}, []);
|
||||
|
||||
// Fetch tasks from API
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["incentive-tasks"],
|
||||
queryFn: () => incentiveTasksApiService.getTasks(),
|
||||
});
|
||||
|
||||
// Mutation to complete a task
|
||||
const completeMutation = useMutation({
|
||||
mutationFn: incentiveTasksApiService.completeTask,
|
||||
onSuccess: (response, taskType) => {
|
||||
if (response.success) {
|
||||
toast.success(response.message);
|
||||
// Track task completion
|
||||
const task = data?.tasks.find((t) => t.task_type === taskType);
|
||||
if (task) {
|
||||
trackIncentiveTaskCompleted(taskType, task.pages_reward);
|
||||
}
|
||||
// Invalidate queries to refresh data
|
||||
queryClient.invalidateQueries({ queryKey: ["incentive-tasks"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["user"] });
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
toast.error("Failed to complete task. Please try again.");
|
||||
},
|
||||
});
|
||||
|
||||
const handleTaskClick = (task: IncentiveTaskInfo) => {
|
||||
if (!task.completed) {
|
||||
trackIncentiveTaskClicked(task.task_type);
|
||||
completeMutation.mutate(task.task_type);
|
||||
}
|
||||
};
|
||||
|
||||
const allCompleted = data?.tasks.every((t) => t.completed) ?? false;
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[calc(100vh-64px)] items-center justify-center px-4 py-8">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="w-full max-w-md"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="mb-6 text-center">
|
||||
<Gift className="mx-auto mb-3 h-8 w-8 text-primary" />
|
||||
<h2 className="text-xl font-bold tracking-tight">Get More Pages</h2>
|
||||
<p className="text-sm text-muted-foreground">Complete tasks to earn additional pages</p>
|
||||
</div>
|
||||
|
||||
{/* Tasks */}
|
||||
{isLoading ? (
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-3 p-3">
|
||||
<Skeleton className="h-9 w-9 rounded-full" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<Skeleton className="h-3 w-1/4" />
|
||||
</div>
|
||||
<Skeleton className="h-8 w-16" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{data?.tasks.map((task) => (
|
||||
<Card
|
||||
key={task.task_type}
|
||||
className={cn("transition-colors", task.completed && "bg-muted/50")}
|
||||
>
|
||||
<CardContent className="flex items-center gap-3 p-3">
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-9 w-9 shrink-0 items-center justify-center rounded-full",
|
||||
task.completed ? "bg-primary text-primary-foreground" : "bg-muted"
|
||||
)}
|
||||
>
|
||||
{task.completed ? <Check className="h-4 w-4" /> : <Star className="h-4 w-4" />}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p
|
||||
className={cn(
|
||||
"text-sm font-medium",
|
||||
task.completed && "text-muted-foreground line-through"
|
||||
)}
|
||||
>
|
||||
{task.title}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">+{task.pages_reward} pages</p>
|
||||
</div>
|
||||
<Button
|
||||
variant={task.completed ? "ghost" : "outline"}
|
||||
size="sm"
|
||||
disabled={task.completed || completeMutation.isPending}
|
||||
onClick={() => handleTaskClick(task)}
|
||||
asChild={!task.completed}
|
||||
>
|
||||
{task.completed ? (
|
||||
<span>Done</span>
|
||||
) : (
|
||||
<a
|
||||
href={task.action_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="gap-1"
|
||||
>
|
||||
{completeMutation.isPending ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
Go
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</>
|
||||
)}
|
||||
</a>
|
||||
)}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Contact */}
|
||||
<Separator className="my-6" />
|
||||
<div className="text-center">
|
||||
<p className="mb-3 text-sm text-muted-foreground">
|
||||
{allCompleted ? "Thanks! Need even more pages?" : "Need more pages?"}
|
||||
</p>
|
||||
<Dialog onOpenChange={(open) => open && trackIncentiveContactOpened()}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="gap-2">
|
||||
<Mail className="h-4 w-4" />
|
||||
Contact Us
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Contact Us</DialogTitle>
|
||||
<DialogDescription>Schedule a meeting or send us an email.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col items-center gap-4 py-4">
|
||||
<Link
|
||||
href="https://calendly.com/eric-surfsense/surfsense-meeting"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex w-full items-center justify-center gap-2 rounded-lg bg-primary px-4 py-2.5 text-sm font-medium text-primary-foreground transition hover:bg-primary/90"
|
||||
>
|
||||
<IconCalendar className="h-4 w-4" />
|
||||
Schedule a Meeting
|
||||
</Link>
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<span className="h-px w-8 bg-border" />
|
||||
<span className="text-xs">or</span>
|
||||
<span className="h-px w-8 bg-border" />
|
||||
</div>
|
||||
<Link
|
||||
href="mailto:eric@surfsense.com"
|
||||
className="flex items-center gap-2 text-sm text-muted-foreground transition hover:text-foreground"
|
||||
>
|
||||
<IconMailFilled className="h-4 w-4" />
|
||||
eric@surfsense.com
|
||||
</Link>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ import {
|
|||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useAtomValue, useSetAtom } from "jotai";
|
||||
import { useParams, useSearchParams } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
|
|
@ -34,6 +35,7 @@ import { GeneratePodcastToolUI } from "@/components/tool-ui/generate-podcast";
|
|||
import { LinkPreviewToolUI } from "@/components/tool-ui/link-preview";
|
||||
import { ScrapeWebpageToolUI } from "@/components/tool-ui/scrape-webpage";
|
||||
import { RecallMemoryToolUI, SaveMemoryToolUI } from "@/components/tool-ui/user-memory";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { useChatSessionStateSync } from "@/hooks/use-chat-session-state";
|
||||
import { useMessagesElectric } from "@/hooks/use-messages-electric";
|
||||
// import { WriteTodosToolUI } from "@/components/tool-ui/write-todos";
|
||||
|
|
@ -132,6 +134,7 @@ interface ThinkingStepData {
|
|||
}
|
||||
|
||||
export default function NewChatPage() {
|
||||
const t = useTranslations("dashboard");
|
||||
const params = useParams();
|
||||
const queryClient = useQueryClient();
|
||||
const [isInitializing, setIsInitializing] = useState(true);
|
||||
|
|
@ -1379,8 +1382,9 @@ export default function NewChatPage() {
|
|||
// Show loading state only when loading an existing thread
|
||||
if (isInitializing) {
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-64px)] items-center justify-center">
|
||||
<div className="text-muted-foreground">Loading chat...</div>
|
||||
<div className="flex h-[calc(100vh-64px)] flex-col items-center justify-center gap-4">
|
||||
<Spinner size="lg" />
|
||||
<div className="text-sm text-muted-foreground">{t("loading_chat")}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { useAtomValue } from "jotai";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
|
@ -17,6 +16,7 @@ import {
|
|||
import { Logo } from "@/components/Logo";
|
||||
import { LLMConfigForm, type LLMConfigFormData } from "@/components/shared/llm-config-form";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { getBearerToken, redirectToLogin } from "@/lib/auth-utils";
|
||||
|
||||
export default function OnboardPage() {
|
||||
|
|
@ -156,7 +156,7 @@ export default function OnboardPage() {
|
|||
<div className="relative">
|
||||
<div className="absolute inset-0 blur-3xl bg-gradient-to-r from-violet-500/20 to-cyan-500/20 rounded-full" />
|
||||
<div className="relative flex items-center justify-center w-24 h-24 mx-auto rounded-2xl bg-gradient-to-br from-violet-500 to-purple-600 shadow-2xl shadow-violet-500/25">
|
||||
<Loader2 className="h-12 w-12 text-white animate-spin" />
|
||||
<Spinner size="xl" className="text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import {
|
|||
Bot,
|
||||
Brain,
|
||||
ChevronRight,
|
||||
FileText,
|
||||
type LucideIcon,
|
||||
Menu,
|
||||
MessageSquare,
|
||||
|
|
@ -15,6 +16,7 @@ import { AnimatePresence, motion } from "motion/react";
|
|||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { GeneralSettingsManager } from "@/components/settings/general-settings-manager";
|
||||
import { LLMRoleManager } from "@/components/settings/llm-role-manager";
|
||||
import { ModelConfigManager } from "@/components/settings/model-config-manager";
|
||||
import { PromptConfigManager } from "@/components/settings/prompt-config-manager";
|
||||
|
|
@ -30,6 +32,12 @@ interface SettingsNavItem {
|
|||
}
|
||||
|
||||
const settingsNavItems: SettingsNavItem[] = [
|
||||
{
|
||||
id: "general",
|
||||
labelKey: "nav_general",
|
||||
descriptionKey: "nav_general_desc",
|
||||
icon: FileText,
|
||||
},
|
||||
{
|
||||
id: "models",
|
||||
labelKey: "nav_agent_configs",
|
||||
|
|
@ -262,6 +270,9 @@ function SettingsContent({
|
|||
ease: [0.4, 0, 0.2, 1],
|
||||
}}
|
||||
>
|
||||
{activeSection === "general" && (
|
||||
<GeneralSettingsManager searchSpaceId={searchSpaceId} />
|
||||
)}
|
||||
{activeSection === "models" && <ModelConfigManager searchSpaceId={searchSpaceId} />}
|
||||
{activeSection === "roles" && <LLMRoleManager searchSpaceId={searchSpaceId} />}
|
||||
{activeSection === "prompts" && <PromptConfigManager searchSpaceId={searchSpaceId} />}
|
||||
|
|
@ -277,7 +288,7 @@ export default function SettingsPage() {
|
|||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const searchSpaceId = Number(params.search_space_id);
|
||||
const [activeSection, setActiveSection] = useState("models");
|
||||
const [activeSection, setActiveSection] = useState("general");
|
||||
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
|
||||
|
||||
// Track settings section view
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,8 +1,8 @@
|
|||
"use client";
|
||||
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { useGlobalLoadingEffect } from "@/hooks/use-global-loading";
|
||||
import { getBearerToken, redirectToLogin } from "@/lib/auth-utils";
|
||||
|
||||
interface DashboardLayoutProps {
|
||||
|
|
@ -10,8 +10,12 @@ interface DashboardLayoutProps {
|
|||
}
|
||||
|
||||
export default function DashboardLayout({ children }: DashboardLayoutProps) {
|
||||
const t = useTranslations("dashboard");
|
||||
const [isCheckingAuth, setIsCheckingAuth] = useState(true);
|
||||
|
||||
// Use the global loading screen - spinner animation won't reset
|
||||
useGlobalLoadingEffect(isCheckingAuth, t("checking_auth"), "default");
|
||||
|
||||
useEffect(() => {
|
||||
// Check if user is authenticated
|
||||
const token = getBearerToken();
|
||||
|
|
@ -23,21 +27,9 @@ export default function DashboardLayout({ children }: DashboardLayoutProps) {
|
|||
setIsCheckingAuth(false);
|
||||
}, []);
|
||||
|
||||
// Show loading screen while checking authentication
|
||||
// Return null while loading - the global provider handles the loading UI
|
||||
if (isCheckingAuth) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen space-y-4">
|
||||
<Card className="w-[350px] bg-background/60 backdrop-blur-sm">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-xl font-medium">Loading Dashboard</CardTitle>
|
||||
<CardDescription>Checking authentication...</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex justify-center py-6">
|
||||
<Loader2 className="h-12 w-12 text-primary animate-spin" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
|
|||
14
surfsense_web/app/dashboard/loading.tsx
Normal file
14
surfsense_web/app/dashboard/loading.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useGlobalLoadingEffect } from "@/hooks/use-global-loading";
|
||||
|
||||
export default function DashboardLoading() {
|
||||
const t = useTranslations("common");
|
||||
|
||||
// Use global loading - spinner animation won't reset when page transitions
|
||||
useGlobalLoadingEffect(true, t("loading"), "default");
|
||||
|
||||
// Return null - the GlobalLoadingProvider handles the loading UI
|
||||
return null;
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { useAtomValue } from "jotai";
|
||||
import { AlertCircle, Loader2, Plus, Search } from "lucide-react";
|
||||
import { AlertCircle, Plus, Search } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
|
@ -18,37 +18,7 @@ import {
|
|||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
|
||||
function LoadingScreen() {
|
||||
const t = useTranslations("dashboard");
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center gap-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<Card className="w-full max-w-[350px] bg-background/60 backdrop-blur-sm">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-xl font-medium">{t("loading")}</CardTitle>
|
||||
<CardDescription>{t("fetching_spaces")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex justify-center py-6">
|
||||
<motion.div
|
||||
animate={{ rotate: 360 }}
|
||||
transition={{ duration: 1.5, repeat: Number.POSITIVE_INFINITY, ease: "linear" }}
|
||||
>
|
||||
<Loader2 className="h-12 w-12 text-primary" />
|
||||
</motion.div>
|
||||
</CardContent>
|
||||
<CardFooter className="border-t pt-4 text-sm text-muted-foreground">
|
||||
{t("may_take_moment")}
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import { useGlobalLoadingEffect } from "@/hooks/use-global-loading";
|
||||
|
||||
function ErrorScreen({ message }: { message: string }) {
|
||||
const t = useTranslations("dashboard");
|
||||
|
|
@ -121,6 +91,7 @@ export default function DashboardPage() {
|
|||
const router = useRouter();
|
||||
const [showCreateDialog, setShowCreateDialog] = useState(false);
|
||||
|
||||
const t = useTranslations("dashboard");
|
||||
const { data: searchSpaces = [], isLoading, error } = useAtomValue(searchSpacesAtom);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -131,11 +102,16 @@ export default function DashboardPage() {
|
|||
}
|
||||
}, [isLoading, searchSpaces, router]);
|
||||
|
||||
if (isLoading) return <LoadingScreen />;
|
||||
// Show loading while fetching or while we have spaces and are about to redirect
|
||||
const shouldShowLoading = isLoading || searchSpaces.length > 0;
|
||||
|
||||
// Use global loading screen - spinner animation won't reset
|
||||
useGlobalLoadingEffect(shouldShowLoading, t("fetching_spaces"), "default");
|
||||
|
||||
if (error) return <ErrorScreen message={error?.message || "Failed to load search spaces"} />;
|
||||
|
||||
if (searchSpaces.length > 0) {
|
||||
return <LoadingScreen />;
|
||||
if (shouldShowLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { useAtomValue } from "jotai";
|
||||
import { Loader2, Menu, User } from "lucide-react";
|
||||
import { Menu, User } from "lucide-react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEffect, useState } from "react";
|
||||
|
|
@ -11,6 +11,7 @@ import { currentUserAtom } from "@/atoms/user/user-query.atoms";
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
|
||||
interface ProfileContentProps {
|
||||
onMenuClick: () => void;
|
||||
|
|
@ -129,7 +130,7 @@ export function ProfileContent({ onMenuClick }: ProfileContentProps) {
|
|||
>
|
||||
{isUserLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
<Spinner size="md" className="text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
|
|
@ -166,7 +167,7 @@ export function ProfileContent({ onMenuClick }: ProfileContentProps) {
|
|||
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" disabled={isPending || !hasChanges}>
|
||||
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{isPending && <Spinner size="sm" className="mr-2" />}
|
||||
{t("profile_save")}
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -6,8 +6,6 @@ import {
|
|||
AlertCircle,
|
||||
ArrowRight,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
Loader2,
|
||||
LogIn,
|
||||
Shield,
|
||||
Sparkles,
|
||||
|
|
@ -30,6 +28,7 @@ import {
|
|||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import type { AcceptInviteResponse } from "@/contracts/types/invites.types";
|
||||
import { invitesApiService } from "@/lib/apis/invites-api.service";
|
||||
import { getBearerToken } from "@/lib/auth-utils";
|
||||
|
|
@ -164,7 +163,7 @@ export default function InviteAcceptPage() {
|
|||
animate={{ rotate: 360 }}
|
||||
transition={{ duration: 1, repeat: Infinity, ease: "linear" }}
|
||||
>
|
||||
<Loader2 className="h-12 w-12 text-primary" />
|
||||
<Spinner size="xl" className="text-primary" />
|
||||
</motion.div>
|
||||
<p className="mt-4 text-muted-foreground">Loading invite details...</p>
|
||||
</CardContent>
|
||||
|
|
@ -353,7 +352,7 @@ export default function InviteAcceptPage() {
|
|||
<Button className="flex-1 gap-2" onClick={handleAccept} disabled={accepting}>
|
||||
{accepting ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<Spinner size="sm" />
|
||||
Accepting...
|
||||
</>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import "./globals.css";
|
|||
import { RootProvider } from "fumadocs-ui/provider/next";
|
||||
import { Roboto } from "next/font/google";
|
||||
import { ElectricProvider } from "@/components/providers/ElectricProvider";
|
||||
import { GlobalLoadingProvider } from "@/components/providers/GlobalLoadingProvider";
|
||||
import { I18nProvider } from "@/components/providers/I18nProvider";
|
||||
import { PostHogProvider } from "@/components/providers/PostHogProvider";
|
||||
import { ThemeProvider } from "@/components/theme/theme-provider";
|
||||
|
|
@ -104,7 +105,9 @@ export default function RootLayout({
|
|||
>
|
||||
<RootProvider>
|
||||
<ReactQueryClientProvider>
|
||||
<ElectricProvider>{children}</ElectricProvider>
|
||||
<ElectricProvider>
|
||||
<GlobalLoadingProvider>{children}</GlobalLoadingProvider>
|
||||
</ElectricProvider>
|
||||
</ReactQueryClientProvider>
|
||||
<Toaster />
|
||||
</RootProvider>
|
||||
|
|
|
|||
|
|
@ -175,5 +175,12 @@ export default function sitemap(): MetadataRoute.Sitemap {
|
|||
changeFrequency: "daily",
|
||||
priority: 0.8,
|
||||
},
|
||||
// How-to documentation
|
||||
{
|
||||
url: "https://www.surfsense.com/docs/how-to/electric-sql",
|
||||
lastModified,
|
||||
changeFrequency: "daily",
|
||||
priority: 0.8,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
|
|
|||
30
surfsense_web/atoms/ui/loading.atoms.ts
Normal file
30
surfsense_web/atoms/ui/loading.atoms.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { atom } from "jotai";
|
||||
|
||||
interface GlobalLoadingState {
|
||||
isLoading: boolean;
|
||||
message?: string;
|
||||
variant: "login" | "default";
|
||||
}
|
||||
|
||||
export const globalLoadingAtom = atom<GlobalLoadingState>({
|
||||
isLoading: false,
|
||||
message: undefined,
|
||||
variant: "default",
|
||||
});
|
||||
|
||||
// Helper atom for showing global loading
|
||||
export const showGlobalLoadingAtom = atom(
|
||||
null,
|
||||
(
|
||||
get,
|
||||
set,
|
||||
{ message, variant = "default" }: { message?: string; variant?: "login" | "default" }
|
||||
) => {
|
||||
set(globalLoadingAtom, { isLoading: true, message, variant });
|
||||
}
|
||||
);
|
||||
|
||||
// Helper atom for hiding global loading
|
||||
export const hideGlobalLoadingAtom = atom(null, (get, set) => {
|
||||
set(globalLoadingAtom, { isLoading: false, message: undefined, variant: "default" });
|
||||
});
|
||||
88
surfsense_web/changelog/content/2026-01-26.mdx
Normal file
88
surfsense_web/changelog/content/2026-01-26.mdx
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
---
|
||||
title: "SurfSense v0.0.12 - New Main UI, Real-time Collaboration and Comments"
|
||||
description: "SurfSense v0.0.12 transforms the platform with a redesigned chat-first interface, real-time collaboration features, comment threads with @mentions, and instant notifications powered by ElectricSQL + PGlite."
|
||||
date: "2026-01-26"
|
||||
tags: ["UX", "UI", "Real-time chat", "Collaboration", "Comments"]
|
||||
version: "0.0.12"
|
||||
---
|
||||
|
||||
<img src="/changelog/0.0.12/header.gif" alt="SurfSense v0.0.12 - New Main UI, Real-time Collaboration and Comments" className="rounded-lg w-full" />
|
||||
|
||||
## What's New in v0.0.12
|
||||
|
||||
This release brings major improvements to **collaboration and user experience**. We've completely redesigned the main interface to be chat-first, introduced real-time notifications and live collaboration features, and added a powerful commenting system with @mentions. These changes make SurfSense faster, more intuitive, and better for team collaboration.
|
||||
|
||||
### Major UX/UI Overhaul
|
||||
|
||||
#### New Chat-First Interface
|
||||
|
||||
- **Dashboard Removed**: Users now land directly in a chat for faster access
|
||||
- **Redesigned Search Spaces**: Moved to a left column with color-coded icons and hover tooltips
|
||||
- **Collapsible Sidebar**: New sidebar design with collapsible sections for private and group chats
|
||||
- **Streamlined Settings**: Accessible through intuitive dropdown menus
|
||||
- **Mobile-Responsive Design**: Better experience on all devices
|
||||
- **Single-Click Google Login**: Replaces the old two-step process
|
||||
|
||||
### Real-Time Collaboration Features
|
||||
|
||||
#### Live Shared Chats
|
||||
|
||||
- **Multi-User Collaboration**: Multiple users can now collaborate in the same chat in real-time
|
||||
- **Status Indicators**: See when the AI is responding to another team member
|
||||
- **Instant Sync**: Changes sync instantly across all open tabs and users
|
||||
|
||||
#### Chat Comments with @Mentions
|
||||
|
||||
- **Comment on AI Responses**: Discuss responses directly with your team
|
||||
- **Single-Level Threading**: Reply to comments with organized threads
|
||||
- **@Mentions**: Tag teammates to get their attention
|
||||
- **Real-Time Notifications**: Receive instant alerts when someone mentions you
|
||||
|
||||
#### Real-Time Notifications
|
||||
|
||||
- **Instant Updates**: Replaced slow polling with instant notifications using ElectricSQL + PGlite
|
||||
- **New Inbox**: See connector indexing, document processing, and system events immediately
|
||||
- **Cross-Tab Sync**: Syncs across all your browser tabs in real-time
|
||||
|
||||
### Connector Enhancements
|
||||
|
||||
#### OAuth Migration for Better Security
|
||||
|
||||
- **New OAuth Connectors**: Migrated Linear, Slack, Notion, Discord, Confluence, and Jira to OAuth-based authentication
|
||||
- **Circleback Integration**: Connect your AI meeting notes from Circleback
|
||||
- **Future Date Indexing**: Index future dates for calendar-based connectors to plan ahead
|
||||
- **5-Minute Periodic Syncing**: Near-real-time updates option
|
||||
- **Real-Time UI Updates**: See connector indexing progress without page refreshes
|
||||
|
||||
<Accordion type="multiple" className="w-full not-prose">
|
||||
<AccordionItem value="item-1">
|
||||
<AccordionTrigger>Bug Fixes</AccordionTrigger>
|
||||
<AccordionContent className="flex flex-col gap-4 text-balance">
|
||||
<ul className="list-disc space-y-2 pl-4">
|
||||
<li>Syncs with no new content now show "Already up to date!" instead of falsely reporting failures</li>
|
||||
<li>Restored missing indexing options page for Google Drive connector</li>
|
||||
<li>File mention picker now handles large document counts with server-side search and pagination</li>
|
||||
<li>Reasoning steps no longer overlap with chat input field</li>
|
||||
<li>File upload modal is now scrollable when adding many files</li>
|
||||
<li>OAuth connectors now display properly on mobile devices</li>
|
||||
</ul>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="item-2">
|
||||
<AccordionTrigger>Technical Improvements</AccordionTrigger>
|
||||
<AccordionContent className="flex flex-col gap-4 text-balance">
|
||||
<ul className="list-disc space-y-2 pl-4">
|
||||
<li>Made Alembic migrations idempotent for safer deployments</li>
|
||||
<li>Migrations now work on fresh databases from scratch</li>
|
||||
<li>Major refactoring of chat components for better maintainability</li>
|
||||
<li>Streamlined sidebar and connector page code</li>
|
||||
<li>Fixed legacy route handling</li>
|
||||
<li>Fixed hardcoded Docker values for complex deployments</li>
|
||||
</ul>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
|
||||
This release transforms SurfSense into a truly collaborative, real-time platform with a redesigned interface that puts chat front and center. The addition of comments, @mentions, and live collaboration features makes it easier than ever for teams to work together without leaving the app.
|
||||
|
||||
SurfSense is your AI-powered federated search solution, connecting all your knowledge sources in one place.
|
||||
|
|
@ -1,7 +1,9 @@
|
|||
"use client";
|
||||
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEffect } from "react";
|
||||
import { useGlobalLoadingEffect } from "@/hooks/use-global-loading";
|
||||
import { getAndClearRedirectPath, setBearerToken } from "@/lib/auth-utils";
|
||||
import { trackLoginSuccess } from "@/lib/posthog/events";
|
||||
|
||||
|
|
@ -25,8 +27,12 @@ const TokenHandler = ({
|
|||
tokenParamName = "token",
|
||||
storageKey = "surfsense_bearer_token",
|
||||
}: TokenHandlerProps) => {
|
||||
const t = useTranslations("auth");
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
// Always show loading for this component - spinner animation won't reset
|
||||
useGlobalLoadingEffect(true, t("processing_authentication"), "default");
|
||||
|
||||
useEffect(() => {
|
||||
// Only run on client-side
|
||||
if (typeof window === "undefined") return;
|
||||
|
|
@ -66,11 +72,8 @@ const TokenHandler = ({
|
|||
}
|
||||
}, [searchParams, tokenParamName, storageKey, redirectPath]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[200px]">
|
||||
<p className="text-gray-500">Processing authentication...</p>
|
||||
</div>
|
||||
);
|
||||
// Return null - the global provider handles the loading UI
|
||||
return null;
|
||||
};
|
||||
|
||||
export default TokenHandler;
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import {
|
|||
useAssistantApi,
|
||||
useAssistantState,
|
||||
} from "@assistant-ui/react";
|
||||
import { FileText, Loader2, Paperclip, PlusIcon, Upload, XIcon } from "lucide-react";
|
||||
import { FileText, Paperclip, PlusIcon, Upload, XIcon } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import { type FC, type PropsWithChildren, useEffect, useRef, useState } from "react";
|
||||
import { useShallow } from "zustand/shallow";
|
||||
|
|
@ -20,6 +20,7 @@ import {
|
|||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useDocumentUploadDialog } from "./document-upload-popup";
|
||||
|
|
@ -135,7 +136,7 @@ const AttachmentThumb: FC = () => {
|
|||
if (isProcessing) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center bg-muted">
|
||||
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
||||
<Spinner size="md" className="text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -213,7 +214,7 @@ const AttachmentUI: FC = () => {
|
|||
>
|
||||
{isProcessing ? (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
<Spinner size="xs" />
|
||||
Processing...
|
||||
</span>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { Loader2 } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ChatSessionStatusProps {
|
||||
|
|
@ -43,7 +43,7 @@ export const ChatSessionStatus: FC<ChatSessionStatusProps> = ({
|
|||
className
|
||||
)}
|
||||
>
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
<Spinner size="xs" />
|
||||
<span>Currently responding to {displayName}</span>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,37 +1,50 @@
|
|||
"use client";
|
||||
|
||||
import { useAtomValue } from "jotai";
|
||||
import { Cable, Loader2 } from "lucide-react";
|
||||
import { Cable } from "lucide-react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import type { FC } from "react";
|
||||
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
|
||||
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
|
||||
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
|
||||
import { Dialog, DialogContent } from "@/components/ui/dialog";
|
||||
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Tabs, TabsContent } from "@/components/ui/tabs";
|
||||
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
|
||||
import { useConnectorsElectric } from "@/hooks/use-connectors-electric";
|
||||
import { useDocumentsElectric } from "@/hooks/use-documents-electric";
|
||||
import { useInbox } from "@/hooks/use-inbox";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ConnectorDialogHeader } from "./connector-popup/components/connector-dialog-header";
|
||||
import { ConnectorConnectView } from "./connector-popup/connector-configs/views/connector-connect-view";
|
||||
import { ConnectorEditView } from "./connector-popup/connector-configs/views/connector-edit-view";
|
||||
import { IndexingConfigurationView } from "./connector-popup/connector-configs/views/indexing-configuration-view";
|
||||
import { OAUTH_CONNECTORS } from "./connector-popup/constants/connector-constants";
|
||||
import {
|
||||
COMPOSIO_CONNECTORS,
|
||||
OAUTH_CONNECTORS,
|
||||
} from "./connector-popup/constants/connector-constants";
|
||||
import { useConnectorDialog } from "./connector-popup/hooks/use-connector-dialog";
|
||||
import { useIndexingConnectors } from "./connector-popup/hooks/use-indexing-connectors";
|
||||
import { ActiveConnectorsTab } from "./connector-popup/tabs/active-connectors-tab";
|
||||
import { AllConnectorsTab } from "./connector-popup/tabs/all-connectors-tab";
|
||||
import { ComposioToolkitView } from "./connector-popup/views/composio-toolkit-view";
|
||||
import { ConnectorAccountsListView } from "./connector-popup/views/connector-accounts-list-view";
|
||||
import { YouTubeCrawlerView } from "./connector-popup/views/youtube-crawler-view";
|
||||
|
||||
export const ConnectorIndicator: FC = () => {
|
||||
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
|
||||
const searchParams = useSearchParams();
|
||||
const { data: currentUser } = useAtomValue(currentUserAtom);
|
||||
|
||||
// Fetch document type counts using Electric SQL + PGlite for real-time updates
|
||||
const { documentTypeCounts, loading: documentTypesLoading } = useDocumentsElectric(searchSpaceId);
|
||||
|
||||
// Fetch notifications to detect indexing failures
|
||||
const { inboxItems = [] } = useInbox(
|
||||
currentUser?.id ?? null,
|
||||
searchSpaceId ? Number(searchSpaceId) : null,
|
||||
"connector_indexing"
|
||||
);
|
||||
|
||||
// Check if YouTube view is active
|
||||
const isYouTubeView = searchParams.get("view") === "youtube";
|
||||
|
||||
|
|
@ -88,12 +101,6 @@ export const ConnectorIndicator: FC = () => {
|
|||
setConnectorConfig,
|
||||
setIndexingConnectorConfig,
|
||||
setConnectorName,
|
||||
// Composio
|
||||
viewingComposio,
|
||||
connectingComposioToolkit,
|
||||
handleOpenComposio,
|
||||
handleBackFromComposio,
|
||||
handleConnectComposioToolkit,
|
||||
} = useConnectorDialog();
|
||||
|
||||
// Fetch connectors using Electric SQL + PGlite for real-time updates
|
||||
|
|
@ -123,8 +130,10 @@ export const ConnectorIndicator: FC = () => {
|
|||
};
|
||||
|
||||
// Track indexing state locally - clears automatically when Electric SQL detects last_indexed_at changed
|
||||
const { indexingConnectorIds, startIndexing } = useIndexingConnectors(
|
||||
connectors as SearchSourceConnector[]
|
||||
// Also clears when failed notifications are detected
|
||||
const { indexingConnectorIds, startIndexing, stopIndexing } = useIndexingConnectors(
|
||||
connectors as SearchSourceConnector[],
|
||||
inboxItems
|
||||
);
|
||||
|
||||
const isLoading = connectorsLoading || documentTypesLoading;
|
||||
|
|
@ -142,7 +151,7 @@ export const ConnectorIndicator: FC = () => {
|
|||
|
||||
// Check which connectors are already connected
|
||||
// Using Electric SQL + PGlite for real-time connector updates
|
||||
const connectedTypes = new Set(
|
||||
const connectedTypes = new Set<string>(
|
||||
(connectors || []).map((c: SearchSourceConnector) => c.connector_type)
|
||||
);
|
||||
|
||||
|
|
@ -166,7 +175,7 @@ export const ConnectorIndicator: FC = () => {
|
|||
onClick={() => handleOpenChange(true)}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
<Spinner size="sm" />
|
||||
) : (
|
||||
<>
|
||||
<Cable className="size-4 stroke-[1.5px]" />
|
||||
|
|
@ -179,22 +188,11 @@ export const ConnectorIndicator: FC = () => {
|
|||
)}
|
||||
</TooltipIconButton>
|
||||
|
||||
<DialogContent className="max-w-3xl w-[95vw] sm:w-full h-[75vh] sm:h-[85vh] flex flex-col p-0 gap-0 overflow-hidden border border-border bg-muted text-foreground [&>button]:right-4 sm:[&>button]:right-12 [&>button]:top-6 sm:[&>button]:top-10 [&>button]:opacity-80 hover:[&>button]:opacity-100 [&>button_svg]:size-5">
|
||||
<DialogContent className="max-w-3xl w-[95vw] sm:w-full h-[75vh] sm:h-[85vh] flex flex-col p-0 gap-0 overflow-hidden border border-border bg-muted text-foreground focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0 [&>button]:right-4 sm:[&>button]:right-12 [&>button]:top-6 sm:[&>button]:top-10 [&>button]:opacity-80 hover:[&>button]:opacity-100 [&>button_svg]:size-5">
|
||||
<DialogTitle className="sr-only">Manage Connectors</DialogTitle>
|
||||
{/* YouTube Crawler View - shown when adding YouTube videos */}
|
||||
{isYouTubeView && searchSpaceId ? (
|
||||
<YouTubeCrawlerView searchSpaceId={searchSpaceId} onBack={handleBackFromYouTube} />
|
||||
) : viewingComposio && searchSpaceId ? (
|
||||
<ComposioToolkitView
|
||||
searchSpaceId={searchSpaceId}
|
||||
connectedToolkits={(connectors || [])
|
||||
.filter((c: SearchSourceConnector) => c.connector_type === "COMPOSIO_CONNECTOR")
|
||||
.map((c: SearchSourceConnector) => c.config?.toolkit_id as string)
|
||||
.filter(Boolean)}
|
||||
onBack={handleBackFromComposio}
|
||||
onConnectToolkit={handleConnectComposioToolkit}
|
||||
isConnecting={connectingComposioToolkit !== null}
|
||||
connectingToolkitId={connectingComposioToolkit}
|
||||
/>
|
||||
) : viewingMCPList ? (
|
||||
<ConnectorAccountsListView
|
||||
connectorType="MCP_CONNECTOR"
|
||||
|
|
@ -215,9 +213,14 @@ export const ConnectorIndicator: FC = () => {
|
|||
onBack={handleBackFromAccountsList}
|
||||
onManage={handleStartEdit}
|
||||
onAddAccount={() => {
|
||||
const oauthConnector = OAUTH_CONNECTORS.find(
|
||||
(c) => c.connectorType === viewingAccountsType.connectorType
|
||||
);
|
||||
// Check both OAUTH_CONNECTORS and COMPOSIO_CONNECTORS
|
||||
const oauthConnector =
|
||||
OAUTH_CONNECTORS.find(
|
||||
(c) => c.connectorType === viewingAccountsType.connectorType
|
||||
) ||
|
||||
COMPOSIO_CONNECTORS.find(
|
||||
(c) => c.connectorType === viewingAccountsType.connectorType
|
||||
);
|
||||
if (oauthConnector) {
|
||||
handleConnectOAuth(oauthConnector);
|
||||
}
|
||||
|
|
@ -260,7 +263,13 @@ export const ConnectorIndicator: FC = () => {
|
|||
editingConnector.connector_type !== "GOOGLE_DRIVE_CONNECTOR"
|
||||
? () => {
|
||||
startIndexing(editingConnector.id);
|
||||
handleQuickIndexConnector(editingConnector.id, editingConnector.connector_type);
|
||||
handleQuickIndexConnector(
|
||||
editingConnector.id,
|
||||
editingConnector.connector_type,
|
||||
stopIndexing,
|
||||
startDate,
|
||||
endDate
|
||||
);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
|
|
@ -331,7 +340,6 @@ export const ConnectorIndicator: FC = () => {
|
|||
onCreateYouTubeCrawler={handleCreateYouTubeCrawler}
|
||||
onManage={handleStartEdit}
|
||||
onViewAccountsList={handleViewAccountsList}
|
||||
onOpenComposio={handleOpenComposio}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,78 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { Zap } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import type { FC } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ComposioConnectorCardProps {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
connectorCount?: number;
|
||||
onConnect: () => void;
|
||||
}
|
||||
|
||||
export const ComposioConnectorCard: FC<ComposioConnectorCardProps> = ({
|
||||
id,
|
||||
title,
|
||||
description,
|
||||
connectorCount = 0,
|
||||
onConnect,
|
||||
}) => {
|
||||
const hasConnections = connectorCount > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"group relative flex items-center gap-4 p-4 rounded-xl text-left transition-all duration-200 w-full border",
|
||||
"border-violet-500/20 bg-gradient-to-br from-violet-500/5 to-purple-500/5",
|
||||
"hover:border-violet-500/40 hover:from-violet-500/10 hover:to-purple-500/10"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-12 w-12 items-center justify-center rounded-lg transition-colors shrink-0 border",
|
||||
"bg-gradient-to-br from-violet-500/10 to-purple-500/10 border-violet-500/20"
|
||||
)}
|
||||
>
|
||||
<Image
|
||||
src="/connectors/composio.svg"
|
||||
alt="Composio"
|
||||
width={24}
|
||||
height={24}
|
||||
className="size-6"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[14px] font-semibold leading-tight truncate">{title}</span>
|
||||
<Zap className="size-3.5 text-violet-500" />
|
||||
</div>
|
||||
{hasConnections ? (
|
||||
<p className="text-[10px] text-muted-foreground mt-1 flex items-center gap-1.5">
|
||||
<span>
|
||||
{connectorCount} {connectorCount === 1 ? "connection" : "connections"}
|
||||
</span>
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-[10px] text-muted-foreground mt-1">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={hasConnections ? "secondary" : "default"}
|
||||
className={cn(
|
||||
"h-8 text-[11px] px-3 rounded-lg shrink-0 font-medium shadow-xs",
|
||||
!hasConnections && "bg-violet-600 hover:bg-violet-700 text-white",
|
||||
hasConnections &&
|
||||
"bg-white text-slate-700 hover:bg-slate-50 border-0 dark:bg-secondary dark:text-secondary-foreground dark:hover:bg-secondary/80"
|
||||
)}
|
||||
onClick={onConnect}
|
||||
>
|
||||
{hasConnections ? "Manage" : "Browse"}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,9 +1,10 @@
|
|||
"use client";
|
||||
|
||||
import { IconBrandYoutube } from "@tabler/icons-react";
|
||||
import { FileText, Loader2 } from "lucide-react";
|
||||
import { FileText } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { EnumConnectorName } from "@/contracts/enums/connector";
|
||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
|
@ -111,7 +112,7 @@ export const ConnectorCard: FC<ConnectorCardProps> = ({
|
|||
</div>
|
||||
{isIndexing ? (
|
||||
<p className="text-[11px] text-primary mt-1 flex items-center gap-1.5">
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
<Spinner size="xs" />
|
||||
Syncing
|
||||
</p>
|
||||
) : isConnected ? (
|
||||
|
|
@ -151,7 +152,7 @@ export const ConnectorCard: FC<ConnectorCardProps> = ({
|
|||
disabled={isConnecting || !isEnabled}
|
||||
>
|
||||
{isConnecting ? (
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
<Spinner size="xs" />
|
||||
) : !isEnabled ? (
|
||||
"Unavailable"
|
||||
) : isConnected ? (
|
||||
|
|
|
|||
|
|
@ -24,6 +24,11 @@
|
|||
"enabled": true,
|
||||
"status": "warning",
|
||||
"statusMessage": "Some requests may be blocked if not using Firecrawl."
|
||||
},
|
||||
"COMPOSIO_GOOGLE_DRIVE_CONNECTOR": {
|
||||
"enabled": false,
|
||||
"status": "disabled",
|
||||
"statusMessage": "Not available yet."
|
||||
}
|
||||
},
|
||||
"globalSettings": {
|
||||
|
|
|
|||
|
|
@ -6,12 +6,6 @@ import type { FC } from "react";
|
|||
import { useRef, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import * as z from "zod";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import {
|
||||
Form,
|
||||
|
|
@ -85,6 +79,7 @@ export const BookStackConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitt
|
|||
BOOKSTACK_TOKEN_SECRET: values.token_secret,
|
||||
},
|
||||
is_indexable: true,
|
||||
is_active: true,
|
||||
last_indexed_at: null,
|
||||
periodic_indexing_enabled: periodicEnabled,
|
||||
indexing_frequency_minutes: periodicEnabled ? parseInt(frequencyMinutes, 10) : null,
|
||||
|
|
@ -301,124 +296,6 @@ export const BookStackConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitt
|
|||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Documentation Section */}
|
||||
<Accordion
|
||||
type="single"
|
||||
collapsible
|
||||
className="w-full border border-border rounded-xl bg-slate-400/5 dark:bg-white/5"
|
||||
>
|
||||
<AccordionItem value="documentation" className="border-0">
|
||||
<AccordionTrigger className="text-sm sm:text-base font-medium px-3 sm:px-6 no-underline hover:no-underline">
|
||||
Documentation
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-3 sm:px-6 pb-3 sm:pb-6 space-y-6">
|
||||
<div>
|
||||
<h3 className="text-sm sm:text-base font-semibold mb-2">How it works</h3>
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground">
|
||||
The BookStack connector uses the BookStack REST API to fetch all pages from your
|
||||
BookStack instance that your account has access to.
|
||||
</p>
|
||||
<ul className="mt-2 list-disc pl-5 text-[10px] sm:text-xs text-muted-foreground space-y-1">
|
||||
<li>
|
||||
For follow up indexing runs, the connector retrieves pages that have been updated
|
||||
since the last indexing attempt.
|
||||
</li>
|
||||
<li>
|
||||
Indexing is configured to run periodically, so updates should appear in your
|
||||
search results within minutes.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm sm:text-base font-semibold mb-2">Authorization</h3>
|
||||
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 mb-4">
|
||||
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
|
||||
<AlertTitle className="text-[10px] sm:text-xs">API Token Required</AlertTitle>
|
||||
<AlertDescription className="text-[9px] sm:text-[10px]">
|
||||
You need to create an API token from your BookStack instance. The token requires
|
||||
"Access System API" permission.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="space-y-4 sm:space-y-6">
|
||||
<div>
|
||||
<h4 className="text-[10px] sm:text-xs font-medium mb-2">
|
||||
Step 1: Create an API Token
|
||||
</h4>
|
||||
<ol className="list-decimal pl-5 space-y-2 text-[10px] sm:text-xs text-muted-foreground">
|
||||
<li>Log in to your BookStack instance</li>
|
||||
<li>Click on your profile icon → Edit Profile</li>
|
||||
<li>Navigate to the "API Tokens" tab</li>
|
||||
<li>Click "Create Token" and give it a name</li>
|
||||
<li>Copy both the Token ID and Token Secret</li>
|
||||
<li>Paste them in the form above</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-[10px] sm:text-xs font-medium mb-2">
|
||||
Step 2: Grant necessary access
|
||||
</h4>
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground mb-3">
|
||||
Your user account must have "Access System API" permission. The connector will
|
||||
only index content your account can view.
|
||||
</p>
|
||||
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20">
|
||||
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
|
||||
<AlertTitle className="text-[10px] sm:text-xs">Rate Limiting</AlertTitle>
|
||||
<AlertDescription className="text-[9px] sm:text-[10px]">
|
||||
BookStack API has a rate limit of 180 requests per minute. The connector
|
||||
automatically handles rate limiting to ensure reliable indexing.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm sm:text-base font-semibold mb-2">Indexing</h3>
|
||||
<ol className="list-decimal pl-5 space-y-2 text-[10px] sm:text-xs text-muted-foreground mb-4">
|
||||
<li>
|
||||
Navigate to the Connector Dashboard and select the <strong>BookStack</strong>{" "}
|
||||
Connector.
|
||||
</li>
|
||||
<li>
|
||||
Enter your <strong>BookStack Instance URL</strong> (e.g.,
|
||||
https://docs.example.com)
|
||||
</li>
|
||||
<li>
|
||||
Enter your <strong>Token ID</strong> and <strong>Token Secret</strong> from your
|
||||
BookStack API token.
|
||||
</li>
|
||||
<li>
|
||||
Click <strong>Connect</strong> to establish the connection.
|
||||
</li>
|
||||
<li>Once connected, your BookStack pages will be indexed automatically.</li>
|
||||
</ol>
|
||||
|
||||
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20">
|
||||
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
|
||||
<AlertTitle className="text-[10px] sm:text-xs">What Gets Indexed</AlertTitle>
|
||||
<AlertDescription className="text-[9px] sm:text-[10px]">
|
||||
<p className="mb-2">The BookStack connector indexes the following data:</p>
|
||||
<ul className="list-disc pl-5 space-y-1">
|
||||
<li>All pages from your BookStack instance</li>
|
||||
<li>Page content in Markdown format</li>
|
||||
<li>Page titles and metadata</li>
|
||||
<li>Book and chapter hierarchy information</li>
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -6,12 +6,6 @@ import type { FC } from "react";
|
|||
import { useRef, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import * as z from "zod";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import {
|
||||
Form,
|
||||
|
|
@ -253,131 +247,6 @@ export const LumaConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitting }
|
|||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Documentation Section */}
|
||||
<Accordion
|
||||
type="single"
|
||||
collapsible
|
||||
className="w-full border border-border rounded-xl bg-slate-400/5 dark:bg-white/5"
|
||||
>
|
||||
<AccordionItem value="documentation" className="border-0">
|
||||
<AccordionTrigger className="text-sm sm:text-base font-medium px-3 sm:px-6 no-underline hover:no-underline">
|
||||
Documentation
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-3 sm:px-6 pb-3 sm:pb-6 space-y-6">
|
||||
<div>
|
||||
<h3 className="text-sm sm:text-base font-semibold mb-2">How it works</h3>
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground">
|
||||
The Luma connector uses the Luma API to fetch all events that your API key has
|
||||
access to.
|
||||
</p>
|
||||
<ul className="mt-2 list-disc pl-5 text-[10px] sm:text-xs text-muted-foreground space-y-1">
|
||||
<li>
|
||||
For follow up indexing runs, the connector retrieves events that have been updated
|
||||
since the last indexing attempt.
|
||||
</li>
|
||||
<li>
|
||||
Indexing is configured to run periodically, so updates should appear in your
|
||||
search results within minutes.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm sm:text-base font-semibold mb-2">Authorization</h3>
|
||||
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 mb-4">
|
||||
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
|
||||
<AlertTitle className="text-[10px] sm:text-xs">API Key Required</AlertTitle>
|
||||
<AlertDescription className="text-[9px] sm:text-[10px]">
|
||||
You need a Luma API key to use this connector. The key will be used to read your
|
||||
Luma events with read-only permissions.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="space-y-4 sm:space-y-6">
|
||||
<div>
|
||||
<h4 className="text-[10px] sm:text-xs font-medium mb-2">
|
||||
Step 1: Get Your API Key
|
||||
</h4>
|
||||
<ol className="list-decimal pl-5 space-y-2 text-[10px] sm:text-xs text-muted-foreground">
|
||||
<li>Log into your Luma account</li>
|
||||
<li>Navigate to your account settings</li>
|
||||
<li>Go to API settings or Developer settings</li>
|
||||
<li>Generate a new API key</li>
|
||||
<li>Copy the generated API key</li>
|
||||
<li>
|
||||
You can also visit{" "}
|
||||
<a
|
||||
href="https://lu.ma/api"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium underline underline-offset-4"
|
||||
>
|
||||
Luma API Settings
|
||||
</a>{" "}
|
||||
for more information.
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-[10px] sm:text-xs font-medium mb-2">
|
||||
Step 2: Grant necessary access
|
||||
</h4>
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground mb-3">
|
||||
The API key will have access to all events that your user account can see.
|
||||
Make sure your account has appropriate permissions for the events you want to
|
||||
index.
|
||||
</p>
|
||||
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20">
|
||||
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
|
||||
<AlertTitle className="text-[10px] sm:text-xs">Data Privacy</AlertTitle>
|
||||
<AlertDescription className="text-[9px] sm:text-[10px]">
|
||||
Only event details, descriptions, and attendee information will be indexed.
|
||||
Event attachments and linked files are not indexed by this connector.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm sm:text-base font-semibold mb-2">Indexing</h3>
|
||||
<ol className="list-decimal pl-5 space-y-2 text-[10px] sm:text-xs text-muted-foreground mb-4">
|
||||
<li>
|
||||
Navigate to the Connector Dashboard and select the <strong>Luma</strong>{" "}
|
||||
Connector.
|
||||
</li>
|
||||
<li>
|
||||
Place your <strong>API Key</strong> in the form field.
|
||||
</li>
|
||||
<li>
|
||||
Click <strong>Connect</strong> to establish the connection.
|
||||
</li>
|
||||
<li>Once connected, your Luma events will be indexed automatically.</li>
|
||||
</ol>
|
||||
|
||||
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20">
|
||||
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
|
||||
<AlertTitle className="text-[10px] sm:text-xs">What Gets Indexed</AlertTitle>
|
||||
<AlertDescription className="text-[9px] sm:text-[10px]">
|
||||
<p className="mb-2">The Luma connector indexes the following data:</p>
|
||||
<ul className="list-disc pl-5 space-y-1">
|
||||
<li>Event titles and descriptions</li>
|
||||
<li>Event details and metadata</li>
|
||||
<li>Attendee information</li>
|
||||
<li>Event dates and locations</li>
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,17 +1,11 @@
|
|||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { FolderOpen, Info } from "lucide-react";
|
||||
import { Info } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import * as z from "zod";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import {
|
||||
Form,
|
||||
|
|
@ -109,7 +103,7 @@ export const ObsidianConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitti
|
|||
return (
|
||||
<div className="space-y-6 pb-6">
|
||||
<Alert className="bg-purple-500/10 dark:bg-purple-500/10 border-purple-500/30 p-2 sm:p-3 flex items-center [&>svg]:relative [&>svg]:left-0 [&>svg]:top-0 [&>svg+div]:translate-y-0">
|
||||
<FolderOpen className="h-3 w-3 sm:h-4 sm:w-4 shrink-0 ml-1 text-purple-500" />
|
||||
<Info className="h-3 w-3 sm:h-4 sm:w-4 shrink-0 ml-1 text-purple-500" />
|
||||
<div className="-ml-1">
|
||||
<AlertTitle className="text-xs sm:text-sm">Self-Hosted Only</AlertTitle>
|
||||
<AlertDescription className="text-[10px] sm:text-xs pl-0!">
|
||||
|
|
@ -320,145 +314,6 @@ export const ObsidianConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitti
|
|||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Documentation Section */}
|
||||
<Accordion
|
||||
type="single"
|
||||
collapsible
|
||||
className="w-full border border-border rounded-xl bg-slate-400/5 dark:bg-white/5"
|
||||
>
|
||||
<AccordionItem value="documentation" className="border-0">
|
||||
<AccordionTrigger className="text-sm sm:text-base font-medium px-3 sm:px-6 no-underline hover:no-underline">
|
||||
Documentation
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-3 sm:px-6 pb-3 sm:pb-6 space-y-6">
|
||||
<div>
|
||||
<h3 className="text-sm sm:text-base font-semibold mb-2">How it works</h3>
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground">
|
||||
The Obsidian connector scans your local Obsidian vault directory and indexes all
|
||||
Markdown files. It preserves your note structure and extracts metadata from YAML
|
||||
frontmatter.
|
||||
</p>
|
||||
<ul className="mt-2 list-disc pl-5 text-[10px] sm:text-xs text-muted-foreground space-y-1">
|
||||
<li>
|
||||
The connector parses frontmatter metadata (title, tags, aliases, dates, etc.)
|
||||
</li>
|
||||
<li>Wiki-style links ([[note]]) are extracted and preserved</li>
|
||||
<li>Inline tags (#tag) are recognized and indexed</li>
|
||||
<li>Content is chunked intelligently for optimal search results</li>
|
||||
<li>
|
||||
Subsequent indexing runs use content hashing to skip unchanged files for faster
|
||||
sync
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm sm:text-base font-semibold mb-2">Setup</h3>
|
||||
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 mb-4">
|
||||
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
|
||||
<AlertTitle className="text-[10px] sm:text-xs">
|
||||
File System Access Required
|
||||
</AlertTitle>
|
||||
<AlertDescription className="text-[9px] sm:text-[10px]">
|
||||
The SurfSense backend must have read access to your Obsidian vault directory.
|
||||
For Docker deployments, mount your vault as a volume.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="space-y-4 sm:space-y-6">
|
||||
<div>
|
||||
<h4 className="text-[10px] sm:text-xs font-medium mb-2">
|
||||
Step 1: Locate your vault
|
||||
</h4>
|
||||
<ol className="list-decimal pl-5 space-y-2 text-[10px] sm:text-xs text-muted-foreground">
|
||||
<li>
|
||||
<strong>macOS/Linux:</strong> Right-click any note in Obsidian → "Reveal in
|
||||
Finder" to see the vault folder
|
||||
</li>
|
||||
<li>
|
||||
<strong>Windows:</strong> Right-click any note → "Show in system explorer"
|
||||
</li>
|
||||
<li>
|
||||
<strong>Or:</strong> Click the vault switcher (bottom-left icon) → "Open
|
||||
folder" next to your vault name
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-[10px] sm:text-xs font-medium mb-2">
|
||||
Step 2: Enter the path
|
||||
</h4>
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground mb-2">
|
||||
<strong>Running locally (no Docker):</strong> Use the direct path to your
|
||||
vault:
|
||||
</p>
|
||||
<pre className="bg-slate-800 text-slate-200 p-2 rounded text-[9px] sm:text-[10px] overflow-x-auto mb-2">
|
||||
{`/Users/yourname/Documents/MyObsidianVault`}
|
||||
</pre>
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground mb-2">
|
||||
<strong>Running in Docker:</strong> Mount your vault as a volume in
|
||||
docker-compose.yml:
|
||||
</p>
|
||||
<pre className="bg-slate-800 text-slate-200 p-2 rounded text-[9px] sm:text-[10px] overflow-x-auto">
|
||||
{`volumes:
|
||||
- /path/to/your/vault:/app/obsidian_vaults/my-vault:ro`}
|
||||
</pre>
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground mt-2">
|
||||
Then use <code>/app/obsidian_vaults/my-vault</code> as your vault path.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-[10px] sm:text-xs font-medium mb-2">
|
||||
Step 3: Configure exclusions
|
||||
</h4>
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground">
|
||||
Common folders to exclude:
|
||||
</p>
|
||||
<ul className="list-disc pl-5 mt-1 space-y-1 text-[10px] sm:text-xs text-muted-foreground">
|
||||
<li>
|
||||
<code>.obsidian</code> - Obsidian config (always recommended)
|
||||
</li>
|
||||
<li>
|
||||
<code>.trash</code> - Obsidian's trash folder
|
||||
</li>
|
||||
<li>
|
||||
<code>templates</code> - If you have a templates folder
|
||||
</li>
|
||||
<li>
|
||||
<code>daily-notes</code> - If you want to exclude daily notes
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm sm:text-base font-semibold mb-2">What Gets Indexed</h3>
|
||||
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20">
|
||||
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
|
||||
<AlertTitle className="text-[10px] sm:text-xs">Indexed Content</AlertTitle>
|
||||
<AlertDescription className="text-[9px] sm:text-[10px]">
|
||||
<p className="mb-2">The Obsidian connector indexes:</p>
|
||||
<ul className="list-disc pl-5 space-y-1">
|
||||
<li>All Markdown files (.md) in your vault</li>
|
||||
<li>YAML frontmatter metadata (title, tags, aliases, dates)</li>
|
||||
<li>Wiki-style links between notes</li>
|
||||
<li>Inline tags throughout your notes</li>
|
||||
<li>Full note content with proper chunking</li>
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,14 @@
|
|||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
|
||||
|
||||
interface ComposioCalendarConfigProps {
|
||||
connector: SearchSourceConnector;
|
||||
onConfigChange?: (config: Record<string, unknown>) => void;
|
||||
onNameChange?: (name: string) => void;
|
||||
}
|
||||
|
||||
export const ComposioCalendarConfig: FC<ComposioCalendarConfigProps> = () => {
|
||||
return <div className="space-y-6" />;
|
||||
};
|
||||
|
|
@ -1,160 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { ExternalLink, Info, Zap } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import type { FC } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ComposioConfigProps {
|
||||
connector: SearchSourceConnector;
|
||||
onConfigChange?: (config: Record<string, unknown>) => void;
|
||||
onNameChange?: (name: string) => void;
|
||||
}
|
||||
|
||||
// Get toolkit display info
|
||||
const getToolkitInfo = (toolkitId: string): { name: string; icon: string; description: string } => {
|
||||
switch (toolkitId) {
|
||||
case "googledrive":
|
||||
return {
|
||||
name: "Google Drive",
|
||||
icon: "/connectors/google-drive.svg",
|
||||
description: "Files and documents from Google Drive",
|
||||
};
|
||||
case "gmail":
|
||||
return {
|
||||
name: "Gmail",
|
||||
icon: "/connectors/google-gmail.svg",
|
||||
description: "Emails from Gmail",
|
||||
};
|
||||
case "googlecalendar":
|
||||
return {
|
||||
name: "Google Calendar",
|
||||
icon: "/connectors/google-calendar.svg",
|
||||
description: "Events from Google Calendar",
|
||||
};
|
||||
case "slack":
|
||||
return {
|
||||
name: "Slack",
|
||||
icon: "/connectors/slack.svg",
|
||||
description: "Messages from Slack",
|
||||
};
|
||||
case "notion":
|
||||
return {
|
||||
name: "Notion",
|
||||
icon: "/connectors/notion.svg",
|
||||
description: "Pages from Notion",
|
||||
};
|
||||
case "github":
|
||||
return {
|
||||
name: "GitHub",
|
||||
icon: "/connectors/github.svg",
|
||||
description: "Repositories from GitHub",
|
||||
};
|
||||
default:
|
||||
return {
|
||||
name: toolkitId,
|
||||
icon: "/connectors/composio.svg",
|
||||
description: "Connected via Composio",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const ComposioConfig: FC<ComposioConfigProps> = ({ connector }) => {
|
||||
const toolkitId = connector.config?.toolkit_id as string;
|
||||
const toolkitName = connector.config?.toolkit_name as string;
|
||||
const isIndexable = connector.config?.is_indexable as boolean;
|
||||
const composioAccountId = connector.config?.composio_connected_account_id as string;
|
||||
|
||||
const toolkitInfo = getToolkitInfo(toolkitId);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Toolkit Info Card */}
|
||||
<div className="rounded-xl border border-violet-500/20 bg-gradient-to-br from-violet-500/5 to-purple-500/5 p-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-gradient-to-br from-violet-500/10 to-purple-500/10 border border-violet-500/20 shrink-0">
|
||||
<Image
|
||||
src={toolkitInfo.icon}
|
||||
alt={toolkitInfo.name}
|
||||
width={24}
|
||||
height={24}
|
||||
className="size-6"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="text-sm font-semibold">{toolkitName || toolkitInfo.name}</h3>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-[10px] px-1.5 py-0 h-5 bg-violet-500/10 text-violet-600 dark:text-violet-400 border-violet-500/20"
|
||||
>
|
||||
<Zap className="size-3 mr-0.5" />
|
||||
Composio
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{toolkitInfo.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Connection Details */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Connection Details
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between py-2 px-3 rounded-lg bg-muted/50">
|
||||
<span className="text-xs text-muted-foreground">Toolkit</span>
|
||||
<span className="text-xs font-medium">{toolkitId}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between py-2 px-3 rounded-lg bg-muted/50">
|
||||
<span className="text-xs text-muted-foreground">Indexing Supported</span>
|
||||
<Badge
|
||||
variant={isIndexable ? "default" : "secondary"}
|
||||
className={cn(
|
||||
"text-[10px] px-1.5 py-0 h-5",
|
||||
isIndexable
|
||||
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20"
|
||||
: "bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20"
|
||||
)}
|
||||
>
|
||||
{isIndexable ? "Yes" : "Coming Soon"}
|
||||
</Badge>
|
||||
</div>
|
||||
{composioAccountId && (
|
||||
<div className="flex items-center justify-between py-2 px-3 rounded-lg bg-muted/50">
|
||||
<span className="text-xs text-muted-foreground">Account ID</span>
|
||||
<span className="text-xs font-mono text-muted-foreground truncate max-w-[150px]">
|
||||
{composioAccountId}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info Banner */}
|
||||
<div className="rounded-lg border border-border/50 bg-muted/30 p-3">
|
||||
<div className="flex items-start gap-2.5">
|
||||
<Info className="size-4 text-muted-foreground shrink-0 mt-0.5" />
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
This connection uses Composio's managed OAuth, which means you don't need to
|
||||
wait for app verification. Your data is securely accessed through Composio.
|
||||
</p>
|
||||
<a
|
||||
href="https://composio.dev"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-xs text-violet-600 dark:text-violet-400 hover:underline"
|
||||
>
|
||||
Learn more about Composio
|
||||
<ExternalLink className="size-3" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,347 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
File,
|
||||
FileSpreadsheet,
|
||||
FileText,
|
||||
FolderClosed,
|
||||
Image,
|
||||
Presentation,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { ComposioDriveFolderTree } from "@/components/connectors/composio-drive-folder-tree";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
|
||||
|
||||
interface ComposioDriveConfigProps {
|
||||
connector: SearchSourceConnector;
|
||||
onConfigChange?: (config: Record<string, unknown>) => void;
|
||||
onNameChange?: (name: string) => void;
|
||||
}
|
||||
|
||||
interface SelectedFolder {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface IndexingOptions {
|
||||
max_files_per_folder: number;
|
||||
incremental_sync: boolean;
|
||||
include_subfolders: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_INDEXING_OPTIONS: IndexingOptions = {
|
||||
max_files_per_folder: 100,
|
||||
incremental_sync: true,
|
||||
include_subfolders: true,
|
||||
};
|
||||
|
||||
// Helper to get appropriate icon for file type based on file name
|
||||
function getFileIconFromName(fileName: string, className: string = "size-3.5 shrink-0") {
|
||||
const lowerName = fileName.toLowerCase();
|
||||
// Spreadsheets
|
||||
if (
|
||||
lowerName.endsWith(".xlsx") ||
|
||||
lowerName.endsWith(".xls") ||
|
||||
lowerName.endsWith(".csv") ||
|
||||
lowerName.includes("spreadsheet")
|
||||
) {
|
||||
return <FileSpreadsheet className={`${className} text-green-500`} />;
|
||||
}
|
||||
// Presentations
|
||||
if (
|
||||
lowerName.endsWith(".pptx") ||
|
||||
lowerName.endsWith(".ppt") ||
|
||||
lowerName.includes("presentation")
|
||||
) {
|
||||
return <Presentation className={`${className} text-orange-500`} />;
|
||||
}
|
||||
// Documents (word, text only - not PDF)
|
||||
if (
|
||||
lowerName.endsWith(".docx") ||
|
||||
lowerName.endsWith(".doc") ||
|
||||
lowerName.endsWith(".txt") ||
|
||||
lowerName.includes("document") ||
|
||||
lowerName.includes("word") ||
|
||||
lowerName.includes("text")
|
||||
) {
|
||||
return <FileText className={`${className} text-gray-500`} />;
|
||||
}
|
||||
// Images
|
||||
if (
|
||||
lowerName.endsWith(".png") ||
|
||||
lowerName.endsWith(".jpg") ||
|
||||
lowerName.endsWith(".jpeg") ||
|
||||
lowerName.endsWith(".gif") ||
|
||||
lowerName.endsWith(".webp") ||
|
||||
lowerName.endsWith(".svg")
|
||||
) {
|
||||
return <Image className={`${className} text-purple-500`} />;
|
||||
}
|
||||
// Default (including PDF)
|
||||
return <File className={`${className} text-gray-500`} />;
|
||||
}
|
||||
|
||||
export const ComposioDriveConfig: FC<ComposioDriveConfigProps> = ({
|
||||
connector,
|
||||
onConfigChange,
|
||||
}) => {
|
||||
const isIndexable = connector.config?.is_indexable as boolean;
|
||||
|
||||
// Initialize with existing selected folders and files from connector config
|
||||
const existingFolders =
|
||||
(connector.config?.selected_folders as SelectedFolder[] | undefined) || [];
|
||||
const existingFiles = (connector.config?.selected_files as SelectedFolder[] | undefined) || [];
|
||||
const existingIndexingOptions =
|
||||
(connector.config?.indexing_options as IndexingOptions | undefined) || DEFAULT_INDEXING_OPTIONS;
|
||||
|
||||
const [selectedFolders, setSelectedFolders] = useState<SelectedFolder[]>(existingFolders);
|
||||
const [selectedFiles, setSelectedFiles] = useState<SelectedFolder[]>(existingFiles);
|
||||
const [showFolderSelector, setShowFolderSelector] = useState(false);
|
||||
const [indexingOptions, setIndexingOptions] = useState<IndexingOptions>(existingIndexingOptions);
|
||||
|
||||
// Update selected folders and files when connector config changes
|
||||
useEffect(() => {
|
||||
const folders = (connector.config?.selected_folders as SelectedFolder[] | undefined) || [];
|
||||
const files = (connector.config?.selected_files as SelectedFolder[] | undefined) || [];
|
||||
const options =
|
||||
(connector.config?.indexing_options as IndexingOptions | undefined) ||
|
||||
DEFAULT_INDEXING_OPTIONS;
|
||||
setSelectedFolders(folders);
|
||||
setSelectedFiles(files);
|
||||
setIndexingOptions(options);
|
||||
}, [connector.config]);
|
||||
|
||||
const updateConfig = (
|
||||
folders: SelectedFolder[],
|
||||
files: SelectedFolder[],
|
||||
options: IndexingOptions
|
||||
) => {
|
||||
if (onConfigChange) {
|
||||
onConfigChange({
|
||||
...connector.config,
|
||||
selected_folders: folders,
|
||||
selected_files: files,
|
||||
indexing_options: options,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectFolders = (folders: SelectedFolder[]) => {
|
||||
setSelectedFolders(folders);
|
||||
updateConfig(folders, selectedFiles, indexingOptions);
|
||||
};
|
||||
|
||||
const handleSelectFiles = (files: SelectedFolder[]) => {
|
||||
setSelectedFiles(files);
|
||||
updateConfig(selectedFolders, files, indexingOptions);
|
||||
};
|
||||
|
||||
const handleIndexingOptionChange = (key: keyof IndexingOptions, value: number | boolean) => {
|
||||
const newOptions = { ...indexingOptions, [key]: value };
|
||||
setIndexingOptions(newOptions);
|
||||
updateConfig(selectedFolders, selectedFiles, newOptions);
|
||||
};
|
||||
|
||||
const handleRemoveFolder = (folderId: string) => {
|
||||
const newFolders = selectedFolders.filter((folder) => folder.id !== folderId);
|
||||
setSelectedFolders(newFolders);
|
||||
updateConfig(newFolders, selectedFiles, indexingOptions);
|
||||
};
|
||||
|
||||
const handleRemoveFile = (fileId: string) => {
|
||||
const newFiles = selectedFiles.filter((file) => file.id !== fileId);
|
||||
setSelectedFiles(newFiles);
|
||||
updateConfig(selectedFolders, newFiles, indexingOptions);
|
||||
};
|
||||
|
||||
const totalSelected = selectedFolders.length + selectedFiles.length;
|
||||
|
||||
// Only show configuration if the connector is indexable
|
||||
if (!isIndexable) {
|
||||
return <div className="space-y-6" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Folder & File Selection */}
|
||||
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
|
||||
<div className="space-y-1 sm:space-y-2">
|
||||
<h3 className="font-medium text-sm sm:text-base">Folder & File Selection</h3>
|
||||
<p className="text-xs sm:text-sm text-muted-foreground">
|
||||
Select specific folders and/or individual files to index from your Google Drive.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{totalSelected > 0 && (
|
||||
<div className="p-2 sm:p-3 bg-muted rounded-lg text-xs sm:text-sm space-y-1 sm:space-y-2">
|
||||
<p className="font-medium">
|
||||
Selected {totalSelected} item{totalSelected > 1 ? "s" : ""}: {(() => {
|
||||
const parts: string[] = [];
|
||||
if (selectedFolders.length > 0) {
|
||||
parts.push(
|
||||
`${selectedFolders.length} folder${selectedFolders.length > 1 ? "s" : ""}`
|
||||
);
|
||||
}
|
||||
if (selectedFiles.length > 0) {
|
||||
parts.push(`${selectedFiles.length} file${selectedFiles.length > 1 ? "s" : ""}`);
|
||||
}
|
||||
return parts.length > 0 ? `(${parts.join(", ")})` : "";
|
||||
})()}
|
||||
</p>
|
||||
<div className="max-h-20 sm:max-h-24 overflow-y-auto space-y-1">
|
||||
{selectedFolders.map((folder) => (
|
||||
<div
|
||||
key={folder.id}
|
||||
className="text-xs sm:text-sm text-muted-foreground truncate flex items-center gap-1.5"
|
||||
title={folder.name}
|
||||
>
|
||||
<FolderClosed className="size-3.5 shrink-0 text-gray-500" />
|
||||
<span className="flex-1 truncate">{folder.name}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveFolder(folder.id)}
|
||||
className="shrink-0 p-0.5 hover:bg-muted-foreground/20 rounded transition-colors"
|
||||
aria-label={`Remove ${folder.name}`}
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{selectedFiles.map((file) => (
|
||||
<div
|
||||
key={file.id}
|
||||
className="text-xs sm:text-sm text-muted-foreground truncate flex items-center gap-1.5"
|
||||
title={file.name}
|
||||
>
|
||||
{getFileIconFromName(file.name)}
|
||||
<span className="flex-1 truncate">{file.name}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveFile(file.id)}
|
||||
className="shrink-0 p-0.5 hover:bg-muted-foreground/20 rounded transition-colors"
|
||||
aria-label={`Remove ${file.name}`}
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showFolderSelector ? (
|
||||
<div className="space-y-2 sm:space-y-3">
|
||||
<ComposioDriveFolderTree
|
||||
connectorId={connector.id}
|
||||
selectedFolders={selectedFolders}
|
||||
onSelectFolders={handleSelectFolders}
|
||||
selectedFiles={selectedFiles}
|
||||
onSelectFiles={handleSelectFiles}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowFolderSelector(false)}
|
||||
className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 hover:bg-slate-400/10 dark:hover:bg-white/10 text-xs sm:text-sm h-8 sm:h-9"
|
||||
>
|
||||
Done Selecting
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setShowFolderSelector(true)}
|
||||
className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 hover:bg-slate-400/10 dark:hover:bg-white/10 text-xs sm:text-sm h-8 sm:h-9"
|
||||
>
|
||||
{totalSelected > 0 ? "Change Selection" : "Select Folders & Files"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Indexing Options */}
|
||||
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-4">
|
||||
<div className="space-y-1 sm:space-y-2">
|
||||
<h3 className="font-medium text-sm sm:text-base">Indexing Options</h3>
|
||||
<p className="text-xs sm:text-sm text-muted-foreground">
|
||||
Configure how files are indexed from your Google Drive.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Max files per folder */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="max-files" className="text-sm font-medium">
|
||||
Max files per folder
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Maximum number of files to index from each folder
|
||||
</p>
|
||||
</div>
|
||||
<Select
|
||||
value={indexingOptions.max_files_per_folder.toString()}
|
||||
onValueChange={(value) =>
|
||||
handleIndexingOptionChange("max_files_per_folder", parseInt(value, 10))
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="max-files"
|
||||
className="w-[140px] bg-slate-400/5 dark:bg-slate-400/5 border-slate-400/20 text-xs sm:text-sm"
|
||||
>
|
||||
<SelectValue placeholder="Select limit" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="z-[100]">
|
||||
<SelectItem value="50" className="text-xs sm:text-sm">
|
||||
50 files
|
||||
</SelectItem>
|
||||
<SelectItem value="100" className="text-xs sm:text-sm">
|
||||
100 files
|
||||
</SelectItem>
|
||||
<SelectItem value="250" className="text-xs sm:text-sm">
|
||||
250 files
|
||||
</SelectItem>
|
||||
<SelectItem value="500" className="text-xs sm:text-sm">
|
||||
500 files
|
||||
</SelectItem>
|
||||
<SelectItem value="1000" className="text-xs sm:text-sm">
|
||||
1000 files
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Include subfolders toggle */}
|
||||
<div className="flex items-center justify-between pt-2 border-t border-slate-400/20">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="include-subfolders" className="text-sm font-medium">
|
||||
Include subfolders
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Recursively index files in subfolders of selected folders
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="include-subfolders"
|
||||
checked={indexingOptions.include_subfolders}
|
||||
onCheckedChange={(checked) => handleIndexingOptionChange("include_subfolders", checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
|
||||
|
||||
interface ComposioGmailConfigProps {
|
||||
connector: SearchSourceConnector;
|
||||
onConfigChange?: (config: Record<string, unknown>) => void;
|
||||
onNameChange?: (name: string) => void;
|
||||
}
|
||||
|
||||
export const ComposioGmailConfig: FC<ComposioGmailConfigProps> = () => {
|
||||
return <div className="space-y-6" />;
|
||||
};
|
||||
|
|
@ -1,6 +1,14 @@
|
|||
"use client";
|
||||
|
||||
import { File, FileSpreadsheet, FileText, FolderClosed, Image, Presentation } from "lucide-react";
|
||||
import {
|
||||
File,
|
||||
FileSpreadsheet,
|
||||
FileText,
|
||||
FolderClosed,
|
||||
Image,
|
||||
Presentation,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { GoogleDriveFolderTree } from "@/components/connectors/google-drive-folder-tree";
|
||||
|
|
@ -135,6 +143,18 @@ export const GoogleDriveConfig: FC<ConnectorConfigProps> = ({ connector, onConfi
|
|||
updateConfig(selectedFolders, selectedFiles, newOptions);
|
||||
};
|
||||
|
||||
const handleRemoveFolder = (folderId: string) => {
|
||||
const newFolders = selectedFolders.filter((folder) => folder.id !== folderId);
|
||||
setSelectedFolders(newFolders);
|
||||
updateConfig(newFolders, selectedFiles, indexingOptions);
|
||||
};
|
||||
|
||||
const handleRemoveFile = (fileId: string) => {
|
||||
const newFiles = selectedFiles.filter((file) => file.id !== fileId);
|
||||
setSelectedFiles(newFiles);
|
||||
updateConfig(selectedFolders, newFiles, indexingOptions);
|
||||
};
|
||||
|
||||
const totalSelected = selectedFolders.length + selectedFiles.length;
|
||||
|
||||
return (
|
||||
|
|
@ -161,29 +181,45 @@ export const GoogleDriveConfig: FC<ConnectorConfigProps> = ({ connector, onConfi
|
|||
if (selectedFiles.length > 0) {
|
||||
parts.push(`${selectedFiles.length} file${selectedFiles.length > 1 ? "s" : ""}`);
|
||||
}
|
||||
return parts.length > 0 ? `(${parts.join(" ")})` : "";
|
||||
return parts.length > 0 ? `(${parts.join(", ")})` : "";
|
||||
})()}
|
||||
</p>
|
||||
<div className="max-h-20 sm:max-h-24 overflow-y-auto space-y-1">
|
||||
{selectedFolders.map((folder) => (
|
||||
<p
|
||||
<div
|
||||
key={folder.id}
|
||||
className="text-xs sm:text-sm text-muted-foreground truncate flex items-center gap-1.5"
|
||||
title={folder.name}
|
||||
>
|
||||
<FolderClosed className="size-3.5 shrink-0 text-gray-500" />
|
||||
{folder.name}
|
||||
</p>
|
||||
<span className="flex-1 truncate">{folder.name}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveFolder(folder.id)}
|
||||
className="shrink-0 p-0.5 hover:bg-muted-foreground/20 rounded transition-colors"
|
||||
aria-label={`Remove ${folder.name}`}
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{selectedFiles.map((file) => (
|
||||
<p
|
||||
<div
|
||||
key={file.id}
|
||||
className="text-xs sm:text-sm text-muted-foreground truncate flex items-center gap-1.5"
|
||||
title={file.name}
|
||||
>
|
||||
{getFileIconFromName(file.name)}
|
||||
{file.name}
|
||||
</p>
|
||||
<span className="flex-1 truncate">{file.name}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveFile(file.id)}
|
||||
className="shrink-0 p-0.5 hover:bg-muted-foreground/20 rounded transition-colors"
|
||||
aria-label={`Remove ${file.name}`}
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -6,7 +6,9 @@ import { BaiduSearchApiConfig } from "./components/baidu-search-api-config";
|
|||
import { BookStackConfig } from "./components/bookstack-config";
|
||||
import { CirclebackConfig } from "./components/circleback-config";
|
||||
import { ClickUpConfig } from "./components/clickup-config";
|
||||
import { ComposioConfig } from "./components/composio-config";
|
||||
import { ComposioCalendarConfig } from "./components/composio-calendar-config";
|
||||
import { ComposioDriveConfig } from "./components/composio-drive-config";
|
||||
import { ComposioGmailConfig } from "./components/composio-gmail-config";
|
||||
import { ConfluenceConfig } from "./components/confluence-config";
|
||||
import { DiscordConfig } from "./components/discord-config";
|
||||
import { ElasticsearchConfig } from "./components/elasticsearch-config";
|
||||
|
|
@ -77,8 +79,12 @@ export function getConnectorConfigComponent(
|
|||
return MCPConfig;
|
||||
case "OBSIDIAN_CONNECTOR":
|
||||
return ObsidianConfig;
|
||||
case "COMPOSIO_CONNECTOR":
|
||||
return ComposioConfig;
|
||||
case "COMPOSIO_GOOGLE_DRIVE_CONNECTOR":
|
||||
return ComposioDriveConfig;
|
||||
case "COMPOSIO_GMAIL_CONNECTOR":
|
||||
return ComposioGmailConfig;
|
||||
case "COMPOSIO_GOOGLE_CALENDAR_CONNECTOR":
|
||||
return ComposioCalendarConfig;
|
||||
// OAuth connectors (Gmail, Calendar, Airtable, Notion) and others don't need special config UI
|
||||
default:
|
||||
return null;
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
"use client";
|
||||
|
||||
import { ArrowLeft, Loader2 } from "lucide-react";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { type FC, useMemo } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import type { EnumConnectorName } from "@/contracts/enums/connector";
|
||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||
import { getConnectorTypeDisplay } from "@/lib/connectors/utils";
|
||||
|
|
@ -139,7 +140,7 @@ export const ConnectorConnectView: FC<ConnectorConnectViewProps> = ({
|
|||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
Connecting
|
||||
</>
|
||||
) : connectorType === "MCP_CONNECTOR" ? (
|
||||
|
|
|
|||
|
|
@ -1,13 +1,15 @@
|
|||
"use client";
|
||||
|
||||
import { ArrowLeft, Info, Loader2, RefreshCw, Trash2 } from "lucide-react";
|
||||
import { ArrowLeft, Info, RefreshCw, Trash2 } from "lucide-react";
|
||||
import { type FC, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { DateRangeSelector } from "../../components/date-range-selector";
|
||||
import { PeriodicSyncConfig } from "../../components/periodic-sync-config";
|
||||
import { getConnectorDisplayName } from "../../tabs/all-connectors-tab";
|
||||
import { getConnectorConfigComponent } from "../index";
|
||||
|
||||
interface ConnectorEditViewProps {
|
||||
|
|
@ -97,12 +99,16 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
|
|||
};
|
||||
}, [checkScrollState]);
|
||||
|
||||
// Reset local quick indexing state when indexing completes
|
||||
// Reset local quick indexing state when indexing completes or fails
|
||||
useEffect(() => {
|
||||
if (!isIndexing) {
|
||||
setIsQuickIndexing(false);
|
||||
if (!isIndexing && isQuickIndexing) {
|
||||
// Small delay to ensure smooth transition
|
||||
const timer = setTimeout(() => {
|
||||
setIsQuickIndexing(false);
|
||||
}, 100);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [isIndexing]);
|
||||
}, [isIndexing, isQuickIndexing]);
|
||||
|
||||
const handleDisconnectClick = () => {
|
||||
setShowDisconnectConfirm(true);
|
||||
|
|
@ -118,11 +124,11 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
|
|||
};
|
||||
|
||||
const handleQuickIndex = useCallback(() => {
|
||||
if (onQuickIndex) {
|
||||
if (onQuickIndex && !isQuickIndexing && !isIndexing) {
|
||||
setIsQuickIndexing(true);
|
||||
onQuickIndex();
|
||||
}
|
||||
}, [onQuickIndex]);
|
||||
}, [onQuickIndex, isQuickIndexing, isIndexing]);
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||
|
|
@ -151,7 +157,7 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
|
|||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h2 className="text-xl sm:text-2xl font-semibold tracking-tight text-wrap whitespace-normal wrap-break-word">
|
||||
{connector.name}
|
||||
{getConnectorDisplayName(connector.name)}
|
||||
</h2>
|
||||
<p className="text-xs sm:text-base text-muted-foreground mt-1">
|
||||
Manage your connector settings and sync configuration
|
||||
|
|
@ -206,8 +212,9 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
|
|||
{/* Date range selector and periodic sync - only shown for indexable connectors */}
|
||||
{connector.is_indexable && (
|
||||
<>
|
||||
{/* Date range selector - not shown for Google Drive, Webcrawler, or GitHub (indexes full repo snapshots) */}
|
||||
{/* Date range selector - not shown for Google Drive (regular and Composio), Webcrawler, or GitHub (indexes full repo snapshots) */}
|
||||
{connector.connector_type !== "GOOGLE_DRIVE_CONNECTOR" &&
|
||||
connector.connector_type !== "COMPOSIO_GOOGLE_DRIVE_CONNECTOR" &&
|
||||
connector.connector_type !== "WEBCRAWLER_CONNECTOR" &&
|
||||
connector.connector_type !== "GITHUB_CONNECTOR" && (
|
||||
<DateRangeSelector
|
||||
|
|
@ -217,6 +224,7 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
|
|||
onEndDateChange={onEndDateChange}
|
||||
allowFutureDates={
|
||||
connector.connector_type === "GOOGLE_CALENDAR_CONNECTOR" ||
|
||||
connector.connector_type === "COMPOSIO_GOOGLE_CALENDAR_CONNECTOR" ||
|
||||
connector.connector_type === "LUMA_CONNECTOR"
|
||||
}
|
||||
/>
|
||||
|
|
@ -224,8 +232,11 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
|
|||
|
||||
{/* Periodic sync - shown for all indexable connectors */}
|
||||
{(() => {
|
||||
// Check if Google Drive has folders/files selected
|
||||
// Check if Google Drive (regular or Composio) has folders/files selected
|
||||
const isGoogleDrive = connector.connector_type === "GOOGLE_DRIVE_CONNECTOR";
|
||||
const isComposioGoogleDrive =
|
||||
connector.connector_type === "COMPOSIO_GOOGLE_DRIVE_CONNECTOR";
|
||||
const requiresFolderSelection = isGoogleDrive || isComposioGoogleDrive;
|
||||
const selectedFolders =
|
||||
(connector.config?.selected_folders as
|
||||
| Array<{ id: string; name: string }>
|
||||
|
|
@ -235,7 +246,7 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
|
|||
| Array<{ id: string; name: string }>
|
||||
| undefined) || [];
|
||||
const hasItemsSelected = selectedFolders.length > 0 || selectedFiles.length > 0;
|
||||
const isDisabled = isGoogleDrive && !hasItemsSelected;
|
||||
const isDisabled = requiresFolderSelection && !hasItemsSelected;
|
||||
|
||||
return (
|
||||
<PeriodicSyncConfig
|
||||
|
|
@ -266,8 +277,8 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
|
|||
Re-indexing runs in the background
|
||||
</p>
|
||||
<p className="text-muted-foreground mt-1 text-[10px] sm:text-sm">
|
||||
You can continue using SurfSense while we sync your data. Check the Active tab
|
||||
to see progress.
|
||||
You can continue using SurfSense while we sync your data. Check inbox for
|
||||
updates.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -301,7 +312,7 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
|
|||
>
|
||||
{isDisconnecting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
Disconnecting
|
||||
</>
|
||||
) : (
|
||||
|
|
@ -337,8 +348,8 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
|
|||
>
|
||||
{isSaving ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Saving...
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
Saving
|
||||
</>
|
||||
) : (
|
||||
"Save Changes"
|
||||
|
|
|
|||
|
|
@ -1,15 +1,16 @@
|
|||
"use client";
|
||||
|
||||
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
|
||||
import { ArrowLeft, Check, Info } from "lucide-react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { type FC, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
|
||||
import { getConnectorTypeDisplay } from "@/lib/connectors/utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { DateRangeSelector } from "../../components/date-range-selector";
|
||||
import { PeriodicSyncConfig } from "../../components/periodic-sync-config";
|
||||
import { type IndexingConfigState, OAUTH_CONNECTORS } from "../../constants/connector-constants";
|
||||
import type { IndexingConfigState } from "../../constants/connector-constants";
|
||||
import { getConnectorDisplayName } from "../../tabs/all-connectors-tab";
|
||||
import { getConnectorConfigComponent } from "../index";
|
||||
|
||||
|
|
@ -91,8 +92,6 @@ export const IndexingConfigurationView: FC<IndexingConfigurationViewProps> = ({
|
|||
};
|
||||
}, [checkScrollState]);
|
||||
|
||||
const authConnector = OAUTH_CONNECTORS.find((c) => c.connectorType === connector?.connector_type);
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||
{/* Fixed Header */}
|
||||
|
|
@ -151,8 +150,9 @@ export const IndexingConfigurationView: FC<IndexingConfigurationViewProps> = ({
|
|||
{/* Date range selector and periodic sync - only shown for indexable connectors */}
|
||||
{connector?.is_indexable && (
|
||||
<>
|
||||
{/* Date range selector - not shown for Google Drive, Webcrawler, or GitHub (indexes full repo snapshots) */}
|
||||
{/* Date range selector - not shown for Google Drive (regular and Composio), Webcrawler, or GitHub (indexes full repo snapshots) */}
|
||||
{config.connectorType !== "GOOGLE_DRIVE_CONNECTOR" &&
|
||||
config.connectorType !== "COMPOSIO_GOOGLE_DRIVE_CONNECTOR" &&
|
||||
config.connectorType !== "WEBCRAWLER_CONNECTOR" &&
|
||||
config.connectorType !== "GITHUB_CONNECTOR" && (
|
||||
<DateRangeSelector
|
||||
|
|
@ -162,20 +162,22 @@ export const IndexingConfigurationView: FC<IndexingConfigurationViewProps> = ({
|
|||
onEndDateChange={onEndDateChange}
|
||||
allowFutureDates={
|
||||
config.connectorType === "GOOGLE_CALENDAR_CONNECTOR" ||
|
||||
config.connectorType === "COMPOSIO_GOOGLE_CALENDAR_CONNECTOR" ||
|
||||
config.connectorType === "LUMA_CONNECTOR"
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Periodic sync - not shown for Google Drive */}
|
||||
{config.connectorType !== "GOOGLE_DRIVE_CONNECTOR" && (
|
||||
<PeriodicSyncConfig
|
||||
enabled={periodicEnabled}
|
||||
frequencyMinutes={frequencyMinutes}
|
||||
onEnabledChange={onPeriodicEnabledChange}
|
||||
onFrequencyChange={onFrequencyChange}
|
||||
/>
|
||||
)}
|
||||
{/* Periodic sync - not shown for Google Drive (regular and Composio) */}
|
||||
{config.connectorType !== "GOOGLE_DRIVE_CONNECTOR" &&
|
||||
config.connectorType !== "COMPOSIO_GOOGLE_DRIVE_CONNECTOR" && (
|
||||
<PeriodicSyncConfig
|
||||
enabled={periodicEnabled}
|
||||
frequencyMinutes={frequencyMinutes}
|
||||
onEnabledChange={onPeriodicEnabledChange}
|
||||
onFrequencyChange={onFrequencyChange}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
|
@ -188,8 +190,8 @@ export const IndexingConfigurationView: FC<IndexingConfigurationViewProps> = ({
|
|||
<div className="text-xs sm:text-sm">
|
||||
<p className="font-medium text-xs sm:text-sm">Indexing runs in the background</p>
|
||||
<p className="text-muted-foreground mt-1 text-[10px] sm:text-sm">
|
||||
You can continue using SurfSense while we sync your data. Check the Active tab
|
||||
to see progress.
|
||||
You can continue using SurfSense while we sync your data. Check inbox for
|
||||
updates.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -215,7 +217,7 @@ export const IndexingConfigurationView: FC<IndexingConfigurationViewProps> = ({
|
|||
>
|
||||
{isStartingIndexing ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
Starting...
|
||||
</>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -175,14 +175,28 @@ export const OTHER_CONNECTORS = [
|
|||
},
|
||||
] as const;
|
||||
|
||||
// Composio Connector (Single entry that opens toolkit selector)
|
||||
// Composio Connectors - Individual entries for each supported toolkit
|
||||
export const COMPOSIO_CONNECTORS = [
|
||||
{
|
||||
id: "composio-connector",
|
||||
title: "Composio",
|
||||
description: "Connect 100+ apps via Composio (Google, Slack, Notion, etc.)",
|
||||
connectorType: EnumConnectorName.COMPOSIO_CONNECTOR,
|
||||
// No authEndpoint - handled via toolkit selector view
|
||||
id: "composio-googledrive",
|
||||
title: "Google Drive",
|
||||
description: "Search your Drive files via Composio",
|
||||
connectorType: EnumConnectorName.COMPOSIO_GOOGLE_DRIVE_CONNECTOR,
|
||||
authEndpoint: "/api/v1/auth/composio/connector/add/?toolkit_id=googledrive",
|
||||
},
|
||||
{
|
||||
id: "composio-gmail",
|
||||
title: "Gmail",
|
||||
description: "Search through your emails via Composio",
|
||||
connectorType: EnumConnectorName.COMPOSIO_GMAIL_CONNECTOR,
|
||||
authEndpoint: "/api/v1/auth/composio/connector/add/?toolkit_id=gmail",
|
||||
},
|
||||
{
|
||||
id: "composio-googlecalendar",
|
||||
title: "Google Calendar",
|
||||
description: "Search through your events via Composio",
|
||||
connectorType: EnumConnectorName.COMPOSIO_GOOGLE_CALENDAR_CONNECTOR,
|
||||
authEndpoint: "/api/v1/auth/composio/connector/add/?toolkit_id=googlecalendar",
|
||||
},
|
||||
] as const;
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,9 @@ import { searchSourceConnectorTypeEnum } from "@/contracts/types/connector.types
|
|||
export const connectorPopupQueryParamsSchema = z.object({
|
||||
modal: z.enum(["connectors"]).optional(),
|
||||
tab: z.enum(["all", "active"]).optional(),
|
||||
view: z.enum(["configure", "edit", "connect", "youtube", "accounts", "mcp-list"]).optional(),
|
||||
view: z
|
||||
.enum(["configure", "edit", "connect", "youtube", "accounts", "mcp-list", "composio"])
|
||||
.optional(),
|
||||
connector: z.string().optional(),
|
||||
connectorId: z.string().optional(),
|
||||
connectorType: z.string().optional(),
|
||||
|
|
|
|||
|
|
@ -26,7 +26,11 @@ import {
|
|||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||
import { queryClient } from "@/lib/query-client/client";
|
||||
import type { IndexingConfigState } from "../constants/connector-constants";
|
||||
import { OAUTH_CONNECTORS, OTHER_CONNECTORS } from "../constants/connector-constants";
|
||||
import {
|
||||
COMPOSIO_CONNECTORS,
|
||||
OAUTH_CONNECTORS,
|
||||
OTHER_CONNECTORS,
|
||||
} from "../constants/connector-constants";
|
||||
import {
|
||||
dateRangeSchema,
|
||||
frequencyMinutesSchema,
|
||||
|
|
@ -83,10 +87,6 @@ export const useConnectorDialog = () => {
|
|||
// MCP list view state (for managing multiple MCP connectors)
|
||||
const [viewingMCPList, setViewingMCPList] = useState(false);
|
||||
|
||||
// Composio toolkit view state
|
||||
const [viewingComposio, setViewingComposio] = useState(false);
|
||||
const [connectingComposioToolkit, setConnectingComposioToolkit] = useState<string | null>(null);
|
||||
|
||||
// Track if we came from accounts list when entering edit mode
|
||||
const [cameFromAccountsList, setCameFromAccountsList] = useState<{
|
||||
connectorType: string;
|
||||
|
|
@ -159,32 +159,28 @@ export const useConnectorDialog = () => {
|
|||
setViewingMCPList(true);
|
||||
}
|
||||
|
||||
// Clear Composio view if view is not "composio" anymore
|
||||
if (params.view !== "composio" && viewingComposio) {
|
||||
setViewingComposio(false);
|
||||
setConnectingComposioToolkit(null);
|
||||
}
|
||||
|
||||
// Handle Composio view
|
||||
if (params.view === "composio" && !viewingComposio) {
|
||||
setViewingComposio(true);
|
||||
}
|
||||
|
||||
// Handle connect view
|
||||
if (params.view === "connect" && params.connectorType && !connectingConnectorType) {
|
||||
setConnectingConnectorType(params.connectorType);
|
||||
}
|
||||
|
||||
// Handle accounts view
|
||||
if (params.view === "accounts" && params.connectorType && !viewingAccountsType) {
|
||||
const oauthConnector = OAUTH_CONNECTORS.find(
|
||||
(c) => c.connectorType === params.connectorType
|
||||
);
|
||||
if (oauthConnector) {
|
||||
setViewingAccountsType({
|
||||
connectorType: oauthConnector.connectorType,
|
||||
connectorTitle: oauthConnector.title,
|
||||
});
|
||||
if (params.view === "accounts" && params.connectorType) {
|
||||
// Update state if not set, or if connectorType has changed
|
||||
const needsUpdate =
|
||||
!viewingAccountsType || viewingAccountsType.connectorType !== params.connectorType;
|
||||
|
||||
if (needsUpdate) {
|
||||
// Check both OAUTH_CONNECTORS and COMPOSIO_CONNECTORS
|
||||
const oauthConnector =
|
||||
OAUTH_CONNECTORS.find((c) => c.connectorType === params.connectorType) ||
|
||||
COMPOSIO_CONNECTORS.find((c) => c.connectorType === params.connectorType);
|
||||
if (oauthConnector) {
|
||||
setViewingAccountsType({
|
||||
connectorType: oauthConnector.connectorType,
|
||||
connectorTitle: oauthConnector.title,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -195,7 +191,10 @@ export const useConnectorDialog = () => {
|
|||
|
||||
// Handle configure view (for page refresh support)
|
||||
if (params.view === "configure" && params.connector && !indexingConfig && allConnectors) {
|
||||
const oauthConnector = OAUTH_CONNECTORS.find((c) => c.id === params.connector);
|
||||
// Check both OAUTH_CONNECTORS and COMPOSIO_CONNECTORS
|
||||
const oauthConnector =
|
||||
OAUTH_CONNECTORS.find((c) => c.id === params.connector) ||
|
||||
COMPOSIO_CONNECTORS.find((c) => c.id === params.connector);
|
||||
if (oauthConnector) {
|
||||
let existingConnector: SearchSourceConnector | undefined;
|
||||
if (params.connectorId) {
|
||||
|
|
@ -293,6 +292,7 @@ export const useConnectorDialog = () => {
|
|||
indexingConfig,
|
||||
connectingConnectorType,
|
||||
viewingAccountsType,
|
||||
viewingMCPList,
|
||||
]);
|
||||
|
||||
// Detect OAuth success / Failure and transition to config view
|
||||
|
|
@ -328,58 +328,72 @@ export const useConnectorDialog = () => {
|
|||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
params.success === "true" &&
|
||||
params.connector &&
|
||||
searchSpaceId &&
|
||||
params.modal === "connectors"
|
||||
) {
|
||||
const oauthConnector = OAUTH_CONNECTORS.find((c) => c.id === params.connector);
|
||||
if (oauthConnector) {
|
||||
refetchAllConnectors().then((result) => {
|
||||
if (!result.data) return;
|
||||
if (params.success === "true" && searchSpaceId && params.modal === "connectors") {
|
||||
refetchAllConnectors().then((result) => {
|
||||
if (!result.data) return;
|
||||
|
||||
let newConnector: SearchSourceConnector | undefined;
|
||||
if (params.connectorId) {
|
||||
const connectorId = parseInt(params.connectorId, 10);
|
||||
newConnector = result.data.find((c: SearchSourceConnector) => c.id === connectorId);
|
||||
} else {
|
||||
let newConnector: SearchSourceConnector | undefined;
|
||||
let oauthConnector:
|
||||
| (typeof OAUTH_CONNECTORS)[number]
|
||||
| (typeof COMPOSIO_CONNECTORS)[number]
|
||||
| undefined;
|
||||
|
||||
// First, try to find connector by connectorId if provided
|
||||
if (params.connectorId) {
|
||||
const connectorId = parseInt(params.connectorId, 10);
|
||||
newConnector = result.data.find((c: SearchSourceConnector) => c.id === connectorId);
|
||||
|
||||
// If we found the connector, find the matching OAuth/Composio connector by type
|
||||
if (newConnector) {
|
||||
oauthConnector =
|
||||
OAUTH_CONNECTORS.find((c) => c.connectorType === newConnector!.connector_type) ||
|
||||
COMPOSIO_CONNECTORS.find((c) => c.connectorType === newConnector!.connector_type);
|
||||
}
|
||||
}
|
||||
|
||||
// If we don't have a connector yet, try to find by connector param
|
||||
if (!newConnector && params.connector) {
|
||||
oauthConnector =
|
||||
OAUTH_CONNECTORS.find((c) => c.id === params.connector) ||
|
||||
COMPOSIO_CONNECTORS.find((c) => c.id === params.connector);
|
||||
|
||||
if (oauthConnector) {
|
||||
newConnector = result.data.find(
|
||||
(c: SearchSourceConnector) => c.connector_type === oauthConnector.connectorType
|
||||
(c: SearchSourceConnector) => c.connector_type === oauthConnector!.connectorType
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (newConnector) {
|
||||
const connectorValidation = searchSourceConnector.safeParse(newConnector);
|
||||
if (connectorValidation.success) {
|
||||
// Track connector connected event for OAuth connectors
|
||||
trackConnectorConnected(
|
||||
Number(searchSpaceId),
|
||||
oauthConnector.connectorType,
|
||||
newConnector.id
|
||||
);
|
||||
if (newConnector && oauthConnector) {
|
||||
const connectorValidation = searchSourceConnector.safeParse(newConnector);
|
||||
if (connectorValidation.success) {
|
||||
// Track connector connected event for OAuth/Composio connectors
|
||||
trackConnectorConnected(
|
||||
Number(searchSpaceId),
|
||||
oauthConnector.connectorType,
|
||||
newConnector.id
|
||||
);
|
||||
|
||||
const config = validateIndexingConfigState({
|
||||
connectorType: oauthConnector.connectorType,
|
||||
connectorId: newConnector.id,
|
||||
connectorTitle: oauthConnector.title,
|
||||
});
|
||||
setIndexingConfig(config);
|
||||
setIndexingConnector(newConnector);
|
||||
setIndexingConnectorConfig(newConnector.config);
|
||||
setIsOpen(true);
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete("success");
|
||||
url.searchParams.set("connectorId", newConnector.id.toString());
|
||||
url.searchParams.set("view", "configure");
|
||||
window.history.replaceState({}, "", url.toString());
|
||||
} else {
|
||||
console.warn("Invalid connector data after OAuth:", connectorValidation.error);
|
||||
toast.error("Failed to validate connector data");
|
||||
}
|
||||
const config = validateIndexingConfigState({
|
||||
connectorType: oauthConnector.connectorType,
|
||||
connectorId: newConnector.id,
|
||||
connectorTitle: oauthConnector.title,
|
||||
});
|
||||
setIndexingConfig(config);
|
||||
setIndexingConnector(newConnector);
|
||||
setIndexingConnectorConfig(newConnector.config);
|
||||
setIsOpen(true);
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete("success");
|
||||
url.searchParams.set("connectorId", newConnector.id.toString());
|
||||
url.searchParams.set("view", "configure");
|
||||
window.history.replaceState({}, "", url.toString());
|
||||
} else {
|
||||
console.warn("Invalid connector data after OAuth:", connectorValidation.error);
|
||||
toast.error("Failed to validate connector data");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
// Invalid query params - log but don't crash
|
||||
|
|
@ -389,17 +403,18 @@ export const useConnectorDialog = () => {
|
|||
|
||||
// Handle OAuth connection
|
||||
const handleConnectOAuth = useCallback(
|
||||
async (connector: (typeof OAUTH_CONNECTORS)[number]) => {
|
||||
async (connector: (typeof OAUTH_CONNECTORS)[number] | (typeof COMPOSIO_CONNECTORS)[number]) => {
|
||||
if (!searchSpaceId || !connector.authEndpoint) return;
|
||||
|
||||
// Set connecting state immediately to disable button and show spinner
|
||||
setConnectingId(connector.id);
|
||||
|
||||
try {
|
||||
const response = await authenticatedFetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}${connector.authEndpoint}?space_id=${searchSpaceId}`,
|
||||
{ method: "GET" }
|
||||
);
|
||||
// Check if authEndpoint already has query parameters
|
||||
const separator = connector.authEndpoint.includes("?") ? "&" : "?";
|
||||
const url = `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}${connector.authEndpoint}${separator}space_id=${searchSpaceId}`;
|
||||
|
||||
const response = await authenticatedFetch(url, { method: "GET" });
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to initiate ${connector.title} OAuth`);
|
||||
|
|
@ -799,23 +814,19 @@ export const useConnectorDialog = () => {
|
|||
|
||||
// Handle viewing accounts list for OAuth connector type
|
||||
const handleViewAccountsList = useCallback(
|
||||
(connectorType: string, connectorTitle: string) => {
|
||||
(connectorType: string, _connectorTitle?: string) => {
|
||||
if (!searchSpaceId) return;
|
||||
|
||||
setViewingAccountsType({
|
||||
connectorType,
|
||||
connectorTitle,
|
||||
});
|
||||
|
||||
// Update URL to show accounts view, preserving current tab
|
||||
// The useEffect will handle setting viewingAccountsType based on URL params
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("modal", "connectors");
|
||||
url.searchParams.set("view", "accounts");
|
||||
url.searchParams.set("connectorType", connectorType);
|
||||
// Keep the current tab in URL so we can go back to it
|
||||
window.history.pushState({ modal: true }, "", url.toString());
|
||||
router.replace(url.pathname + url.search, { scroll: false });
|
||||
},
|
||||
[searchSpaceId]
|
||||
[searchSpaceId, router]
|
||||
);
|
||||
|
||||
// Handle going back from accounts list view
|
||||
|
|
@ -839,8 +850,8 @@ export const useConnectorDialog = () => {
|
|||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("modal", "connectors");
|
||||
url.searchParams.set("view", "mcp-list");
|
||||
window.history.pushState({ modal: true }, "", url.toString());
|
||||
}, [searchSpaceId]);
|
||||
router.replace(url.pathname + url.search, { scroll: false });
|
||||
}, [searchSpaceId, router]);
|
||||
|
||||
// Handle going back from MCP list view
|
||||
const handleBackFromMCPList = useCallback(() => {
|
||||
|
|
@ -861,71 +872,15 @@ export const useConnectorDialog = () => {
|
|||
router.replace(url.pathname + url.search, { scroll: false });
|
||||
}, [router]);
|
||||
|
||||
// Handle opening Composio toolkit view
|
||||
const handleOpenComposio = useCallback(() => {
|
||||
if (!searchSpaceId) return;
|
||||
|
||||
setViewingComposio(true);
|
||||
|
||||
// Update URL to show Composio view
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("modal", "connectors");
|
||||
url.searchParams.set("view", "composio");
|
||||
window.history.pushState({ modal: true }, "", url.toString());
|
||||
}, [searchSpaceId]);
|
||||
|
||||
// Handle going back from Composio view
|
||||
const handleBackFromComposio = useCallback(() => {
|
||||
setViewingComposio(false);
|
||||
setConnectingComposioToolkit(null);
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("modal", "connectors");
|
||||
url.searchParams.delete("view");
|
||||
router.replace(url.pathname + url.search, { scroll: false });
|
||||
}, [router]);
|
||||
|
||||
// Handle connecting a Composio toolkit
|
||||
const handleConnectComposioToolkit = useCallback(
|
||||
async (toolkitId: string) => {
|
||||
if (!searchSpaceId) return;
|
||||
|
||||
setConnectingComposioToolkit(toolkitId);
|
||||
|
||||
try {
|
||||
const response = await authenticatedFetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/auth/composio/connector/add?space_id=${searchSpaceId}&toolkit_id=${toolkitId}`,
|
||||
{ method: "GET" }
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to initiate Composio OAuth for ${toolkitId}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.auth_url) {
|
||||
// Redirect to Composio OAuth
|
||||
window.location.href = data.auth_url;
|
||||
} else {
|
||||
throw new Error("No authorization URL received from Composio");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error connecting Composio toolkit:", error);
|
||||
toast.error(`Failed to connect ${toolkitId}. Please try again.`);
|
||||
setConnectingComposioToolkit(null);
|
||||
}
|
||||
},
|
||||
[searchSpaceId]
|
||||
);
|
||||
|
||||
// Handle starting indexing
|
||||
const handleStartIndexing = useCallback(
|
||||
async (refreshConnectors: () => void) => {
|
||||
if (!indexingConfig || !searchSpaceId) return;
|
||||
|
||||
// Validate date range (skip for Google Drive and Webcrawler)
|
||||
// Validate date range (skip for Google Drive, Composio Drive, and Webcrawler)
|
||||
if (
|
||||
indexingConfig.connectorType !== "GOOGLE_DRIVE_CONNECTOR" &&
|
||||
indexingConfig.connectorType !== "COMPOSIO_GOOGLE_DRIVE_CONNECTOR" &&
|
||||
indexingConfig.connectorType !== "WEBCRAWLER_CONNECTOR"
|
||||
) {
|
||||
const dateRangeValidation = dateRangeSchema.safeParse({ startDate, endDate });
|
||||
|
|
@ -970,8 +925,12 @@ export const useConnectorDialog = () => {
|
|||
});
|
||||
}
|
||||
|
||||
// Handle Google Drive folder selection
|
||||
if (indexingConfig.connectorType === "GOOGLE_DRIVE_CONNECTOR" && indexingConnectorConfig) {
|
||||
// Handle Google Drive folder selection (regular and Composio)
|
||||
if (
|
||||
(indexingConfig.connectorType === "GOOGLE_DRIVE_CONNECTOR" ||
|
||||
indexingConfig.connectorType === "COMPOSIO_GOOGLE_DRIVE_CONNECTOR") &&
|
||||
indexingConnectorConfig
|
||||
) {
|
||||
const selectedFolders = indexingConnectorConfig.selected_folders as
|
||||
| Array<{ id: string; name: string }>
|
||||
| undefined;
|
||||
|
|
@ -1191,8 +1150,12 @@ export const useConnectorDialog = () => {
|
|||
return;
|
||||
}
|
||||
|
||||
// Prevent periodic indexing for Google Drive without folders/files selected
|
||||
if (periodicEnabled && editingConnector.connector_type === "GOOGLE_DRIVE_CONNECTOR") {
|
||||
// Prevent periodic indexing for Google Drive (regular or Composio) without folders/files selected
|
||||
if (
|
||||
periodicEnabled &&
|
||||
(editingConnector.connector_type === "GOOGLE_DRIVE_CONNECTOR" ||
|
||||
editingConnector.connector_type === "COMPOSIO_GOOGLE_DRIVE_CONNECTOR")
|
||||
) {
|
||||
const selectedFolders = (connectorConfig || editingConnector.config)?.selected_folders as
|
||||
| Array<{ id: string; name: string }>
|
||||
| undefined;
|
||||
|
|
@ -1241,8 +1204,11 @@ export const useConnectorDialog = () => {
|
|||
if (!editingConnector.is_indexable) {
|
||||
// Non-indexable connectors (like Tavily API) don't need re-indexing
|
||||
indexingDescription = "Settings saved.";
|
||||
} else if (editingConnector.connector_type === "GOOGLE_DRIVE_CONNECTOR") {
|
||||
// Google Drive uses folder selection from config, not date ranges
|
||||
} else if (
|
||||
editingConnector.connector_type === "GOOGLE_DRIVE_CONNECTOR" ||
|
||||
editingConnector.connector_type === "COMPOSIO_GOOGLE_DRIVE_CONNECTOR"
|
||||
) {
|
||||
// Google Drive (both regular and Composio) uses folder selection from config, not date ranges
|
||||
const selectedFolders = (connectorConfig || editingConnector.config)?.selected_folders as
|
||||
| Array<{ id: string; name: string }>
|
||||
| undefined;
|
||||
|
|
@ -1423,13 +1389,24 @@ export const useConnectorDialog = () => {
|
|||
setIsDisconnecting(false);
|
||||
}
|
||||
},
|
||||
[editingConnector, searchSpaceId, deleteConnector, router]
|
||||
[editingConnector, searchSpaceId, deleteConnector, router, cameFromMCPList]
|
||||
);
|
||||
|
||||
// Handle quick index (index without date picker, uses backend defaults)
|
||||
// Handle quick index (index with selected date range, or backend defaults if none selected)
|
||||
const handleQuickIndexConnector = useCallback(
|
||||
async (connectorId: number, connectorType?: string) => {
|
||||
if (!searchSpaceId) return;
|
||||
async (
|
||||
connectorId: number,
|
||||
connectorType?: string,
|
||||
stopIndexing?: (id: number) => void,
|
||||
startDate?: Date,
|
||||
endDate?: Date
|
||||
) => {
|
||||
if (!searchSpaceId) {
|
||||
if (stopIndexing) {
|
||||
stopIndexing(connectorId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Track quick index clicked event
|
||||
if (connectorType) {
|
||||
|
|
@ -1437,10 +1414,16 @@ export const useConnectorDialog = () => {
|
|||
}
|
||||
|
||||
try {
|
||||
// Format dates if provided, otherwise pass undefined (backend will use defaults)
|
||||
const startDateStr = startDate ? format(startDate, "yyyy-MM-dd") : undefined;
|
||||
const endDateStr = endDate ? format(endDate, "yyyy-MM-dd") : undefined;
|
||||
|
||||
await indexConnector({
|
||||
connector_id: connectorId,
|
||||
queryParams: {
|
||||
search_space_id: searchSpaceId,
|
||||
start_date: startDateStr,
|
||||
end_date: endDateStr,
|
||||
},
|
||||
});
|
||||
toast.success("Indexing started", {
|
||||
|
|
@ -1451,12 +1434,18 @@ export const useConnectorDialog = () => {
|
|||
queryClient.invalidateQueries({
|
||||
queryKey: cacheKeys.logs.summary(Number(searchSpaceId)),
|
||||
});
|
||||
// Note: Don't call stopIndexing here - let useIndexingConnectors hook
|
||||
// detect when last_indexed_at changes via Electric SQL
|
||||
} catch (error) {
|
||||
console.error("Error indexing connector content:", error);
|
||||
toast.error(error instanceof Error ? error.message : "Failed to start indexing");
|
||||
// Stop indexing state on error
|
||||
if (stopIndexing) {
|
||||
stopIndexing(connectorId);
|
||||
}
|
||||
}
|
||||
},
|
||||
[searchSpaceId, indexConnector]
|
||||
[searchSpaceId, indexConnector, queryClient]
|
||||
);
|
||||
|
||||
// Handle going back from edit view
|
||||
|
|
@ -1578,7 +1567,6 @@ export const useConnectorDialog = () => {
|
|||
allConnectors,
|
||||
viewingAccountsType,
|
||||
viewingMCPList,
|
||||
viewingComposio,
|
||||
|
||||
// Setters
|
||||
setSearchQuery,
|
||||
|
|
@ -1614,12 +1602,5 @@ export const useConnectorDialog = () => {
|
|||
connectorConfig,
|
||||
setConnectorConfig,
|
||||
setIndexingConnectorConfig,
|
||||
|
||||
// Composio
|
||||
viewingComposio,
|
||||
connectingComposioToolkit,
|
||||
handleOpenComposio,
|
||||
handleBackFromComposio,
|
||||
handleConnectComposioToolkit,
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,17 +2,24 @@
|
|||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
|
||||
import type { InboxItem } from "@/contracts/types/inbox.types";
|
||||
import { isConnectorIndexingMetadata } from "@/contracts/types/inbox.types";
|
||||
|
||||
/**
|
||||
* Hook to track which connectors are currently indexing using local state.
|
||||
*
|
||||
* This provides a better UX than polling by:
|
||||
* 1. Setting indexing state immediately when user triggers indexing (optimistic)
|
||||
* 2. Clearing indexing state when Electric SQL detects last_indexed_at changed
|
||||
* 2. Detecting in_progress notifications from Electric SQL to restore state after remounts
|
||||
* 3. Clearing indexing state when notifications become completed or failed
|
||||
* 4. Clearing indexing state when Electric SQL detects last_indexed_at changed
|
||||
*
|
||||
* The actual `last_indexed_at` value comes from Electric SQL/PGlite, not local state.
|
||||
*/
|
||||
export function useIndexingConnectors(connectors: SearchSourceConnector[]) {
|
||||
export function useIndexingConnectors(
|
||||
connectors: SearchSourceConnector[],
|
||||
inboxItems?: InboxItem[]
|
||||
) {
|
||||
// Set of connector IDs that are currently indexing
|
||||
const [indexingConnectorIds, setIndexingConnectorIds] = useState<Set<number>>(new Set());
|
||||
|
||||
|
|
@ -22,31 +29,71 @@ export function useIndexingConnectors(connectors: SearchSourceConnector[]) {
|
|||
// Detect when last_indexed_at changes (indexing completed) via Electric SQL
|
||||
useEffect(() => {
|
||||
const previousValues = previousLastIndexedAtRef.current;
|
||||
const newIndexingIds = new Set(indexingConnectorIds);
|
||||
let hasChanges = false;
|
||||
|
||||
for (const connector of connectors) {
|
||||
const previousValue = previousValues.get(connector.id);
|
||||
const currentValue = connector.last_indexed_at;
|
||||
|
||||
// If last_indexed_at changed and connector was in indexing state, clear it
|
||||
// If last_indexed_at changed, clear it from indexing state
|
||||
if (
|
||||
previousValue !== undefined && // We've seen this connector before
|
||||
previousValue !== currentValue && // Value changed
|
||||
indexingConnectorIds.has(connector.id) // It was marked as indexing
|
||||
previousValue !== currentValue // Value changed
|
||||
) {
|
||||
newIndexingIds.delete(connector.id);
|
||||
hasChanges = true;
|
||||
// Use functional update to access current state
|
||||
setIndexingConnectorIds((prev) => {
|
||||
if (prev.has(connector.id)) {
|
||||
const next = new Set(prev);
|
||||
next.delete(connector.id);
|
||||
return next;
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
}
|
||||
|
||||
// Update previous value tracking
|
||||
previousValues.set(connector.id, currentValue);
|
||||
}
|
||||
}, [connectors]);
|
||||
|
||||
if (hasChanges) {
|
||||
setIndexingConnectorIds(newIndexingIds);
|
||||
}
|
||||
}, [connectors, indexingConnectorIds]);
|
||||
// Detect notification status changes and update indexing state accordingly
|
||||
// This restores spinner state after component remounts and handles all status transitions
|
||||
useEffect(() => {
|
||||
if (!inboxItems || inboxItems.length === 0) return;
|
||||
|
||||
setIndexingConnectorIds((prev) => {
|
||||
const newIndexingIds = new Set(prev);
|
||||
let hasChanges = false;
|
||||
|
||||
for (const item of inboxItems) {
|
||||
// Only check connector_indexing notifications
|
||||
if (item.type !== "connector_indexing") continue;
|
||||
|
||||
const metadata = isConnectorIndexingMetadata(item.metadata) ? item.metadata : null;
|
||||
if (!metadata) continue;
|
||||
|
||||
// If status is "in_progress", add connector to indexing set
|
||||
if (metadata.status === "in_progress") {
|
||||
if (!newIndexingIds.has(metadata.connector_id)) {
|
||||
newIndexingIds.add(metadata.connector_id);
|
||||
hasChanges = true;
|
||||
}
|
||||
}
|
||||
// If status is "completed" or "failed", remove connector from indexing set
|
||||
else if (
|
||||
metadata.status === "completed" ||
|
||||
metadata.status === "failed" ||
|
||||
(metadata.error_message && metadata.error_message.trim().length > 0)
|
||||
) {
|
||||
if (newIndexingIds.has(metadata.connector_id)) {
|
||||
newIndexingIds.delete(metadata.connector_id);
|
||||
hasChanges = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return hasChanges ? newIndexingIds : prev;
|
||||
});
|
||||
}, [inboxItems]);
|
||||
|
||||
// Add a connector to the indexing set (called when indexing starts)
|
||||
const startIndexing = useCallback((connectorId: number) => {
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
"use client";
|
||||
|
||||
import { ArrowRight, Cable, Loader2 } from "lucide-react";
|
||||
import { ArrowRight, Cable } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { FC } from "react";
|
||||
import { useState } from "react";
|
||||
import { getDocumentTypeLabel } from "@/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentTypeIcon";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { TabsContent } from "@/components/ui/tabs";
|
||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||
|
|
@ -13,8 +14,9 @@ import type { SearchSourceConnector } from "@/contracts/types/connector.types";
|
|||
import type { LogActiveTask, LogSummary } from "@/contracts/types/log.types";
|
||||
import { connectorsApiService } from "@/lib/apis/connectors-api.service";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { OAUTH_CONNECTORS } from "../constants/connector-constants";
|
||||
import { COMPOSIO_CONNECTORS, OAUTH_CONNECTORS } from "../constants/connector-constants";
|
||||
import { getDocumentCountForConnector } from "../utils/connector-document-mapping";
|
||||
import { getConnectorDisplayName } from "./all-connectors-tab";
|
||||
|
||||
interface ActiveConnectorsTabProps {
|
||||
searchQuery: string;
|
||||
|
|
@ -113,7 +115,10 @@ export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
|
|||
|
||||
// Get display info for OAuth connector type
|
||||
const getOAuthConnectorTypeInfo = (connectorType: string) => {
|
||||
const oauthConnector = OAUTH_CONNECTORS.find((c) => c.connectorType === connectorType);
|
||||
// Check both OAUTH_CONNECTORS and COMPOSIO_CONNECTORS
|
||||
const oauthConnector =
|
||||
OAUTH_CONNECTORS.find((c) => c.connectorType === connectorType) ||
|
||||
COMPOSIO_CONNECTORS.find((c) => c.connectorType === connectorType);
|
||||
return {
|
||||
title:
|
||||
oauthConnector?.title ||
|
||||
|
|
@ -205,7 +210,7 @@ export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
|
|||
<p className="text-[14px] font-semibold leading-tight truncate">{title}</p>
|
||||
{isAnyIndexing ? (
|
||||
<p className="text-[11px] text-primary mt-1 flex items-center gap-1.5">
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
<Spinner size="xs" />
|
||||
Syncing
|
||||
</p>
|
||||
) : (
|
||||
|
|
@ -260,13 +265,13 @@ export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
|
|||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-[14px] font-semibold leading-tight">
|
||||
{connector.name}
|
||||
<p className="text-[14px] font-semibold leading-tight truncate">
|
||||
{getConnectorDisplayName(connector.name)}
|
||||
</p>
|
||||
</div>
|
||||
{isIndexing ? (
|
||||
<p className="text-[11px] text-primary mt-1 flex items-center gap-1.5">
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
<Spinner size="xs" />
|
||||
Syncing
|
||||
</p>
|
||||
) : !isMCPConnector ? (
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import type { FC } from "react";
|
|||
import { EnumConnectorName } from "@/contracts/enums/connector";
|
||||
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
|
||||
import { isSelfHosted } from "@/lib/env-config";
|
||||
import { ComposioConnectorCard } from "../components/composio-connector-card";
|
||||
import { ConnectorCard } from "../components/connector-card";
|
||||
import {
|
||||
COMPOSIO_CONNECTORS,
|
||||
|
|
@ -35,13 +34,14 @@ interface AllConnectorsTabProps {
|
|||
allConnectors: SearchSourceConnector[] | undefined;
|
||||
documentTypeCounts?: Record<string, number>;
|
||||
indexingConnectorIds?: Set<number>;
|
||||
onConnectOAuth: (connector: (typeof OAUTH_CONNECTORS)[number]) => void;
|
||||
onConnectOAuth: (
|
||||
connector: (typeof OAUTH_CONNECTORS)[number] | (typeof COMPOSIO_CONNECTORS)[number]
|
||||
) => void;
|
||||
onConnectNonOAuth?: (connectorType: string) => void;
|
||||
onCreateWebcrawler?: () => void;
|
||||
onCreateYouTubeCrawler?: () => void;
|
||||
onManage?: (connector: SearchSourceConnector) => void;
|
||||
onViewAccountsList?: (connectorType: string, connectorTitle: string) => void;
|
||||
onOpenComposio?: () => void;
|
||||
}
|
||||
|
||||
export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
|
||||
|
|
@ -57,7 +57,6 @@ export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
|
|||
onCreateYouTubeCrawler,
|
||||
onManage,
|
||||
onViewAccountsList,
|
||||
onOpenComposio,
|
||||
}) => {
|
||||
// Check if self-hosted mode (for showing self-hosted only connectors)
|
||||
const selfHosted = isSelfHosted();
|
||||
|
|
@ -93,23 +92,18 @@ export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
|
|||
c.description.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
// Count Composio connectors
|
||||
const composioConnectorCount = allConnectors
|
||||
? allConnectors.filter(
|
||||
(c: SearchSourceConnector) => c.connector_type === EnumConnectorName.COMPOSIO_CONNECTOR
|
||||
).length
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Quick Connect */}
|
||||
{filteredOAuth.length > 0 && (
|
||||
{/* Managed OAuth (Composio Integrations) */}
|
||||
{filteredComposio.length > 0 && (
|
||||
<section>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<h3 className="text-sm font-semibold text-muted-foreground">Quick Connect</h3>
|
||||
<h3 className="text-sm font-semibold text-muted-foreground">
|
||||
Managed OAuth (Composio)
|
||||
</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{filteredOAuth.map((connector) => {
|
||||
{filteredComposio.map((connector) => {
|
||||
const isConnected = connectedTypes.has(connector.connectorType);
|
||||
const isConnecting = connectingId === connector.id;
|
||||
|
||||
|
|
@ -123,18 +117,6 @@ export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
|
|||
|
||||
const accountCount = typeConnectors.length;
|
||||
|
||||
// Get the most recent last_indexed_at across all accounts
|
||||
const mostRecentLastIndexed = typeConnectors.reduce<string | undefined>(
|
||||
(latest, c) => {
|
||||
if (!c.last_indexed_at) return latest;
|
||||
if (!latest) return c.last_indexed_at;
|
||||
return new Date(c.last_indexed_at) > new Date(latest)
|
||||
? c.last_indexed_at
|
||||
: latest;
|
||||
},
|
||||
undefined
|
||||
);
|
||||
|
||||
const documentCount = getDocumentCountForConnector(
|
||||
connector.connectorType,
|
||||
documentTypeCounts
|
||||
|
|
@ -168,29 +150,59 @@ export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
|
|||
</section>
|
||||
)}
|
||||
|
||||
{/* Composio Integrations */}
|
||||
{/* {filteredComposio.length > 0 && onOpenComposio && (
|
||||
{/* Quick Connect */}
|
||||
{filteredOAuth.length > 0 && (
|
||||
<section>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<h3 className="text-sm font-semibold text-muted-foreground">Managed OAuth</h3>
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-violet-500/10 text-violet-600 dark:text-violet-400 border border-violet-500/20 font-medium">
|
||||
No verification needed
|
||||
</span>
|
||||
<h3 className="text-sm font-semibold text-muted-foreground">Quick Connect</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{filteredComposio.map((connector) => (
|
||||
<ComposioConnectorCard
|
||||
key={connector.id}
|
||||
id={connector.id}
|
||||
title={connector.title}
|
||||
description={connector.description}
|
||||
connectorCount={composioConnectorCount}
|
||||
onConnect={onOpenComposio}
|
||||
/>
|
||||
))}
|
||||
{filteredOAuth.map((connector) => {
|
||||
const isConnected = connectedTypes.has(connector.connectorType);
|
||||
const isConnecting = connectingId === connector.id;
|
||||
|
||||
// Find all connectors of this type
|
||||
const typeConnectors =
|
||||
isConnected && allConnectors
|
||||
? allConnectors.filter(
|
||||
(c: SearchSourceConnector) => c.connector_type === connector.connectorType
|
||||
)
|
||||
: [];
|
||||
|
||||
const accountCount = typeConnectors.length;
|
||||
|
||||
const documentCount = getDocumentCountForConnector(
|
||||
connector.connectorType,
|
||||
documentTypeCounts
|
||||
);
|
||||
|
||||
// Check if any account is currently indexing
|
||||
const isIndexing = typeConnectors.some((c) => indexingConnectorIds?.has(c.id));
|
||||
|
||||
return (
|
||||
<ConnectorCard
|
||||
key={connector.id}
|
||||
id={connector.id}
|
||||
title={connector.title}
|
||||
description={connector.description}
|
||||
connectorType={connector.connectorType}
|
||||
isConnected={isConnected}
|
||||
isConnecting={isConnecting}
|
||||
documentCount={documentCount}
|
||||
accountCount={accountCount}
|
||||
isIndexing={isIndexing}
|
||||
onConnect={() => onConnectOAuth(connector)}
|
||||
onManage={
|
||||
isConnected && onViewAccountsList
|
||||
? () => onViewAccountsList(connector.connectorType, connector.title)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)} */}
|
||||
)}
|
||||
|
||||
{/* More Integrations */}
|
||||
{filteredOther.length > 0 && (
|
||||
|
|
|
|||
|
|
@ -31,7 +31,10 @@ export const CONNECTOR_TO_DOCUMENT_TYPE: Record<string, string> = {
|
|||
// Special mappings (connector type differs from document type)
|
||||
GOOGLE_DRIVE_CONNECTOR: "GOOGLE_DRIVE_FILE",
|
||||
WEBCRAWLER_CONNECTOR: "CRAWLED_URL",
|
||||
COMPOSIO_CONNECTOR: "COMPOSIO_CONNECTOR",
|
||||
// Composio connectors map to their own document types
|
||||
COMPOSIO_GOOGLE_DRIVE_CONNECTOR: "COMPOSIO_GOOGLE_DRIVE_CONNECTOR",
|
||||
COMPOSIO_GMAIL_CONNECTOR: "COMPOSIO_GMAIL_CONNECTOR",
|
||||
COMPOSIO_GOOGLE_CALENDAR_CONNECTOR: "COMPOSIO_GOOGLE_CALENDAR_CONNECTOR",
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1,355 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
ArrowLeft,
|
||||
Calendar,
|
||||
Check,
|
||||
ExternalLink,
|
||||
FileText,
|
||||
Github,
|
||||
HardDrive,
|
||||
Loader2,
|
||||
Mail,
|
||||
MessageSquare,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import type { FC } from "react";
|
||||
import { useState } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ComposioToolkit {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
isIndexable: boolean;
|
||||
}
|
||||
|
||||
interface ComposioToolkitViewProps {
|
||||
searchSpaceId: string;
|
||||
connectedToolkits: string[];
|
||||
onBack: () => void;
|
||||
onConnectToolkit: (toolkitId: string) => void;
|
||||
isConnecting: boolean;
|
||||
connectingToolkitId: string | null;
|
||||
}
|
||||
|
||||
// Available Composio toolkits
|
||||
const COMPOSIO_TOOLKITS: ComposioToolkit[] = [
|
||||
{
|
||||
id: "googledrive",
|
||||
name: "Google Drive",
|
||||
description: "Search your Drive files and documents",
|
||||
isIndexable: true,
|
||||
},
|
||||
{
|
||||
id: "gmail",
|
||||
name: "Gmail",
|
||||
description: "Search through your emails",
|
||||
isIndexable: true,
|
||||
},
|
||||
{
|
||||
id: "googlecalendar",
|
||||
name: "Google Calendar",
|
||||
description: "Search through your events",
|
||||
isIndexable: true,
|
||||
},
|
||||
{
|
||||
id: "slack",
|
||||
name: "Slack",
|
||||
description: "Search Slack messages",
|
||||
isIndexable: false,
|
||||
},
|
||||
{
|
||||
id: "notion",
|
||||
name: "Notion",
|
||||
description: "Search Notion pages",
|
||||
isIndexable: false,
|
||||
},
|
||||
{
|
||||
id: "github",
|
||||
name: "GitHub",
|
||||
description: "Search repositories and code",
|
||||
isIndexable: false,
|
||||
},
|
||||
];
|
||||
|
||||
// Get icon for toolkit
|
||||
const getToolkitIcon = (toolkitId: string, className?: string) => {
|
||||
const iconClass = className || "size-5";
|
||||
|
||||
switch (toolkitId) {
|
||||
case "googledrive":
|
||||
return (
|
||||
<Image
|
||||
src="/connectors/google-drive.svg"
|
||||
alt="Google Drive"
|
||||
width={20}
|
||||
height={20}
|
||||
className={iconClass}
|
||||
/>
|
||||
);
|
||||
case "gmail":
|
||||
return (
|
||||
<Image
|
||||
src="/connectors/google-gmail.svg"
|
||||
alt="Gmail"
|
||||
width={20}
|
||||
height={20}
|
||||
className={iconClass}
|
||||
/>
|
||||
);
|
||||
case "googlecalendar":
|
||||
return (
|
||||
<Image
|
||||
src="/connectors/google-calendar.svg"
|
||||
alt="Google Calendar"
|
||||
width={20}
|
||||
height={20}
|
||||
className={iconClass}
|
||||
/>
|
||||
);
|
||||
case "slack":
|
||||
return (
|
||||
<Image
|
||||
src="/connectors/slack.svg"
|
||||
alt="Slack"
|
||||
width={20}
|
||||
height={20}
|
||||
className={iconClass}
|
||||
/>
|
||||
);
|
||||
case "notion":
|
||||
return (
|
||||
<Image
|
||||
src="/connectors/notion.svg"
|
||||
alt="Notion"
|
||||
width={20}
|
||||
height={20}
|
||||
className={iconClass}
|
||||
/>
|
||||
);
|
||||
case "github":
|
||||
return (
|
||||
<Image
|
||||
src="/connectors/github.svg"
|
||||
alt="GitHub"
|
||||
width={20}
|
||||
height={20}
|
||||
className={iconClass}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return <Zap className={iconClass} />;
|
||||
}
|
||||
};
|
||||
|
||||
export const ComposioToolkitView: FC<ComposioToolkitViewProps> = ({
|
||||
searchSpaceId,
|
||||
connectedToolkits,
|
||||
onBack,
|
||||
onConnectToolkit,
|
||||
isConnecting,
|
||||
connectingToolkitId,
|
||||
}) => {
|
||||
const [hoveredToolkit, setHoveredToolkit] = useState<string | null>(null);
|
||||
|
||||
// Separate indexable and non-indexable toolkits
|
||||
const indexableToolkits = COMPOSIO_TOOLKITS.filter((t) => t.isIndexable);
|
||||
const nonIndexableToolkits = COMPOSIO_TOOLKITS.filter((t) => !t.isIndexable);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<div className="px-6 sm:px-12 pt-8 sm:pt-10 pb-4 sm:pb-6 border-b border-border/50 bg-muted">
|
||||
{/* Back button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className="flex items-center gap-2 text-xs sm:text-sm text-muted-foreground hover:text-foreground mb-6 w-fit transition-colors"
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
Back to connectors
|
||||
</button>
|
||||
|
||||
{/* Header content */}
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-4">
|
||||
<div className="flex gap-4 flex-1 w-full sm:w-auto">
|
||||
<div className="flex h-14 w-14 items-center justify-center rounded-xl bg-gradient-to-br from-violet-500/20 to-purple-500/20 border border-violet-500/30 shrink-0">
|
||||
<Image
|
||||
src="/connectors/composio.svg"
|
||||
alt="Composio"
|
||||
width={28}
|
||||
height={28}
|
||||
className="size-7"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h2 className="text-xl sm:text-2xl font-semibold tracking-tight">Composio</h2>
|
||||
<p className="text-xs sm:text-sm text-muted-foreground mt-1">
|
||||
Connect 100+ apps with managed OAuth - no verification needed
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
href="https://composio.dev"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<span>Powered by Composio</span>
|
||||
<ExternalLink className="size-3" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto px-6 sm:px-12 py-6 sm:py-8">
|
||||
{/* Indexable Toolkits (Google Services) */}
|
||||
<section className="mb-8">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<h3 className="text-sm font-semibold text-foreground">Google Services</h3>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-[10px] px-1.5 py-0 h-5 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20"
|
||||
>
|
||||
Indexable
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mb-4">
|
||||
Connect Google services via Composio's verified OAuth app. Your data will be
|
||||
indexed and searchable.
|
||||
</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{indexableToolkits.map((toolkit) => {
|
||||
const isConnected = connectedToolkits.includes(toolkit.id);
|
||||
const isThisConnecting = connectingToolkitId === toolkit.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={toolkit.id}
|
||||
onMouseEnter={() => setHoveredToolkit(toolkit.id)}
|
||||
onMouseLeave={() => setHoveredToolkit(null)}
|
||||
className={cn(
|
||||
"group relative flex flex-col p-4 rounded-xl border transition-all duration-200",
|
||||
isConnected
|
||||
? "border-emerald-500/30 bg-emerald-500/5"
|
||||
: "border-border bg-card hover:border-violet-500/30 hover:bg-violet-500/5"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-10 w-10 items-center justify-center rounded-lg border transition-colors",
|
||||
isConnected
|
||||
? "bg-emerald-500/10 border-emerald-500/20"
|
||||
: "bg-muted border-border group-hover:border-violet-500/20 group-hover:bg-violet-500/10"
|
||||
)}
|
||||
>
|
||||
{getToolkitIcon(toolkit.id, "size-5")}
|
||||
</div>
|
||||
{isConnected && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-[10px] px-1.5 py-0 h-5 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20"
|
||||
>
|
||||
<Check className="size-3 mr-0.5" />
|
||||
Connected
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<h4 className="text-sm font-medium mb-1">{toolkit.name}</h4>
|
||||
<p className="text-xs text-muted-foreground mb-4 flex-1">{toolkit.description}</p>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={isConnected ? "secondary" : "default"}
|
||||
className={cn(
|
||||
"w-full h-8 text-xs font-medium",
|
||||
!isConnected && "bg-violet-600 hover:bg-violet-700 text-white"
|
||||
)}
|
||||
onClick={() => onConnectToolkit(toolkit.id)}
|
||||
disabled={isConnecting || isConnected}
|
||||
>
|
||||
{isThisConnecting ? (
|
||||
<>
|
||||
<Loader2 className="size-3 mr-1.5 animate-spin" />
|
||||
Connecting...
|
||||
</>
|
||||
) : isConnected ? (
|
||||
"Connected"
|
||||
) : (
|
||||
"Connect"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Non-Indexable Toolkits (Coming Soon) */}
|
||||
<section>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<h3 className="text-sm font-semibold text-foreground">More Integrations</h3>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-[10px] px-1.5 py-0 h-5 bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20"
|
||||
>
|
||||
Coming Soon
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mb-4">
|
||||
Connect these services for future indexing support. Currently available for connection
|
||||
only.
|
||||
</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 opacity-60">
|
||||
{nonIndexableToolkits.map((toolkit) => (
|
||||
<div
|
||||
key={toolkit.id}
|
||||
className="group relative flex flex-col p-4 rounded-xl border border-border bg-card/50"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg border bg-muted border-border">
|
||||
{getToolkitIcon(toolkit.id, "size-5")}
|
||||
</div>
|
||||
<Badge variant="outline" className="text-[10px] px-1.5 py-0 h-5">
|
||||
Soon
|
||||
</Badge>
|
||||
</div>
|
||||
<h4 className="text-sm font-medium mb-1">{toolkit.name}</h4>
|
||||
<p className="text-xs text-muted-foreground mb-4 flex-1">{toolkit.description}</p>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="w-full h-8 text-xs font-medium"
|
||||
disabled
|
||||
>
|
||||
Coming Soon
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Info footer */}
|
||||
<div className="mt-8 p-4 rounded-xl bg-muted/50 border border-border/50">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-violet-500/10 border border-violet-500/20 shrink-0">
|
||||
<Zap className="size-4 text-violet-500" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-1">Why use Composio?</h4>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
Composio provides pre-verified OAuth apps, so you don't need to wait for Google
|
||||
app verification. Your data is securely processed through Composio's managed
|
||||
authentication.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,9 +1,10 @@
|
|||
"use client";
|
||||
|
||||
import { differenceInDays, differenceInMinutes, format, isToday, isYesterday } from "date-fns";
|
||||
import { ArrowLeft, Loader2, Plus, Server } from "lucide-react";
|
||||
import { ArrowLeft, Plus, Server } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { EnumConnectorName } from "@/contracts/enums/connector";
|
||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
|
||||
|
|
@ -143,7 +144,7 @@ export const ConnectorAccountsListView: FC<ConnectorAccountsListViewProps> = ({
|
|||
>
|
||||
<div className="flex h-5 w-5 items-center justify-center rounded-md bg-primary/10 shrink-0">
|
||||
{isConnecting ? (
|
||||
<Loader2 className="size-3 animate-spin text-primary" />
|
||||
<Spinner size="xs" className="text-primary" />
|
||||
) : (
|
||||
<Plus className="size-3 text-primary" />
|
||||
)}
|
||||
|
|
@ -207,7 +208,7 @@ export const ConnectorAccountsListView: FC<ConnectorAccountsListViewProps> = ({
|
|||
</p>
|
||||
{isIndexing ? (
|
||||
<p className="text-[11px] text-primary mt-1 flex items-center gap-1.5">
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
<Spinner size="xs" />
|
||||
Syncing
|
||||
</p>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import { TagInput, type Tag as TagType } from "emblor";
|
||||
import { useAtom } from "jotai";
|
||||
import { ArrowLeft, Loader2 } from "lucide-react";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { type FC, useState } from "react";
|
||||
|
|
@ -10,6 +10,7 @@ import { toast } from "sonner";
|
|||
import { createDocumentMutationAtom } from "@/atoms/documents/document-mutation.atoms";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { EnumConnectorName } from "@/contracts/enums/connector";
|
||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||
|
||||
|
|
@ -222,7 +223,7 @@ export const YouTubeCrawlerView: FC<YouTubeCrawlerViewProps> = ({ searchSpaceId,
|
|||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
{t("processing")}
|
||||
</>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import type { CommentActionsProps } from "./types";
|
||||
|
|
@ -23,7 +24,7 @@ export function CommentActions({ canEdit, canDelete, onEdit, onDelete }: Comment
|
|||
size="icon"
|
||||
className="size-7 opacity-100 md:opacity-0 md:group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<MoreHorizontal className="size-4" />
|
||||
<MoreHorizontal className="size-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
|
|
@ -33,8 +34,9 @@ export function CommentActions({ canEdit, canDelete, onEdit, onDelete }: Comment
|
|||
Edit
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{canEdit && canDelete && <DropdownMenuSeparator />}
|
||||
{canDelete && (
|
||||
<DropdownMenuItem onClick={onDelete} className="text-destructive">
|
||||
<DropdownMenuItem onClick={onDelete} className="text-destructive focus:text-destructive">
|
||||
<Trash2 className="mr-2 size-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { MemberMentionItem } from "./member-mention-item";
|
||||
import type { MemberMentionPickerProps } from "./types";
|
||||
|
||||
|
|
@ -24,7 +24,7 @@ export function MemberMentionPicker({
|
|||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-6">
|
||||
<Loader2 className="size-5 animate-spin text-muted-foreground" />
|
||||
<Spinner size="md" className="text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,364 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
File,
|
||||
FileSpreadsheet,
|
||||
FileText,
|
||||
FolderClosed,
|
||||
FolderOpen,
|
||||
HardDrive,
|
||||
Image,
|
||||
Presentation,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { useComposioDriveFolders } from "@/hooks/use-composio-drive-folders";
|
||||
import { connectorsApiService } from "@/lib/apis/connectors-api.service";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface DriveItem {
|
||||
id: string;
|
||||
name: string;
|
||||
mimeType: string;
|
||||
isFolder: boolean;
|
||||
parents?: string[];
|
||||
size?: number;
|
||||
iconLink?: string;
|
||||
}
|
||||
|
||||
interface ItemTreeNode {
|
||||
item: DriveItem;
|
||||
children: DriveItem[] | null; // null = not loaded, [] = loaded but empty
|
||||
isExpanded: boolean;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
interface SelectedFolder {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface ComposioDriveFolderTreeProps {
|
||||
connectorId: number;
|
||||
selectedFolders: SelectedFolder[];
|
||||
onSelectFolders: (folders: SelectedFolder[]) => void;
|
||||
selectedFiles?: SelectedFolder[];
|
||||
onSelectFiles?: (files: SelectedFolder[]) => void;
|
||||
}
|
||||
|
||||
// Helper to get appropriate icon for file type
|
||||
function getFileIcon(mimeType: string, className: string = "h-4 w-4") {
|
||||
if (mimeType.includes("spreadsheet") || mimeType.includes("excel")) {
|
||||
return <FileSpreadsheet className={`${className} text-green-500`} />;
|
||||
}
|
||||
if (mimeType.includes("presentation") || mimeType.includes("powerpoint")) {
|
||||
return <Presentation className={`${className} text-orange-500`} />;
|
||||
}
|
||||
if (mimeType.includes("document") || mimeType.includes("word") || mimeType.includes("text")) {
|
||||
return <FileText className={`${className} text-gray-500`} />;
|
||||
}
|
||||
if (mimeType.includes("image")) {
|
||||
return <Image className={`${className} text-purple-500`} />;
|
||||
}
|
||||
return <File className={`${className} text-gray-500`} />;
|
||||
}
|
||||
|
||||
export function ComposioDriveFolderTree({
|
||||
connectorId,
|
||||
selectedFolders,
|
||||
onSelectFolders,
|
||||
selectedFiles = [],
|
||||
onSelectFiles = () => {},
|
||||
}: ComposioDriveFolderTreeProps) {
|
||||
const [itemStates, setItemStates] = useState<Map<string, ItemTreeNode>>(new Map());
|
||||
|
||||
const { data: rootData, isLoading: isLoadingRoot } = useComposioDriveFolders({
|
||||
connectorId,
|
||||
});
|
||||
|
||||
const rootItems = rootData?.items || [];
|
||||
|
||||
const isFolderSelected = (folderId: string): boolean => {
|
||||
return selectedFolders.some((f) => f.id === folderId);
|
||||
};
|
||||
|
||||
const isFileSelected = (fileId: string): boolean => {
|
||||
return selectedFiles.some((f) => f.id === fileId);
|
||||
};
|
||||
|
||||
const toggleFolderSelection = (folderId: string, folderName: string) => {
|
||||
if (isFolderSelected(folderId)) {
|
||||
onSelectFolders(selectedFolders.filter((f) => f.id !== folderId));
|
||||
} else {
|
||||
onSelectFolders([...selectedFolders, { id: folderId, name: folderName }]);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleFileSelection = (fileId: string, fileName: string) => {
|
||||
if (isFileSelected(fileId)) {
|
||||
onSelectFiles(selectedFiles.filter((f) => f.id !== fileId));
|
||||
} else {
|
||||
onSelectFiles([...selectedFiles, { id: fileId, name: fileName }]);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Find an item by ID across all loaded items (root and nested).
|
||||
*/
|
||||
const findItem = (itemId: string): DriveItem | undefined => {
|
||||
const state = itemStates.get(itemId);
|
||||
if (state?.item) return state.item;
|
||||
|
||||
const rootItem = rootItems.find((item) => item.id === itemId);
|
||||
if (rootItem) return rootItem;
|
||||
|
||||
for (const [, nodeState] of itemStates) {
|
||||
if (nodeState.children) {
|
||||
const found = nodeState.children.find((child) => child.id === itemId);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Load and display contents of a specific folder.
|
||||
*/
|
||||
const loadFolderContents = async (folderId: string) => {
|
||||
try {
|
||||
setItemStates((prev) => {
|
||||
const newMap = new Map(prev);
|
||||
const existing = newMap.get(folderId);
|
||||
if (existing) {
|
||||
newMap.set(folderId, { ...existing, isLoading: true });
|
||||
} else {
|
||||
const item = findItem(folderId);
|
||||
if (item) {
|
||||
newMap.set(folderId, {
|
||||
item,
|
||||
children: null,
|
||||
isExpanded: false,
|
||||
isLoading: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
return newMap;
|
||||
});
|
||||
|
||||
const data = await connectorsApiService.listComposioDriveFolders({
|
||||
connector_id: connectorId,
|
||||
parent_id: folderId,
|
||||
});
|
||||
const items = data.items || [];
|
||||
|
||||
setItemStates((prev) => {
|
||||
const newMap = new Map(prev);
|
||||
const existing = newMap.get(folderId);
|
||||
const item = existing?.item || findItem(folderId);
|
||||
|
||||
if (item) {
|
||||
newMap.set(folderId, {
|
||||
item,
|
||||
children: items,
|
||||
isExpanded: true,
|
||||
isLoading: false,
|
||||
});
|
||||
} else {
|
||||
console.error(`Could not find item for folderId: ${folderId}`);
|
||||
}
|
||||
return newMap;
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error loading folder contents:", error);
|
||||
setItemStates((prev) => {
|
||||
const newMap = new Map(prev);
|
||||
const existing = newMap.get(folderId);
|
||||
if (existing) {
|
||||
newMap.set(folderId, { ...existing, isLoading: false });
|
||||
}
|
||||
return newMap;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Toggle folder expand/collapse state.
|
||||
*/
|
||||
const toggleFolder = async (item: DriveItem) => {
|
||||
if (!item.isFolder) return;
|
||||
|
||||
const state = itemStates.get(item.id);
|
||||
|
||||
if (!state || state.children === null) {
|
||||
await loadFolderContents(item.id);
|
||||
} else {
|
||||
setItemStates((prev) => {
|
||||
const newMap = new Map(prev);
|
||||
newMap.set(item.id, {
|
||||
...state,
|
||||
isExpanded: !state.isExpanded,
|
||||
});
|
||||
return newMap;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Render a single item (folder or file) with its children.
|
||||
*/
|
||||
const renderItem = (item: DriveItem, level: number = 0) => {
|
||||
const state = itemStates.get(item.id);
|
||||
const isExpanded = state?.isExpanded || false;
|
||||
const isLoading = state?.isLoading || false;
|
||||
const children = state?.children;
|
||||
const isFolder = item.isFolder;
|
||||
const isSelected = isFolder ? isFolderSelected(item.id) : isFileSelected(item.id);
|
||||
|
||||
const childFolders = children?.filter((c) => c.isFolder) || [];
|
||||
const childFiles = children?.filter((c) => !c.isFolder) || [];
|
||||
|
||||
const indentSize = 0.75; // Smaller indent for mobile
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className="w-full sm:ml-[calc(var(--level)*1.25rem)]"
|
||||
style={
|
||||
{ marginLeft: `${level * indentSize}rem`, "--level": level } as React.CSSProperties & {
|
||||
"--level"?: number;
|
||||
}
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center group gap-1 sm:gap-2 h-auto py-1 sm:py-2 px-1 sm:px-2 rounded-md",
|
||||
isFolder && "hover:bg-accent cursor-pointer",
|
||||
!isFolder && "cursor-default opacity-60",
|
||||
isSelected && "bg-accent/50"
|
||||
)}
|
||||
>
|
||||
{isFolder ? (
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center justify-center w-3 h-3 sm:w-4 sm:h-4 shrink-0 bg-transparent border-0 p-0 cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleFolder(item);
|
||||
}}
|
||||
aria-label={isExpanded ? `Collapse ${item.name}` : `Expand ${item.name}`}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Spinner size="xs" className="h-2.5 w-2.5 sm:h-3 sm:w-3" />
|
||||
) : isExpanded ? (
|
||||
<ChevronDown className="h-3 w-3 sm:h-4 sm:w-4" />
|
||||
) : (
|
||||
<ChevronRight className="h-3 w-3 sm:h-4 sm:w-4" />
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<span className="w-3 h-3 sm:w-4 sm:h-4 shrink-0" />
|
||||
)}
|
||||
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => {
|
||||
if (isFolder) {
|
||||
toggleFolderSelection(item.id, item.name);
|
||||
} else {
|
||||
toggleFileSelection(item.id, item.name);
|
||||
}
|
||||
}}
|
||||
className="shrink-0 h-3.5 w-3.5 sm:h-4 sm:w-4 border-slate-400/20 dark:border-white/20"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
|
||||
<div className="shrink-0">
|
||||
{isFolder ? (
|
||||
isExpanded ? (
|
||||
<FolderOpen className="h-3 w-3 sm:h-4 sm:w-4 text-gray-500" />
|
||||
) : (
|
||||
<FolderClosed className="h-3 w-3 sm:h-4 sm:w-4 text-gray-500" />
|
||||
)
|
||||
) : (
|
||||
getFileIcon(item.mimeType, "h-3 w-3 sm:h-4 sm:w-4")
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isFolder ? (
|
||||
<button
|
||||
type="button"
|
||||
className="truncate flex-1 text-left text-xs sm:text-sm min-w-0 bg-transparent border-0 p-0 cursor-pointer"
|
||||
onClick={() => toggleFolder(item)}
|
||||
>
|
||||
{item.name}
|
||||
</button>
|
||||
) : (
|
||||
<span className="truncate flex-1 text-left text-xs sm:text-sm min-w-0">
|
||||
{item.name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isExpanded && isFolder && children && (
|
||||
<div className="w-full">
|
||||
{childFolders.map((child) => renderItem(child, level + 1))}
|
||||
{childFiles.map((child) => renderItem(child, level + 1))}
|
||||
|
||||
{children.length === 0 && (
|
||||
<div className="text-[10px] sm:text-xs text-muted-foreground py-1 sm:py-2 pl-1 sm:pl-2">
|
||||
Empty folder
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border border-slate-400/20 dark:border-white/20 rounded-md w-full overflow-hidden">
|
||||
<ScrollArea className="h-[300px] sm:h-[450px] w-full">
|
||||
<div className="p-1 sm:p-2 pr-2 sm:pr-4 w-full overflow-x-hidden">
|
||||
<div className="mb-1 sm:mb-2 pb-1 sm:pb-2 border-b border-slate-400/20 dark:border-white/20">
|
||||
<div className="flex items-center gap-1 sm:gap-2 h-auto py-1 sm:py-2 px-1 sm:px-2 rounded-md hover:bg-accent cursor-pointer">
|
||||
<Checkbox
|
||||
checked={isFolderSelected("root")}
|
||||
onCheckedChange={() => toggleFolderSelection("root", "My Drive")}
|
||||
className="shrink-0 h-3.5 w-3.5 sm:h-4 sm:w-4 border-slate-400/20 dark:border-white/20"
|
||||
/>
|
||||
<HardDrive className="h-3 w-3 sm:h-4 sm:w-4 text-primary shrink-0" />
|
||||
<button
|
||||
type="button"
|
||||
className="font-semibold truncate text-xs sm:text-sm cursor-pointer bg-transparent border-0 p-0 text-left"
|
||||
onClick={() => toggleFolderSelection("root", "My Drive")}
|
||||
>
|
||||
My Drive
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoadingRoot && (
|
||||
<div className="flex items-center justify-center py-4 sm:py-8">
|
||||
<Spinner size="sm" className="sm:h-6 sm:w-6 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="w-full overflow-x-hidden">
|
||||
{!isLoadingRoot && rootItems.map((item) => renderItem(item, 0))}
|
||||
</div>
|
||||
|
||||
{!isLoadingRoot && rootItems.length === 0 && (
|
||||
<div className="text-center text-xs sm:text-sm text-muted-foreground py-4 sm:py-8">
|
||||
No files or folders found in your Google Drive
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -10,12 +10,12 @@ import {
|
|||
FolderOpen,
|
||||
HardDrive,
|
||||
Image,
|
||||
Loader2,
|
||||
Presentation,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { useGoogleDriveFolders } from "@/hooks/use-google-drive-folders";
|
||||
import { connectorsApiService } from "@/lib/apis/connectors-api.service";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
|
@ -253,7 +253,7 @@ export function GoogleDriveFolderTree({
|
|||
aria-label={isExpanded ? `Collapse ${item.name}` : `Expand ${item.name}`}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="h-2.5 w-2.5 sm:h-3 sm:w-3 animate-spin" />
|
||||
<Spinner size="xs" className="h-2.5 w-2.5 sm:h-3 sm:w-3" />
|
||||
) : isExpanded ? (
|
||||
<ChevronDown className="h-3 w-3 sm:h-4 sm:w-4" />
|
||||
) : (
|
||||
|
|
@ -344,7 +344,7 @@ export function GoogleDriveFolderTree({
|
|||
|
||||
{isLoadingRoot && (
|
||||
<div className="flex items-center justify-center py-4 sm:py-8">
|
||||
<Loader2 className="h-4 w-4 sm:h-6 sm:w-6 animate-spin text-muted-foreground" />
|
||||
<Spinner size="sm" className="sm:h-6 sm:w-6 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,104 +1,55 @@
|
|||
"use client";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { IconMailFilled } from "@tabler/icons-react";
|
||||
import { IconCalendar, IconMailFilled } from "@tabler/icons-react";
|
||||
import { motion } from "motion/react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import type React from "react";
|
||||
import { useId, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { useId } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// Define validation schema matching the database schema
|
||||
const contactFormSchema = z.object({
|
||||
name: z.string().min(1, "Name is required").max(255, "Name is too long"),
|
||||
email: z.email("Invalid email address").max(255, "Email is too long"),
|
||||
company: z.string().min(1, "Company is required").max(255, "Company name is too long"),
|
||||
message: z.string().optional().prefault(""),
|
||||
});
|
||||
|
||||
type ContactFormData = z.infer<typeof contactFormSchema>;
|
||||
|
||||
export function ContactFormGridWithDetails() {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
reset,
|
||||
} = useForm<ContactFormData>({
|
||||
resolver: zodResolver(contactFormSchema),
|
||||
});
|
||||
|
||||
const onSubmit = async (data: ContactFormData) => {
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/contact", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
toast.success("Message sent successfully!", {
|
||||
description: "We will get back to you as soon as possible.",
|
||||
});
|
||||
reset();
|
||||
} else {
|
||||
toast.error("Failed to send message", {
|
||||
description: result.message || "Please try again later.",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error submitting form:", error);
|
||||
toast.error("Something went wrong", {
|
||||
description: "Please try again later.",
|
||||
});
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto grid w-full max-w-7xl grid-cols-1 gap-10 px-4 py-10 md:px-6 md:py-20 lg:grid-cols-2">
|
||||
<div className="relative flex flex-col items-center overflow-hidden lg:items-start">
|
||||
<div className="mx-auto flex w-full max-w-7xl flex-col items-center gap-10 px-4 py-10 md:px-6 md:py-20">
|
||||
<div className="relative flex flex-col items-center overflow-hidden">
|
||||
<div className="flex items-start justify-start">
|
||||
<FeatureIconContainer className="flex items-center justify-center overflow-hidden">
|
||||
<IconMailFilled className="h-6 w-6 text-blue-500" />
|
||||
</FeatureIconContainer>
|
||||
</div>
|
||||
<h2 className="mt-9 bg-gradient-to-b from-neutral-800 to-neutral-900 bg-clip-text text-left text-xl font-bold text-transparent md:text-3xl lg:text-5xl dark:from-neutral-200 dark:to-neutral-300">
|
||||
<h2 className="mt-9 bg-gradient-to-b from-neutral-800 to-neutral-900 bg-clip-text text-center text-xl font-bold text-transparent md:text-3xl lg:text-5xl dark:from-neutral-200 dark:to-neutral-300">
|
||||
Contact
|
||||
</h2>
|
||||
<p className="mt-8 max-w-lg text-center text-base text-neutral-600 md:text-left dark:text-neutral-400">
|
||||
We'd love to Hear From You.
|
||||
<p className="mt-8 max-w-lg text-center text-base text-neutral-600 dark:text-neutral-400">
|
||||
We'd love to hear from you. Schedule a meeting or send us an email.
|
||||
</p>
|
||||
|
||||
<div className="mt-10 hidden flex-col items-center gap-4 md:flex-row lg:flex">
|
||||
<div className="mt-10 flex flex-col items-center gap-6">
|
||||
<Link
|
||||
href="mailto:rohan@surfsense.com"
|
||||
className="text-sm text-neutral-500 dark:text-neutral-400"
|
||||
href="https://calendly.com/eric-surfsense/surfsense-meeting"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-3 rounded-xl bg-gradient-to-b from-blue-500 to-blue-600 px-6 py-3 text-base font-medium text-white shadow-lg transition duration-200 hover:from-blue-600 hover:to-blue-700"
|
||||
>
|
||||
rohan@surfsense.com
|
||||
<IconCalendar className="h-5 w-5" />
|
||||
Schedule a Meeting
|
||||
</Link>
|
||||
<div className="h-1 w-1 rounded-full bg-neutral-500 dark:bg-neutral-400" />
|
||||
|
||||
<div className="flex items-center gap-2 text-neutral-500 dark:text-neutral-400">
|
||||
<span className="h-px w-8 bg-neutral-300 dark:bg-neutral-600" />
|
||||
<span className="text-sm">or</span>
|
||||
<span className="h-px w-8 bg-neutral-300 dark:bg-neutral-600" />
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href="https://cal.com/mod-surfsense"
|
||||
className="text-sm text-neutral-500 dark:text-neutral-400"
|
||||
href="mailto:eric@surfsense.com"
|
||||
className="flex items-center gap-2 text-base text-neutral-600 transition duration-200 hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-neutral-200"
|
||||
>
|
||||
https://cal.com/mod-surfsense
|
||||
<IconMailFilled className="h-5 w-5" />
|
||||
eric@surfsense.com
|
||||
</Link>
|
||||
</div>
|
||||
<div className="div relative mt-20 flex w-[600px] flex-shrink-0 -translate-x-10 items-center justify-center [perspective:800px] [transform-style:preserve-3d] sm:-translate-x-0 lg:-translate-x-32">
|
||||
|
||||
<div className="div relative mt-20 flex w-[600px] flex-shrink-0 items-center justify-center [perspective:800px] [transform-style:preserve-3d]">
|
||||
<Pin className="h-30 w-85 top-0 left-0" />
|
||||
|
||||
<Image
|
||||
|
|
@ -110,95 +61,6 @@ export function ContactFormGridWithDetails() {
|
|||
/>
|
||||
</div>
|
||||
</div>
|
||||
<form
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className="relative mx-auto flex w-full max-w-2xl flex-col items-start gap-4 overflow-hidden rounded-3xl bg-gradient-to-b from-gray-100 to-gray-200 p-4 sm:p-10 dark:from-neutral-900 dark:to-neutral-950"
|
||||
>
|
||||
<Grid size={20} />
|
||||
<div className="relative z-20 mb-4 w-full">
|
||||
<label
|
||||
className="mb-2 inline-block text-sm font-medium text-neutral-600 dark:text-neutral-300"
|
||||
htmlFor="name"
|
||||
>
|
||||
Full name
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
type="text"
|
||||
placeholder="John Doe"
|
||||
{...register("name")}
|
||||
className={cn(
|
||||
"shadow-input h-10 w-full rounded-md border bg-white pl-4 text-sm text-neutral-700 placeholder-neutral-500 outline-none focus:ring-2 focus:ring-neutral-800 focus:outline-none active:outline-none dark:border-neutral-800 dark:bg-neutral-800 dark:text-white",
|
||||
errors.name ? "border-red-500" : "border-transparent"
|
||||
)}
|
||||
/>
|
||||
{errors.name && <p className="mt-1 text-xs text-red-500">{errors.name.message}</p>}
|
||||
</div>
|
||||
<div className="relative z-20 mb-4 w-full">
|
||||
<label
|
||||
className="mb-2 inline-block text-sm font-medium text-neutral-600 dark:text-neutral-300"
|
||||
htmlFor="email"
|
||||
>
|
||||
Email Address
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="john.doe@example.com"
|
||||
{...register("email")}
|
||||
className={cn(
|
||||
"shadow-input h-10 w-full rounded-md border bg-white pl-4 text-sm text-neutral-700 placeholder-neutral-500 outline-none focus:ring-2 focus:ring-neutral-800 focus:outline-none active:outline-none dark:border-neutral-800 dark:bg-neutral-800 dark:text-white",
|
||||
errors.email ? "border-red-500" : "border-transparent"
|
||||
)}
|
||||
/>
|
||||
{errors.email && <p className="mt-1 text-xs text-red-500">{errors.email.message}</p>}
|
||||
</div>
|
||||
<div className="relative z-20 mb-4 w-full">
|
||||
<label
|
||||
className="mb-2 inline-block text-sm font-medium text-neutral-600 dark:text-neutral-300"
|
||||
htmlFor="company"
|
||||
>
|
||||
Company
|
||||
</label>
|
||||
<input
|
||||
id="company"
|
||||
type="text"
|
||||
placeholder="Example Inc."
|
||||
{...register("company")}
|
||||
className={cn(
|
||||
"shadow-input h-10 w-full rounded-md border bg-white pl-4 text-sm text-neutral-700 placeholder-neutral-500 outline-none focus:ring-2 focus:ring-neutral-800 focus:outline-none active:outline-none dark:border-neutral-800 dark:bg-neutral-800 dark:text-white",
|
||||
errors.company ? "border-red-500" : "border-transparent"
|
||||
)}
|
||||
/>
|
||||
{errors.company && <p className="mt-1 text-xs text-red-500">{errors.company.message}</p>}
|
||||
</div>
|
||||
<div className="relative z-20 mb-4 w-full">
|
||||
<label
|
||||
className="mb-2 inline-block text-sm font-medium text-neutral-600 dark:text-neutral-300"
|
||||
htmlFor="message"
|
||||
>
|
||||
Message <span className="text-neutral-400 text-xs font-normal">(optional)</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="message"
|
||||
rows={5}
|
||||
placeholder="Type your message here"
|
||||
{...register("message")}
|
||||
className={cn(
|
||||
"shadow-input w-full rounded-md border bg-white pt-4 pl-4 text-sm text-neutral-700 placeholder-neutral-500 outline-none focus:ring-2 focus:ring-neutral-800 focus:outline-none active:outline-none dark:border-neutral-800 dark:bg-neutral-800 dark:text-white",
|
||||
errors.message ? "border-red-500" : "border-transparent"
|
||||
)}
|
||||
/>
|
||||
{errors.message && <p className="mt-1 text-xs text-red-500">{errors.message.message}</p>}
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="relative z-10 flex items-center justify-center rounded-md border border-transparent bg-neutral-800 px-4 py-2 text-sm font-medium text-white shadow-[0px_1px_0px_0px_#FFFFFF20_inset] transition duration-200 hover:bg-neutral-900 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm"
|
||||
>
|
||||
{isSubmitting ? "Submitting..." : "Submit"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -184,7 +184,7 @@ function GetStartedButton() {
|
|||
return (
|
||||
<motion.div whileHover={{ scale: 1.02, y: -2 }} whileTap={{ scale: 0.98 }}>
|
||||
<Link
|
||||
href="/register"
|
||||
href="/login"
|
||||
className="group relative z-20 flex h-11 w-full cursor-pointer items-center justify-center gap-2 rounded-xl bg-black px-6 py-2.5 text-sm font-semibold text-white shadow-lg transition-shadow duration-300 hover:shadow-xl sm:w-56 dark:bg-white dark:text-black"
|
||||
>
|
||||
Get Started
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ export const Navbar = () => {
|
|||
const [isScrolled, setIsScrolled] = useState(false);
|
||||
|
||||
const navItems = [
|
||||
// { name: "Home", link: "/" },
|
||||
{ name: "Contact Us", link: "/contact" },
|
||||
{ name: "Pricing", link: "/pricing" },
|
||||
{ name: "Changelog", link: "/changelog" },
|
||||
// { name: "Sign In", link: "/login" },
|
||||
|
|
@ -39,7 +39,7 @@ export const Navbar = () => {
|
|||
}, []);
|
||||
|
||||
return (
|
||||
<div className="fixed top-1 left-0 right-0 z-[60] w-full">
|
||||
<div className="fixed top-1 left-0 right-0 z-60 w-full">
|
||||
<DesktopNav navItems={navItems} isScrolled={isScrolled} />
|
||||
<MobileNav navItems={navItems} isScrolled={isScrolled} />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,12 +1,14 @@
|
|||
"use client";
|
||||
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useAtomValue, useSetAtom } from "jotai";
|
||||
import { Inbox, LogOut, SquareLibrary, Trash2 } from "lucide-react";
|
||||
import { useParams, usePathname, useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useTheme } from "next-themes";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { currentThreadAtom, resetCurrentThreadAtom } from "@/atoms/chat/current-thread.atom";
|
||||
import { deleteSearchSpaceMutationAtom } from "@/atoms/search-spaces/search-space-mutation.atoms";
|
||||
import { searchSpacesAtom } from "@/atoms/search-spaces/search-space-query.atoms";
|
||||
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
|
||||
|
|
@ -21,7 +23,7 @@ import {
|
|||
} from "@/components/ui/dialog";
|
||||
import { useInbox } from "@/hooks/use-inbox";
|
||||
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
|
||||
import { deleteThread, fetchThreads } from "@/lib/chat/thread-persistence";
|
||||
import { deleteThread, fetchThreads, updateThread } from "@/lib/chat/thread-persistence";
|
||||
import { cleanupElectric } from "@/lib/electric/client";
|
||||
import { resetUser, trackLogout } from "@/lib/posthog/events";
|
||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||
|
|
@ -38,6 +40,17 @@ interface LayoutDataProviderProps {
|
|||
breadcrumb?: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format count for display: shows numbers up to 999, then "1k+", "2k+", etc.
|
||||
*/
|
||||
function formatInboxCount(count: number): string {
|
||||
if (count <= 999) {
|
||||
return count.toString();
|
||||
}
|
||||
const thousands = Math.floor(count / 1000);
|
||||
return `${thousands}k+`;
|
||||
}
|
||||
|
||||
export function LayoutDataProvider({
|
||||
searchSpaceId,
|
||||
children,
|
||||
|
|
@ -45,6 +58,7 @@ export function LayoutDataProvider({
|
|||
}: LayoutDataProviderProps) {
|
||||
const t = useTranslations("dashboard");
|
||||
const tCommon = useTranslations("common");
|
||||
const tSidebar = useTranslations("sidebar");
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const pathname = usePathname();
|
||||
|
|
@ -55,11 +69,16 @@ export function LayoutDataProvider({
|
|||
const { data: user } = useAtomValue(currentUserAtom);
|
||||
const { data: searchSpacesData, refetch: refetchSearchSpaces } = useAtomValue(searchSpacesAtom);
|
||||
const { mutateAsync: deleteSearchSpace } = useAtomValue(deleteSearchSpaceMutationAtom);
|
||||
const currentThreadState = useAtomValue(currentThreadAtom);
|
||||
const resetCurrentThread = useSetAtom(resetCurrentThreadAtom);
|
||||
|
||||
// Current IDs from URL
|
||||
// State for handling new chat navigation when router is out of sync
|
||||
const [pendingNewChat, setPendingNewChat] = useState(false);
|
||||
|
||||
// Current IDs from URL, with fallback to atom for replaceState updates
|
||||
const currentChatId = params?.chat_id
|
||||
? Number(Array.isArray(params.chat_id) ? params.chat_id[0] : params.chat_id)
|
||||
: null;
|
||||
: currentThreadState.id;
|
||||
|
||||
// Fetch current search space (for caching purposes)
|
||||
useQuery({
|
||||
|
|
@ -111,6 +130,17 @@ export function LayoutDataProvider({
|
|||
const [isDeletingSearchSpace, setIsDeletingSearchSpace] = useState(false);
|
||||
const [isLeavingSearchSpace, setIsLeavingSearchSpace] = useState(false);
|
||||
|
||||
// Effect to complete new chat navigation after router syncs
|
||||
// This runs when handleNewChat detected an out-of-sync state and triggered a sync
|
||||
useEffect(() => {
|
||||
if (pendingNewChat && params?.chat_id) {
|
||||
// Router is now synced (chat_id is in params), complete navigation to new-chat
|
||||
resetCurrentThread();
|
||||
router.push(`/dashboard/${searchSpaceId}/new-chat`);
|
||||
setPendingNewChat(false);
|
||||
}
|
||||
}, [pendingNewChat, params?.chat_id, router, searchSpaceId, resetCurrentThread]);
|
||||
|
||||
const searchSpaces: SearchSpace[] = useMemo(() => {
|
||||
if (!searchSpacesData || !Array.isArray(searchSpacesData)) return [];
|
||||
return searchSpacesData.map((space) => ({
|
||||
|
|
@ -143,6 +173,7 @@ export function LayoutDataProvider({
|
|||
url: `/dashboard/${searchSpaceId}/new-chat/${thread.id}`,
|
||||
visibility: thread.visibility,
|
||||
isOwnThread: thread.is_own_thread,
|
||||
archived: thread.archived,
|
||||
};
|
||||
|
||||
// Split based on visibility, not ownership:
|
||||
|
|
@ -161,18 +192,18 @@ export function LayoutDataProvider({
|
|||
// Navigation items
|
||||
const navItems: NavItem[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
title: "Documents",
|
||||
url: `/dashboard/${searchSpaceId}/documents`,
|
||||
icon: SquareLibrary,
|
||||
isActive: pathname?.includes("/documents"),
|
||||
},
|
||||
{
|
||||
title: "Inbox",
|
||||
url: "#inbox", // Special URL to indicate this is handled differently
|
||||
icon: Inbox,
|
||||
isActive: isInboxSidebarOpen,
|
||||
badge: unreadCount > 0 ? (unreadCount > 99 ? "99+" : unreadCount) : undefined,
|
||||
badge: unreadCount > 0 ? formatInboxCount(unreadCount) : undefined,
|
||||
},
|
||||
{
|
||||
title: "Documents",
|
||||
url: `/dashboard/${searchSpaceId}/documents`,
|
||||
icon: SquareLibrary,
|
||||
isActive: pathname?.includes("/documents"),
|
||||
},
|
||||
],
|
||||
[searchSpaceId, pathname, isInboxSidebarOpen, unreadCount]
|
||||
|
|
@ -278,8 +309,20 @@ export function LayoutDataProvider({
|
|||
);
|
||||
|
||||
const handleNewChat = useCallback(() => {
|
||||
router.push(`/dashboard/${searchSpaceId}/new-chat`);
|
||||
}, [router, searchSpaceId]);
|
||||
// Check if router is out of sync (thread created via replaceState but params don't have chat_id)
|
||||
const isOutOfSync = currentThreadState.id !== null && !params?.chat_id;
|
||||
|
||||
if (isOutOfSync) {
|
||||
// First sync Next.js router by navigating to the current chat's actual URL
|
||||
// This updates the router's internal state to match the browser URL
|
||||
router.replace(`/dashboard/${searchSpaceId}/new-chat/${currentThreadState.id}`);
|
||||
// Set flag to trigger navigation to new-chat after params update
|
||||
setPendingNewChat(true);
|
||||
} else {
|
||||
// Normal navigation - router is in sync
|
||||
router.push(`/dashboard/${searchSpaceId}/new-chat`);
|
||||
}
|
||||
}, [router, searchSpaceId, currentThreadState.id, params?.chat_id]);
|
||||
|
||||
const handleChatSelect = useCallback(
|
||||
(chat: ChatItem) => {
|
||||
|
|
@ -293,6 +336,28 @@ export function LayoutDataProvider({
|
|||
setShowDeleteChatDialog(true);
|
||||
}, []);
|
||||
|
||||
const handleChatArchive = useCallback(
|
||||
async (chat: ChatItem) => {
|
||||
const newArchivedState = !chat.archived;
|
||||
const successMessage = newArchivedState
|
||||
? tSidebar("chat_archived") || "Chat archived"
|
||||
: tSidebar("chat_unarchived") || "Chat restored";
|
||||
|
||||
try {
|
||||
await updateThread(chat.id, { archived: newArchivedState });
|
||||
toast.success(successMessage);
|
||||
// Invalidate queries to refresh UI (React Query will only refetch active queries)
|
||||
queryClient.invalidateQueries({ queryKey: ["threads", searchSpaceId] });
|
||||
queryClient.invalidateQueries({ queryKey: ["all-threads", searchSpaceId] });
|
||||
queryClient.invalidateQueries({ queryKey: ["search-threads", searchSpaceId] });
|
||||
} catch (error) {
|
||||
console.error("Error archiving thread:", error);
|
||||
toast.error(tSidebar("error_archiving_chat") || "Failed to archive chat");
|
||||
}
|
||||
},
|
||||
[queryClient, searchSpaceId, tSidebar]
|
||||
);
|
||||
|
||||
const handleSettings = useCallback(() => {
|
||||
router.push(`/dashboard/${searchSpaceId}/settings`);
|
||||
}, [router, searchSpaceId]);
|
||||
|
|
@ -380,6 +445,7 @@ export function LayoutDataProvider({
|
|||
onNewChat={handleNewChat}
|
||||
onChatSelect={handleChatSelect}
|
||||
onChatDelete={handleChatDelete}
|
||||
onChatArchive={handleChatArchive}
|
||||
onViewAllSharedChats={handleViewAllSharedChats}
|
||||
onViewAllPrivateChats={handleViewAllPrivateChats}
|
||||
user={{
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ export interface ChatItem {
|
|||
isActive?: boolean;
|
||||
visibility?: "PRIVATE" | "SEARCH_SPACE";
|
||||
isOwnThread?: boolean;
|
||||
archived?: boolean;
|
||||
}
|
||||
|
||||
export interface PageUsage {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { Loader2, Plus, Search } from "lucide-react";
|
||||
import { Plus, Search } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
|
@ -26,6 +26,7 @@ import {
|
|||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { trackSearchSpaceCreated } from "@/lib/posthog/events";
|
||||
|
||||
const formSchema = z.object({
|
||||
|
|
@ -82,29 +83,36 @@ export function CreateSearchSpaceDialog({ open, onOpenChange }: CreateSearchSpac
|
|||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
|
||||
<Search className="h-5 w-5 text-primary" />
|
||||
<DialogContent className="max-w-[90vw] sm:max-w-sm p-4 sm:p-5 data-[state=open]:animate-none data-[state=closed]:animate-none">
|
||||
<DialogHeader className="space-y-2 pb-2">
|
||||
<div className="flex items-center gap-2 sm:gap-3">
|
||||
<div className="flex h-8 w-8 sm:h-10 sm:w-10 items-center justify-center rounded-lg bg-primary/10 flex-shrink-0">
|
||||
<Search className="h-4 w-4 sm:h-5 sm:w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<DialogTitle>{t("create_title")}</DialogTitle>
|
||||
<DialogDescription>{t("create_description")}</DialogDescription>
|
||||
<div className="flex-1 min-w-0">
|
||||
<DialogTitle className="text-base sm:text-lg">{t("create_title")}</DialogTitle>
|
||||
<DialogDescription className="text-xs sm:text-sm mt-0.5">
|
||||
{t("create_description")}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(handleSubmit)} className="flex flex-col gap-4">
|
||||
<form onSubmit={form.handleSubmit(handleSubmit)} className="flex flex-col gap-3 sm:gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("name_label")}</FormLabel>
|
||||
<FormLabel className="text-sm">{t("name_label")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder={t("name_placeholder")} {...field} autoFocus />
|
||||
<Input
|
||||
placeholder={t("name_placeholder")}
|
||||
{...field}
|
||||
autoFocus
|
||||
className="text-sm h-9 sm:h-10"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
|
@ -116,38 +124,47 @@ export function CreateSearchSpaceDialog({ open, onOpenChange }: CreateSearchSpac
|
|||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<FormLabel className="text-sm">
|
||||
{t("description_label")}{" "}
|
||||
<span className="text-muted-foreground font-normal">
|
||||
({tCommon("optional")})
|
||||
</span>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder={t("description_placeholder")} {...field} />
|
||||
<Input
|
||||
placeholder={t("description_placeholder")}
|
||||
{...field}
|
||||
className="text-sm h-9 sm:h-10"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<DialogFooter className="flex gap-2 pt-2">
|
||||
<DialogFooter className="flex-col sm:flex-row gap-2 pt-2 sm:pt-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => handleOpenChange(false)}
|
||||
disabled={isSubmitting}
|
||||
className="w-full sm:w-auto h-9 sm:h-10 text-sm"
|
||||
>
|
||||
{tCommon("cancel")}
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="w-full sm:w-auto h-9 sm:h-10 text-sm"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
<Spinner size="sm" className="mr-1.5" />
|
||||
{t("creating")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
<Plus className="-mr-1 h-4 w-4" />
|
||||
{t("create_button")}
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ interface LayoutShellProps {
|
|||
onNewChat: () => void;
|
||||
onChatSelect: (chat: ChatItem) => void;
|
||||
onChatDelete?: (chat: ChatItem) => void;
|
||||
onChatArchive?: (chat: ChatItem) => void;
|
||||
onViewAllSharedChats?: () => void;
|
||||
onViewAllPrivateChats?: () => void;
|
||||
user: User;
|
||||
|
|
@ -59,6 +60,7 @@ export function LayoutShell({
|
|||
onNewChat,
|
||||
onChatSelect,
|
||||
onChatDelete,
|
||||
onChatArchive,
|
||||
onViewAllSharedChats,
|
||||
onViewAllPrivateChats,
|
||||
user,
|
||||
|
|
@ -107,6 +109,7 @@ export function LayoutShell({
|
|||
onNewChat={onNewChat}
|
||||
onChatSelect={onChatSelect}
|
||||
onChatDelete={onChatDelete}
|
||||
onChatArchive={onChatArchive}
|
||||
onViewAllSharedChats={onViewAllSharedChats}
|
||||
onViewAllPrivateChats={onViewAllPrivateChats}
|
||||
user={user}
|
||||
|
|
@ -155,6 +158,7 @@ export function LayoutShell({
|
|||
onNewChat={onNewChat}
|
||||
onChatSelect={onChatSelect}
|
||||
onChatDelete={onChatDelete}
|
||||
onChatArchive={onChatArchive}
|
||||
onViewAllSharedChats={onViewAllSharedChats}
|
||||
onViewAllPrivateChats={onViewAllPrivateChats}
|
||||
user={user}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|||
import { format } from "date-fns";
|
||||
import {
|
||||
ArchiveIcon,
|
||||
Loader2,
|
||||
MessageCircleMore,
|
||||
MoreHorizontal,
|
||||
RotateCcwIcon,
|
||||
|
|
@ -28,6 +27,7 @@ import {
|
|||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
||||
|
|
@ -231,7 +231,7 @@ export function AllPrivateChatsSidebar({
|
|||
initial={{ x: "-100%" }}
|
||||
animate={{ x: 0 }}
|
||||
exit={{ x: "-100%" }}
|
||||
transition={{ type: "spring", damping: 25, stiffness: 300 }}
|
||||
transition={{ type: "tween", duration: 0.3, ease: "easeOut" }}
|
||||
className="fixed inset-y-0 left-0 z-70 w-80 bg-background shadow-xl flex flex-col pointer-events-auto isolate"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
|
|
@ -304,7 +304,7 @@ export function AllPrivateChatsSidebar({
|
|||
<div className="flex-1 overflow-y-auto overflow-x-hidden p-2">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
<Spinner size="md" className="text-muted-foreground" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="text-center py-8 text-sm text-destructive">
|
||||
|
|
@ -365,7 +365,7 @@ export function AllPrivateChatsSidebar({
|
|||
disabled={isBusy}
|
||||
>
|
||||
{isDeleting ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
<Spinner size="xs" />
|
||||
) : (
|
||||
<MoreHorizontal className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|||
import { format } from "date-fns";
|
||||
import {
|
||||
ArchiveIcon,
|
||||
Loader2,
|
||||
MessageCircleMore,
|
||||
MoreHorizontal,
|
||||
RotateCcwIcon,
|
||||
|
|
@ -28,6 +27,7 @@ import {
|
|||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
||||
|
|
@ -231,7 +231,7 @@ export function AllSharedChatsSidebar({
|
|||
initial={{ x: "-100%" }}
|
||||
animate={{ x: 0 }}
|
||||
exit={{ x: "-100%" }}
|
||||
transition={{ type: "spring", damping: 25, stiffness: 300 }}
|
||||
transition={{ type: "tween", duration: 0.3, ease: "easeOut" }}
|
||||
className="fixed inset-y-0 left-0 z-70 w-80 bg-background shadow-xl flex flex-col pointer-events-auto isolate"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
|
|
@ -304,7 +304,7 @@ export function AllSharedChatsSidebar({
|
|||
<div className="flex-1 overflow-y-auto overflow-x-hidden p-2">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
<Spinner size="md" className="text-muted-foreground" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="text-center py-8 text-sm text-destructive">
|
||||
|
|
@ -365,7 +365,7 @@ export function AllSharedChatsSidebar({
|
|||
disabled={isBusy}
|
||||
>
|
||||
{isDeleting ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
<Spinner size="xs" />
|
||||
) : (
|
||||
<MoreHorizontal className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
"use client";
|
||||
|
||||
import { MessageSquare, MoreHorizontal } from "lucide-react";
|
||||
import { ArchiveIcon, MessageSquare, MoreHorizontal, RotateCcwIcon, Trash2 } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
|
@ -14,11 +15,20 @@ import { cn } from "@/lib/utils";
|
|||
interface ChatListItemProps {
|
||||
name: string;
|
||||
isActive?: boolean;
|
||||
archived?: boolean;
|
||||
onClick?: () => void;
|
||||
onArchive?: () => void;
|
||||
onDelete?: () => void;
|
||||
}
|
||||
|
||||
export function ChatListItem({ name, isActive, onClick, onDelete }: ChatListItemProps) {
|
||||
export function ChatListItem({
|
||||
name,
|
||||
isActive,
|
||||
archived,
|
||||
onClick,
|
||||
onArchive,
|
||||
onDelete,
|
||||
}: ChatListItemProps) {
|
||||
const t = useTranslations("sidebar");
|
||||
|
||||
return (
|
||||
|
|
@ -48,15 +58,39 @@ export function ChatListItem({ name, isActive, onClick, onDelete }: ChatListItem
|
|||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" side="right">
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete?.();
|
||||
}}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
{t("delete")}
|
||||
</DropdownMenuItem>
|
||||
{onArchive && (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onArchive();
|
||||
}}
|
||||
>
|
||||
{archived ? (
|
||||
<>
|
||||
<RotateCcwIcon className="mr-2 h-4 w-4" />
|
||||
<span>{t("unarchive") || "Restore"}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ArchiveIcon className="mr-2 h-4 w-4" />
|
||||
<span>{t("archive") || "Archive"}</span>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{onArchive && onDelete && <DropdownMenuSeparator />}
|
||||
{onDelete && (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
<span>{t("delete")}</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -73,6 +73,17 @@ function getInitials(name: string | null | undefined, email: string | null | und
|
|||
return "U";
|
||||
}
|
||||
|
||||
/**
|
||||
* Format count for display: shows numbers up to 999, then "1k+", "2k+", etc.
|
||||
*/
|
||||
function formatInboxCount(count: number): string {
|
||||
if (count <= 999) {
|
||||
return count.toString();
|
||||
}
|
||||
const thousands = Math.floor(count / 1000);
|
||||
return `${thousands}k+`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get display name for connector type
|
||||
*/
|
||||
|
|
@ -82,6 +93,9 @@ function getConnectorTypeDisplayName(connectorType: string): string {
|
|||
GOOGLE_CALENDAR_CONNECTOR: "Google Calendar",
|
||||
GOOGLE_GMAIL_CONNECTOR: "Gmail",
|
||||
GOOGLE_DRIVE_CONNECTOR: "Google Drive",
|
||||
COMPOSIO_GOOGLE_DRIVE_CONNECTOR: "Composio Google Drive",
|
||||
COMPOSIO_GMAIL_CONNECTOR: "Composio Gmail",
|
||||
COMPOSIO_GOOGLE_CALENDAR_CONNECTOR: "Composio Google Calendar",
|
||||
LINEAR_CONNECTOR: "Linear",
|
||||
NOTION_CONNECTOR: "Notion",
|
||||
SLACK_CONNECTOR: "Slack",
|
||||
|
|
@ -482,7 +496,7 @@ export function InboxSidebar({
|
|||
initial={{ x: "-100%" }}
|
||||
animate={{ x: 0 }}
|
||||
exit={{ x: "-100%" }}
|
||||
transition={{ type: "spring", damping: 25, stiffness: 300 }}
|
||||
transition={{ type: "tween", duration: 0.3, ease: "easeOut" }}
|
||||
className="fixed inset-y-0 left-0 z-70 w-90 bg-background shadow-xl flex flex-col pointer-events-auto isolate"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
|
|
@ -765,7 +779,7 @@ export function InboxSidebar({
|
|||
<AtSign className="h-4 w-4" />
|
||||
<span>{t("mentions") || "Mentions"}</span>
|
||||
<span className="inline-flex items-center justify-center min-w-5 h-5 px-1.5 rounded-full bg-primary/20 text-muted-foreground text-xs font-medium">
|
||||
{unreadMentionsCount}
|
||||
{formatInboxCount(unreadMentionsCount)}
|
||||
</span>
|
||||
</span>
|
||||
</TabsTrigger>
|
||||
|
|
@ -777,7 +791,7 @@ export function InboxSidebar({
|
|||
<History className="h-4 w-4" />
|
||||
<span>{t("status") || "Status"}</span>
|
||||
<span className="inline-flex items-center justify-center min-w-5 h-5 px-1.5 rounded-full bg-primary/20 text-muted-foreground text-xs font-medium">
|
||||
{unreadStatusCount}
|
||||
{formatInboxCount(unreadStatusCount)}
|
||||
</span>
|
||||
</span>
|
||||
</TabsTrigger>
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ interface MobileSidebarProps {
|
|||
onNewChat: () => void;
|
||||
onChatSelect: (chat: ChatItem) => void;
|
||||
onChatDelete?: (chat: ChatItem) => void;
|
||||
onChatArchive?: (chat: ChatItem) => void;
|
||||
onViewAllSharedChats?: () => void;
|
||||
onViewAllPrivateChats?: () => void;
|
||||
user: User;
|
||||
|
|
@ -64,6 +65,7 @@ export function MobileSidebar({
|
|||
onNewChat,
|
||||
onChatSelect,
|
||||
onChatDelete,
|
||||
onChatArchive,
|
||||
onViewAllSharedChats,
|
||||
onViewAllPrivateChats,
|
||||
user,
|
||||
|
|
@ -141,6 +143,7 @@ export function MobileSidebar({
|
|||
}}
|
||||
onChatSelect={handleChatSelect}
|
||||
onChatDelete={onChatDelete}
|
||||
onChatArchive={onChatArchive}
|
||||
onViewAllSharedChats={onViewAllSharedChats}
|
||||
onViewAllPrivateChats={onViewAllPrivateChats}
|
||||
user={user}
|
||||
|
|
|
|||
|
|
@ -20,7 +20,9 @@ export function NavSection({ items, onItemClick, isCollapsed = false }: NavSecti
|
|||
const joyrideAttr =
|
||||
item.title === "Documents" || item.title.toLowerCase().includes("documents")
|
||||
? { "data-joyride": "documents-sidebar" }
|
||||
: {};
|
||||
: item.title === "Inbox" || item.title.toLowerCase().includes("inbox")
|
||||
? { "data-joyride": "inbox-sidebar" }
|
||||
: {};
|
||||
|
||||
if (isCollapsed) {
|
||||
return (
|
||||
|
|
@ -32,14 +34,13 @@ export function NavSection({ items, onItemClick, isCollapsed = false }: NavSecti
|
|||
className={cn(
|
||||
"relative flex h-10 w-10 items-center justify-center rounded-md transition-colors",
|
||||
"hover:bg-accent hover:text-accent-foreground",
|
||||
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
|
||||
item.isActive && "bg-accent text-accent-foreground"
|
||||
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
)}
|
||||
{...joyrideAttr}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
{item.badge && (
|
||||
<span className="absolute top-0.5 right-0.5 inline-flex items-center justify-center min-w-4 h-4 px-1 rounded-full bg-red-500 text-white text-[10px] font-medium">
|
||||
<span className="absolute top-0.5 right-0.5 inline-flex items-center justify-center min-w-[14px] h-[14px] px-0.5 rounded-full bg-red-500 text-white text-[9px] font-medium">
|
||||
{item.badge}
|
||||
</span>
|
||||
)}
|
||||
|
|
@ -62,15 +63,14 @@ export function NavSection({ items, onItemClick, isCollapsed = false }: NavSecti
|
|||
className={cn(
|
||||
"flex items-center gap-2 rounded-md mx-2 px-2 py-1.5 text-sm transition-colors text-left",
|
||||
"hover:bg-accent hover:text-accent-foreground",
|
||||
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
|
||||
item.isActive && "bg-accent text-accent-foreground"
|
||||
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
)}
|
||||
{...joyrideAttr}
|
||||
>
|
||||
<Icon className="h-4 w-4 shrink-0" />
|
||||
<span className="flex-1 truncate">{item.title}</span>
|
||||
{item.badge && (
|
||||
<span className="inline-flex items-center justify-center min-w-5 h-5 px-1.5 rounded-full bg-red-500 text-white text-xs font-medium">
|
||||
<span className="inline-flex items-center justify-center min-w-4 h-4 px-1 rounded-full bg-red-500 text-white text-[10px] font-medium">
|
||||
{item.badge}
|
||||
</span>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
"use client";
|
||||
|
||||
import { Mail } from "lucide-react";
|
||||
import { Plus } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
|
||||
interface PageUsageDisplayProps {
|
||||
|
|
@ -9,6 +11,8 @@ interface PageUsageDisplayProps {
|
|||
}
|
||||
|
||||
export function PageUsageDisplay({ pagesUsed, pagesLimit }: PageUsageDisplayProps) {
|
||||
const params = useParams();
|
||||
const searchSpaceId = params.search_space_id;
|
||||
const usagePercentage = (pagesUsed / pagesLimit) * 100;
|
||||
|
||||
return (
|
||||
|
|
@ -21,13 +25,13 @@ export function PageUsageDisplay({ pagesUsed, pagesLimit }: PageUsageDisplayProp
|
|||
<span className="font-medium">{usagePercentage.toFixed(0)}%</span>
|
||||
</div>
|
||||
<Progress value={usagePercentage} className="h-1.5" />
|
||||
<a
|
||||
href="mailto:rohan@surfsense.com?subject=Request%20to%20Increase%20Page%20Limits"
|
||||
<Link
|
||||
href={`/dashboard/${searchSpaceId}/more-pages`}
|
||||
className="flex items-center gap-1.5 text-[10px] text-muted-foreground hover:text-primary transition-colors"
|
||||
>
|
||||
<Mail className="h-3 w-3 shrink-0" />
|
||||
<span>Contact to increase limits</span>
|
||||
</a>
|
||||
<Plus className="h-3 w-3 shrink-0" />
|
||||
<span>Get More Pages</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ interface SidebarProps {
|
|||
onNewChat: () => void;
|
||||
onChatSelect: (chat: ChatItem) => void;
|
||||
onChatDelete?: (chat: ChatItem) => void;
|
||||
onChatArchive?: (chat: ChatItem) => void;
|
||||
onViewAllSharedChats?: () => void;
|
||||
onViewAllPrivateChats?: () => void;
|
||||
user: User;
|
||||
|
|
@ -52,6 +53,7 @@ export function Sidebar({
|
|||
onNewChat,
|
||||
onChatSelect,
|
||||
onChatDelete,
|
||||
onChatArchive,
|
||||
onViewAllSharedChats,
|
||||
onViewAllPrivateChats,
|
||||
user,
|
||||
|
|
@ -175,7 +177,9 @@ export function Sidebar({
|
|||
key={chat.id}
|
||||
name={chat.name}
|
||||
isActive={chat.id === activeChatId}
|
||||
archived={chat.archived}
|
||||
onClick={() => onChatSelect(chat)}
|
||||
onArchive={() => onChatArchive?.(chat)}
|
||||
onDelete={() => onChatDelete?.(chat)}
|
||||
/>
|
||||
))}
|
||||
|
|
@ -216,7 +220,9 @@ export function Sidebar({
|
|||
key={chat.id}
|
||||
name={chat.name}
|
||||
isActive={chat.id === activeChatId}
|
||||
archived={chat.archived}
|
||||
onClick={() => onChatSelect(chat)}
|
||||
onArchive={() => onChatArchive?.(chat)}
|
||||
onDelete={() => onChatDelete?.(chat)}
|
||||
/>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { ChevronsUpDown, ScrollText, Settings, Users } from "lucide-react";
|
||||
import { ChevronsUpDown, Logs, Settings, Users } from "lucide-react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -57,7 +57,7 @@ export function SidebarHeader({
|
|||
{t("manage_members")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => router.push(`/dashboard/${searchSpaceId}/logs`)}>
|
||||
<ScrollText className="mr-2 h-4 w-4" />
|
||||
<Logs className="mr-2 h-4 w-4" />
|
||||
{t("logs")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { ChevronUp, Languages, Laptop, LogOut, Moon, Settings, Sun } from "lucide-react";
|
||||
import { Check, ChevronUp, Languages, Laptop, LogOut, Moon, Settings, Sun } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import {
|
||||
DropdownMenu,
|
||||
|
|
@ -197,11 +197,12 @@ export function SidebarUserProfile({
|
|||
className={cn(
|
||||
"mb-1 last:mb-0 transition-all",
|
||||
"hover:bg-accent/50",
|
||||
isSelected && "bg-accent/80"
|
||||
isSelected && "text-primary"
|
||||
)}
|
||||
>
|
||||
<Icon className="mr-2 h-4 w-4" />
|
||||
<span className="flex-1">{t(themeOption.value)}</span>
|
||||
{isSelected && <Check className="h-4 w-4 shrink-0" />}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
|
|
@ -226,11 +227,12 @@ export function SidebarUserProfile({
|
|||
className={cn(
|
||||
"mb-1 last:mb-0 transition-all",
|
||||
"hover:bg-accent/50",
|
||||
isSelected && "bg-accent/80"
|
||||
isSelected && "text-primary"
|
||||
)}
|
||||
>
|
||||
<span className="mr-2">{language.flag}</span>
|
||||
<span className="flex-1">{language.name}</span>
|
||||
{isSelected && <Check className="h-4 w-4 shrink-0" />}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
|
|
@ -313,11 +315,12 @@ export function SidebarUserProfile({
|
|||
className={cn(
|
||||
"mb-1 last:mb-0 transition-all",
|
||||
"hover:bg-accent/50",
|
||||
isSelected && "bg-accent/80"
|
||||
isSelected && "text-primary"
|
||||
)}
|
||||
>
|
||||
<Icon className="mr-2 h-4 w-4" />
|
||||
<span className="flex-1">{t(themeOption.value)}</span>
|
||||
{isSelected && <Check className="h-4 w-4 shrink-0" />}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
|
|
@ -342,11 +345,12 @@ export function SidebarUserProfile({
|
|||
className={cn(
|
||||
"mb-1 last:mb-0 transition-all",
|
||||
"hover:bg-accent/50",
|
||||
isSelected && "bg-accent/80"
|
||||
isSelected && "text-primary"
|
||||
)}
|
||||
>
|
||||
<span className="mr-2">{language.flag}</span>
|
||||
<span className="flex-1">{language.name}</span>
|
||||
{isSelected && <Check className="h-4 w-4 shrink-0" />}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import {
|
|||
Cloud,
|
||||
Edit3,
|
||||
Globe,
|
||||
Loader2,
|
||||
Plus,
|
||||
Settings2,
|
||||
Sparkles,
|
||||
|
|
@ -36,6 +35,7 @@ import {
|
|||
CommandSeparator,
|
||||
} from "@/components/ui/command";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import type {
|
||||
GlobalNewLLMConfig,
|
||||
NewLLMConfigPublic,
|
||||
|
|
@ -179,7 +179,7 @@ export function ModelSelector({ onEdit, onAddNew, className }: ModelSelectorProp
|
|||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
<Spinner size="sm" className="text-muted-foreground" />
|
||||
<span className="text-muted-foreground hidden md:inline">Loading</span>
|
||||
</>
|
||||
) : currentConfig ? (
|
||||
|
|
|
|||
|
|
@ -1,17 +1,9 @@
|
|||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
BookOpen,
|
||||
ChevronDown,
|
||||
ExternalLink,
|
||||
FileText,
|
||||
Hash,
|
||||
Loader2,
|
||||
Sparkles,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { BookOpen, ChevronDown, ExternalLink, FileText, Hash, Sparkles, X } from "lucide-react";
|
||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import type React from "react";
|
||||
import { forwardRef, type ReactNode, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
|
@ -20,6 +12,7 @@ import { Badge } from "@/components/ui/badge";
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import type {
|
||||
GetDocumentByChunkResponse,
|
||||
GetSurfsenseDocsByChunkResponse,
|
||||
|
|
@ -63,7 +56,7 @@ interface ChunkCardProps {
|
|||
}
|
||||
|
||||
const ChunkCard = forwardRef<HTMLDivElement, ChunkCardProps>(
|
||||
({ chunk, index, totalChunks, isCited, isActive, disableLayoutAnimation }, ref) => {
|
||||
({ chunk, index, totalChunks, isCited }, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
|
|
@ -122,12 +115,13 @@ export function SourceDetailPanel({
|
|||
children,
|
||||
isDocsChunk = false,
|
||||
}: SourceDetailPanelProps) {
|
||||
const t = useTranslations("dashboard");
|
||||
const scrollAreaRef = useRef<HTMLDivElement>(null);
|
||||
const hasScrolledRef = useRef(false); // Use ref to avoid stale closures
|
||||
const [summaryOpen, setSummaryOpen] = useState(false);
|
||||
const [activeChunkIndex, setActiveChunkIndex] = useState<number | null>(null);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [hasScrolledToCited, setHasScrolledToCited] = useState(false);
|
||||
const [_hasScrolledToCited, setHasScrolledToCited] = useState(false);
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -382,11 +376,10 @@ export function SourceDetailPanel({
|
|||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="flex flex-col items-center gap-4"
|
||||
>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 rounded-full bg-primary/20 blur-xl" />
|
||||
<Loader2 className="h-12 w-12 animate-spin text-primary relative" />
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground font-medium">Loading document</p>
|
||||
<Spinner size="lg" />
|
||||
<p className="text-sm text-muted-foreground font-medium">
|
||||
{t("loading_document")}
|
||||
</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -32,6 +32,12 @@ const TOUR_STEPS: TourStep[] = [
|
|||
content: "Access and manage all your uploaded documents.",
|
||||
placement: "right",
|
||||
},
|
||||
{
|
||||
target: '[data-joyride="inbox-sidebar"]',
|
||||
title: "Check your inbox",
|
||||
content: "View mentions and notifications in one place.",
|
||||
placement: "right",
|
||||
},
|
||||
];
|
||||
|
||||
interface TooltipPosition {
|
||||
|
|
@ -188,14 +194,15 @@ function TourTooltip({
|
|||
const getPointerStyles = (): React.CSSProperties => {
|
||||
const lineLength = 16;
|
||||
const dotSize = 6;
|
||||
// Check if this is the documents step (stepIndex === 1)
|
||||
// Check if this is the documents step (stepIndex === 1) or inbox step (stepIndex === 2)
|
||||
const isDocumentsStep = stepIndex === 1;
|
||||
const isInboxStep = stepIndex === 2;
|
||||
|
||||
if (position.pointerPosition === "left") {
|
||||
return {
|
||||
position: "absolute",
|
||||
left: -lineLength - dotSize,
|
||||
top: isDocumentsStep ? "calc(50% - 8px)" : "50%",
|
||||
top: isDocumentsStep || isInboxStep ? "calc(50% - 8px)" : "50%",
|
||||
transform: "translateY(-50%)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
|
|
@ -518,12 +525,13 @@ export function OnboardingTour() {
|
|||
|
||||
// User is new and hasn't seen tour - wait for DOM elements and start tour
|
||||
const checkAndStartTour = () => {
|
||||
// Check if both required elements exist
|
||||
// Check if all required elements exist
|
||||
const connectorEl = document.querySelector(TOUR_STEPS[0].target);
|
||||
const documentsEl = document.querySelector(TOUR_STEPS[1].target);
|
||||
const inboxEl = document.querySelector(TOUR_STEPS[2].target);
|
||||
|
||||
if (connectorEl && documentsEl) {
|
||||
// Both elements found, start tour
|
||||
if (connectorEl && documentsEl && inboxEl) {
|
||||
// All elements found, start tour
|
||||
setIsActive(true);
|
||||
setTargetEl(connectorEl);
|
||||
setSpotlightTargetEl(connectorEl);
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
"use client";
|
||||
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
|
||||
import { useGlobalLoadingEffect } from "@/hooks/use-global-loading";
|
||||
import { getBearerToken } from "@/lib/auth-utils";
|
||||
import {
|
||||
cleanupElectric,
|
||||
type ElectricClient,
|
||||
|
|
@ -27,6 +30,7 @@ interface ElectricProviderProps {
|
|||
* 5. Provides client via context - hooks should use useElectricClient()
|
||||
*/
|
||||
export function ElectricProvider({ children }: ElectricProviderProps) {
|
||||
const t = useTranslations("common");
|
||||
const [electricClient, setElectricClient] = useState<ElectricClient | null>(null);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const {
|
||||
|
|
@ -105,21 +109,25 @@ export function ElectricProvider({ children }: ElectricProviderProps) {
|
|||
};
|
||||
}, [user?.id, isUserLoaded, electricClient]);
|
||||
|
||||
// Check if user is authenticated first (has bearer token)
|
||||
// This prevents showing loading screen for unauthenticated users on homepage
|
||||
const hasToken = typeof window !== "undefined" && !!getBearerToken();
|
||||
|
||||
// Determine if we should show loading
|
||||
const shouldShowLoading = hasToken && isUserLoaded && !!user?.id && !electricClient && !error;
|
||||
|
||||
// Use global loading hook with ownership tracking - prevents flash during transitions
|
||||
useGlobalLoadingEffect(shouldShowLoading, t("initializing"), "default");
|
||||
|
||||
// For non-authenticated pages (like landing page), render immediately with null context
|
||||
// Also render immediately if user query failed (e.g., token expired)
|
||||
if (!isUserLoaded || !user?.id || isUserError) {
|
||||
if (!hasToken || !isUserLoaded || !user?.id || isUserError) {
|
||||
return <ElectricContext.Provider value={null}>{children}</ElectricContext.Provider>;
|
||||
}
|
||||
|
||||
// Show loading state while initializing for authenticated users
|
||||
// Return children with null context while initializing - the global provider handles the loading UI
|
||||
if (!electricClient && !error) {
|
||||
return (
|
||||
<ElectricContext.Provider value={null}>
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="text-muted-foreground">Initializing...</div>
|
||||
</div>
|
||||
</ElectricContext.Provider>
|
||||
);
|
||||
return <ElectricContext.Provider value={null}>{children}</ElectricContext.Provider>;
|
||||
}
|
||||
|
||||
// If there's an error, still render but warn
|
||||
|
|
|
|||
79
surfsense_web/components/providers/GlobalLoadingProvider.tsx
Normal file
79
surfsense_web/components/providers/GlobalLoadingProvider.tsx
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
"use client";
|
||||
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { AmbientBackground } from "@/app/(home)/login/AmbientBackground";
|
||||
import { globalLoadingAtom } from "@/atoms/ui/loading.atoms";
|
||||
import { Logo } from "@/components/Logo";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* GlobalLoadingProvider renders a persistent loading overlay.
|
||||
* The spinner is ALWAYS in the DOM to prevent animation reset when
|
||||
* loading states change between different pages/components.
|
||||
*
|
||||
* Visibility is controlled via CSS opacity/pointer-events, NOT mounting/unmounting.
|
||||
*/
|
||||
export function GlobalLoadingProvider({ children }: { children: React.ReactNode }) {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const { isLoading, message, variant } = useAtomValue(globalLoadingAtom);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
// The overlay is ALWAYS rendered, but visibility is controlled by CSS
|
||||
// This prevents the spinner animation from resetting
|
||||
const loadingOverlay = (
|
||||
<div
|
||||
className={cn(
|
||||
"fixed inset-0 z-[9999]",
|
||||
isLoading
|
||||
? "opacity-100 pointer-events-auto"
|
||||
: "opacity-0 pointer-events-none transition-opacity duration-150"
|
||||
)}
|
||||
aria-hidden={!isLoading}
|
||||
>
|
||||
{variant === "login" ? (
|
||||
<div className="relative w-full h-full overflow-hidden bg-background">
|
||||
<AmbientBackground />
|
||||
<div className="mx-auto flex h-screen max-w-lg flex-col items-center justify-center">
|
||||
<Logo className="rounded-md" />
|
||||
<div className="mt-8 flex flex-col items-center space-y-4">
|
||||
<div className="h-12 w-12 flex items-center justify-center">
|
||||
{/* Spinner is always mounted, animation never resets */}
|
||||
<Spinner size="lg" className="text-muted-foreground" />
|
||||
</div>
|
||||
<span className="text-muted-foreground text-sm min-h-[1.25rem] text-center max-w-xs">
|
||||
{message}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center bg-background">
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<div className="h-12 w-12 flex items-center justify-center">
|
||||
{/* Spinner is always mounted, animation never resets */}
|
||||
<Spinner size="xl" className="text-primary" />
|
||||
</div>
|
||||
<span className="text-muted-foreground text-sm min-h-[1.25rem] text-center max-w-md px-4">
|
||||
{message}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
// Render inline during SSR/before hydration, use portal after mounting
|
||||
// This prevents the white flash during initial render
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
{mounted ? createPortal(loadingOverlay, document.body) : loadingOverlay}
|
||||
</>
|
||||
);
|
||||
}
|
||||
197
surfsense_web/components/settings/general-settings-manager.tsx
Normal file
197
surfsense_web/components/settings/general-settings-manager.tsx
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { Info, RotateCcw, Save } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { updateSearchSpaceMutationAtom } from "@/atoms/search-spaces/search-space-mutation.atoms";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
|
||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||
|
||||
interface GeneralSettingsManagerProps {
|
||||
searchSpaceId: number;
|
||||
}
|
||||
|
||||
export function GeneralSettingsManager({ searchSpaceId }: GeneralSettingsManagerProps) {
|
||||
const t = useTranslations("searchSpaceSettings");
|
||||
const tCommon = useTranslations("common");
|
||||
const {
|
||||
data: searchSpace,
|
||||
isLoading: loading,
|
||||
refetch: fetchSearchSpace,
|
||||
} = useQuery({
|
||||
queryKey: cacheKeys.searchSpaces.detail(searchSpaceId.toString()),
|
||||
queryFn: () => searchSpacesApiService.getSearchSpace({ id: searchSpaceId }),
|
||||
enabled: !!searchSpaceId,
|
||||
});
|
||||
|
||||
const { mutateAsync: updateSearchSpace } = useAtomValue(updateSearchSpaceMutationAtom);
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
|
||||
// Initialize state from fetched search space
|
||||
useEffect(() => {
|
||||
if (searchSpace) {
|
||||
setName(searchSpace.name || "");
|
||||
setDescription(searchSpace.description || "");
|
||||
setHasChanges(false);
|
||||
}
|
||||
}, [searchSpace]);
|
||||
|
||||
// Track changes
|
||||
useEffect(() => {
|
||||
if (searchSpace) {
|
||||
const currentName = searchSpace.name || "";
|
||||
const currentDescription = searchSpace.description || "";
|
||||
const changed = currentName !== name || currentDescription !== description;
|
||||
setHasChanges(changed);
|
||||
}
|
||||
}, [searchSpace, name, description]);
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
setSaving(true);
|
||||
|
||||
await updateSearchSpace({
|
||||
id: searchSpaceId,
|
||||
data: {
|
||||
name: name.trim(),
|
||||
description: description.trim() || undefined,
|
||||
},
|
||||
});
|
||||
|
||||
setHasChanges(false);
|
||||
await fetchSearchSpace();
|
||||
} catch (error: any) {
|
||||
console.error("Error saving search space details:", error);
|
||||
toast.error(error.message || "Failed to save search space details");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
if (searchSpace) {
|
||||
setName(searchSpace.name || "");
|
||||
setDescription(searchSpace.description || "");
|
||||
setHasChanges(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-4 md:space-y-6">
|
||||
<Card>
|
||||
<CardHeader className="px-3 md:px-6 pt-3 md:pt-6 pb-2 md:pb-3">
|
||||
<Skeleton className="h-5 md:h-6 w-36 md:w-48" />
|
||||
<Skeleton className="h-3 md:h-4 w-full max-w-md mt-2" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 md:space-y-4 px-3 md:px-6 pb-3 md:pb-6">
|
||||
<Skeleton className="h-10 md:h-12 w-full" />
|
||||
<Skeleton className="h-10 md:h-12 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4 md:space-y-6">
|
||||
<Alert className="py-3 md:py-4">
|
||||
<Info className="h-3 w-3 md:h-4 md:w-4 shrink-0" />
|
||||
<AlertDescription className="text-xs md:text-sm">
|
||||
Update your search space name and description. These details help identify and organize
|
||||
your workspace.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{/* Search Space Details Card */}
|
||||
<Card>
|
||||
<CardHeader className="px-3 md:px-6 pt-3 md:pt-6 pb-2 md:pb-3">
|
||||
<CardTitle className="text-base md:text-lg">Search Space Details</CardTitle>
|
||||
<CardDescription className="text-xs md:text-sm">
|
||||
Manage the basic information for this search space.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 md:space-y-5 px-3 md:px-6 pb-3 md:pb-6">
|
||||
<div className="space-y-1.5 md:space-y-2">
|
||||
<Label htmlFor="search-space-name" className="text-sm md:text-base font-medium">
|
||||
{t("general_name_label")}
|
||||
</Label>
|
||||
<Input
|
||||
id="search-space-name"
|
||||
placeholder={t("general_name_placeholder")}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="text-sm md:text-base h-9 md:h-10"
|
||||
/>
|
||||
<p className="text-[10px] md:text-xs text-muted-foreground">
|
||||
{t("general_name_description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 md:space-y-2">
|
||||
<Label htmlFor="search-space-description" className="text-sm md:text-base font-medium">
|
||||
{t("general_description_label")}{" "}
|
||||
<span className="text-muted-foreground font-normal">({tCommon("optional")})</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="search-space-description"
|
||||
placeholder={t("general_description_placeholder")}
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
className="text-sm md:text-base h-9 md:h-10"
|
||||
/>
|
||||
<p className="text-[10px] md:text-xs text-muted-foreground">
|
||||
{t("general_description_description")}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex items-center justify-between pt-3 md:pt-4 gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleReset}
|
||||
disabled={!hasChanges || saving}
|
||||
className="flex items-center gap-2 text-xs md:text-sm h-9 md:h-10"
|
||||
>
|
||||
<RotateCcw className="h-3.5 w-3.5 md:h-4 md:w-4" />
|
||||
{t("general_reset")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={!hasChanges || saving || !name.trim()}
|
||||
className="flex items-center gap-2 text-xs md:text-sm h-9 md:h-10"
|
||||
>
|
||||
<Save className="h-3.5 w-3.5 md:h-4 md:w-4" />
|
||||
{saving ? t("general_saving") : t("general_save")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{hasChanges && (
|
||||
<Alert
|
||||
variant="default"
|
||||
className="bg-blue-50 dark:bg-blue-950/20 border-blue-200 dark:border-blue-800 py-3 md:py-4"
|
||||
>
|
||||
<Info className="h-3 w-3 md:h-4 md:w-4 text-blue-600 dark:text-blue-500 shrink-0" />
|
||||
<AlertDescription className="text-blue-800 dark:text-blue-300 text-xs md:text-sm">
|
||||
{t("general_unsaved_changes")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,16 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { useAtomValue } from "jotai";
|
||||
import {
|
||||
AlertCircle,
|
||||
Bot,
|
||||
CheckCircle,
|
||||
FileText,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
RotateCcw,
|
||||
Save,
|
||||
} from "lucide-react";
|
||||
import { AlertCircle, Bot, CheckCircle, FileText, RefreshCw, RotateCcw, Save } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
|
@ -32,6 +23,7 @@ import {
|
|||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
|
||||
const ROLE_DESCRIPTIONS = {
|
||||
agent: {
|
||||
|
|
@ -206,7 +198,7 @@ export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) {
|
|||
<Card>
|
||||
<CardContent className="flex items-center justify-center py-8 md:py-12">
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Loader2 className="w-4 h-4 md:w-5 md:h-5 animate-spin" />
|
||||
<Spinner size="sm" className="md:h-5 md:w-5" />
|
||||
<span className="text-xs md:text-sm">
|
||||
{configsLoading && preferencesLoading
|
||||
? "Loading configurations and preferences..."
|
||||
|
|
@ -398,7 +390,7 @@ export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) {
|
|||
className="flex items-center gap-2 text-xs md:text-sm h-9 md:h-10"
|
||||
>
|
||||
<Save className="w-3.5 h-3.5 md:w-4 md:h-4" />
|
||||
{isSaving ? "Saving..." : "Save Changes"}
|
||||
{isSaving ? "Saving" : "Save Changes"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ import {
|
|||
Clock,
|
||||
Edit3,
|
||||
FileText,
|
||||
Loader2,
|
||||
MessageSquareQuote,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
|
|
@ -17,7 +16,6 @@ import {
|
|||
} from "lucide-react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
createNewLLMConfigMutationAtom,
|
||||
deleteNewLLMConfigMutationAtom,
|
||||
|
|
@ -49,8 +47,8 @@ import {
|
|||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { LLM_PROVIDERS } from "@/contracts/enums/llm-providers";
|
||||
import type { NewLLMConfig } from "@/contracts/types/new-llm-config.types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
|
|
@ -112,12 +110,10 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
|
|||
async (formData: LLMConfigFormData) => {
|
||||
try {
|
||||
if (editingConfig) {
|
||||
const { search_space_id, ...updateData } = formData;
|
||||
await updateConfig({
|
||||
id: editingConfig.id,
|
||||
data: {
|
||||
...formData,
|
||||
search_space_id: undefined, // Can't change search_space_id
|
||||
},
|
||||
data: updateData,
|
||||
});
|
||||
} else {
|
||||
await createConfig(formData);
|
||||
|
|
@ -156,9 +152,6 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
|
|||
setEditingConfig(null);
|
||||
};
|
||||
|
||||
const getProviderInfo = (providerValue: string) =>
|
||||
LLM_PROVIDERS.find((p) => p.value === providerValue);
|
||||
|
||||
return (
|
||||
<div className="space-y-4 md:space-y-6">
|
||||
{/* Header */}
|
||||
|
|
@ -180,9 +173,9 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
|
|||
{/* Error Alerts */}
|
||||
<AnimatePresence>
|
||||
{errors.length > 0 &&
|
||||
errors.map((err, i) => (
|
||||
errors.map((err) => (
|
||||
<motion.div
|
||||
key={`error-${i}`}
|
||||
key={err?.message ?? `error-${Date.now()}-${Math.random()}`}
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
|
|
@ -218,7 +211,7 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
|
|||
<Card>
|
||||
<CardContent className="flex items-center justify-center py-10 md:py-16">
|
||||
<div className="flex flex-col items-center gap-2 md:gap-3">
|
||||
<Loader2 className="h-6 w-6 md:h-8 md:w-8 animate-spin text-muted-foreground" />
|
||||
<Spinner size="md" className="md:h-8 md:w-8 text-muted-foreground" />
|
||||
<span className="text-xs md:text-sm text-muted-foreground">
|
||||
Loading configurations...
|
||||
</span>
|
||||
|
|
@ -269,7 +262,6 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
|
|||
<motion.div variants={container} initial="hidden" animate="show" className="grid gap-4">
|
||||
<AnimatePresence mode="popLayout">
|
||||
{configs?.map((config) => {
|
||||
const providerInfo = getProviderInfo(config.provider);
|
||||
return (
|
||||
<motion.div
|
||||
key={config.id}
|
||||
|
|
@ -492,8 +484,8 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
|
|||
>
|
||||
{isDeleting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Deleting...
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
Deleting
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -200,7 +200,7 @@ export function PromptConfigManager({ searchSpaceId }: PromptConfigManagerProps)
|
|||
className="flex items-center gap-2 text-xs md:text-sm h-9 md:h-10"
|
||||
>
|
||||
<Save className="h-3.5 w-3.5 md:h-4 md:w-4" />
|
||||
{saving ? "Saving..." : "Save Instructions"}
|
||||
{saving ? "Saving" : "Save Instructions"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import {
|
|||
ChevronDown,
|
||||
ChevronsUpDown,
|
||||
Key,
|
||||
Loader2,
|
||||
MessageSquareQuote,
|
||||
Rocket,
|
||||
Sparkles,
|
||||
|
|
@ -48,6 +47,7 @@ import {
|
|||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { getModelsByProvider } from "@/contracts/enums/llm-models";
|
||||
|
|
@ -592,7 +592,7 @@ export function LLMConfigForm({
|
|||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="h-3.5 w-3.5 sm:h-4 sm:w-4 animate-spin" />
|
||||
<Spinner size="sm" />
|
||||
{mode === "edit" ? "Updating..." : "Creating"}
|
||||
</>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { useAtom } from "jotai";
|
||||
import { CheckCircle2, FileType, Info, Loader2, Tag, Upload, X } from "lucide-react";
|
||||
import { CheckCircle2, FileType, Info, Tag, Upload, X } from "lucide-react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
|
|
@ -20,6 +20,7 @@ import { Button } from "@/components/ui/button";
|
|||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import {
|
||||
trackDocumentUploadFailure,
|
||||
trackDocumentUploadStarted,
|
||||
|
|
@ -424,7 +425,7 @@ export function DocumentUploadTab({
|
|||
>
|
||||
{isUploading ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<Loader2 className="h-4 w-4 sm:h-5 sm:w-5 animate-spin" />
|
||||
<Spinner size="sm" />
|
||||
{t("uploading")}
|
||||
</span>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
"use client";
|
||||
|
||||
import { makeAssistantToolUI } from "@assistant-ui/react";
|
||||
import { AlertCircleIcon, Loader2Icon, MicIcon } from "lucide-react";
|
||||
import { AlertCircleIcon, MicIcon } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { z } from "zod";
|
||||
import { Audio } from "@/components/tool-ui/audio";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { baseApiService } from "@/lib/apis/base-api.service";
|
||||
import { authenticatedFetch } from "@/lib/auth-utils";
|
||||
import { clearActivePodcastTaskId, setActivePodcastTaskId } from "@/lib/chat/podcast-state";
|
||||
|
|
@ -97,8 +98,8 @@ function PodcastGeneratingState({ title }: { title: string }) {
|
|||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-foreground text-lg">{title}</h3>
|
||||
<div className="mt-2 flex items-center gap-2 text-muted-foreground">
|
||||
<Loader2Icon className="size-4 animate-spin" />
|
||||
<span className="text-sm">Generating podcast. This may take a few minutes</span>
|
||||
<Spinner size="sm" />
|
||||
<span className="text-sm">Generating podcast. This may take a few minutes.</span>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<div className="h-1.5 w-full overflow-hidden rounded-full bg-primary/10">
|
||||
|
|
@ -144,7 +145,7 @@ function AudioLoadingState({ title }: { title: string }) {
|
|||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-foreground">{title}</h3>
|
||||
<div className="mt-2 flex items-center gap-2 text-muted-foreground">
|
||||
<Loader2Icon className="size-4 animate-spin" />
|
||||
<Spinner size="sm" />
|
||||
<span className="text-sm">Loading audio...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
"use client";
|
||||
|
||||
import { ExternalLinkIcon, ImageIcon, Loader2 } from "lucide-react";
|
||||
import { ExternalLinkIcon, ImageIcon } from "lucide-react";
|
||||
import NextImage from "next/image";
|
||||
import { Component, type ReactNode, useState } from "react";
|
||||
import { z } from "zod";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
|
|
@ -184,7 +185,7 @@ export function ImageLoading({ title = "Loading image..." }: { title?: string })
|
|||
<Card className="w-full max-w-md overflow-hidden">
|
||||
<div className="aspect-[4/3] bg-muted flex items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<Loader2 className="size-8 text-muted-foreground animate-spin" />
|
||||
<Spinner size="lg" className="text-muted-foreground" />
|
||||
<p className="text-muted-foreground text-sm">{title}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
"use client";
|
||||
|
||||
import { ExternalLinkIcon, Globe, ImageIcon, LinkIcon, Loader2 } from "lucide-react";
|
||||
import { ExternalLinkIcon, Globe, ImageIcon, LinkIcon } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import { Component, type ReactNode } from "react";
|
||||
import { z } from "zod";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
|
|
@ -299,18 +300,17 @@ export function MediaCard({
|
|||
|
||||
{/* Response Actions */}
|
||||
{responseActions && responseActions.length > 0 && (
|
||||
<div
|
||||
className="mt-4 flex items-center justify-end gap-2 border-t pt-3"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="mt-4 flex items-center justify-end gap-2 border-t pt-3">
|
||||
{responseActions.map((action) => (
|
||||
<Tooltip key={action.id}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant={action.variant || "secondary"}
|
||||
size="sm"
|
||||
onClick={() => onResponseAction?.(action.id)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onResponseAction?.(action.id);
|
||||
}}
|
||||
>
|
||||
{action.label}
|
||||
</Button>
|
||||
|
|
@ -337,7 +337,7 @@ export function MediaCardLoading({ title = "Loading preview..." }: { title?: str
|
|||
return (
|
||||
<Card className="w-full max-w-md overflow-hidden">
|
||||
<div className="aspect-[2/1] bg-muted animate-pulse flex items-center justify-center">
|
||||
<Loader2 className="size-8 text-muted-foreground animate-spin" />
|
||||
<Spinner size="lg" className="text-muted-foreground" />
|
||||
</div>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
|
||||
import { makeAssistantToolUI, useAssistantState } from "@assistant-ui/react";
|
||||
import { useAtomValue, useSetAtom } from "jotai";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
|
|
@ -11,6 +10,7 @@ import {
|
|||
registerPlanOwner,
|
||||
updatePlanStateAtom,
|
||||
} from "@/atoms/chat/plan-state.atom";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Plan, PlanErrorBoundary, parseSerializablePlan, TodoStatusSchema } from "./plan";
|
||||
|
||||
// ============================================================================
|
||||
|
|
@ -46,7 +46,7 @@ function WriteTodosLoading() {
|
|||
return (
|
||||
<div className="my-4 w-full max-w-xl rounded-2xl border bg-card/60 px-5 py-4 shadow-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<Loader2 className="size-5 animate-spin text-primary" />
|
||||
<Spinner size="md" className="text-primary" />
|
||||
<span className="text-sm text-muted-foreground">Creating plan...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ const DialogContent = React.forwardRef<
|
|||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] rounded-lg",
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] rounded-lg focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
|
|
|||
|
|
@ -7,12 +7,14 @@ import { cn } from "@/lib/utils";
|
|||
|
||||
function TooltipProvider({
|
||||
delayDuration = 0,
|
||||
disableHoverableContent = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
||||
return (
|
||||
<TooltipPrimitive.Provider
|
||||
data-slot="tooltip-provider"
|
||||
delayDuration={delayDuration}
|
||||
disableHoverableContent={disableHoverableContent}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
|
@ -42,7 +44,7 @@ function TooltipContent({
|
|||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-black text-white font-medium shadow-xl px-3 py-1.5 dark:bg-zinc-800 dark:text-zinc-50 border-none animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit rounded-md text-xs text-balance",
|
||||
"bg-black text-white font-medium shadow-xl px-3 py-1.5 dark:bg-zinc-800 dark:text-zinc-50 border-none animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit rounded-md text-xs text-balance pointer-events-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
|
|
|||
|
|
@ -36,20 +36,20 @@ After registration, configure the required scopes (permissions) for your integra
|
|||
|
||||
| Scope | Description |
|
||||
|-------|-------------|
|
||||
| ✅ `data.recordComments:read` | See comments in records |
|
||||
| ✅ `data.records:read` | See the data in records |
|
||||
| `data.recordComments:read` | See comments in records |
|
||||
| `data.records:read` | See the data in records |
|
||||
|
||||
### Base schema
|
||||
|
||||
| Scope | Description |
|
||||
|-------|-------------|
|
||||
| ✅ `schema.bases:read` | See the structure of a base, like table names or field types |
|
||||
| `schema.bases:read` | See the structure of a base, like table names or field types |
|
||||
|
||||
### User metadata
|
||||
|
||||
| Scope | Description |
|
||||
|-------|-------------|
|
||||
| ✅ `user.email:read` | See the user's email address |
|
||||
| `user.email:read` | See the user's email address |
|
||||
|
||||

|
||||
|
||||
|
|
@ -80,7 +80,9 @@ After saving, you'll find your OAuth credentials on the integration page:
|
|||
1. Copy your **Client ID**
|
||||
2. Copy your **Client Secret**
|
||||
|
||||
> ⚠️ Never share your client secret publicly.
|
||||
<Callout type="warn">
|
||||
Never share your client secret publicly.
|
||||
</Callout>
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -3,4 +3,70 @@ title: Bookstack
|
|||
description: Connect your Bookstack instance to SurfSense
|
||||
---
|
||||
|
||||
# Documentation in progress
|
||||
# BookStack Integration Setup Guide
|
||||
|
||||
This guide walks you through connecting your BookStack instance to SurfSense.
|
||||
|
||||
## How it works
|
||||
|
||||
The BookStack connector uses the BookStack REST API to fetch all pages from your BookStack instance that your account has access to.
|
||||
|
||||
- For follow up indexing runs, the connector retrieves pages that have been updated since the last indexing attempt.
|
||||
- Indexing should be configured to run periodically, so updates should appear in your search results within minutes.
|
||||
|
||||
---
|
||||
|
||||
## Authorization
|
||||
|
||||
<Callout type="info" title="API Token Required">
|
||||
You need to create an API token from your BookStack instance. The token requires "Access System API" permission.
|
||||
</Callout>
|
||||
|
||||
### Step 1: Create an API Token
|
||||
|
||||
1. Log in to your BookStack instance
|
||||
2. Click on your profile icon → Edit Profile
|
||||
3. Navigate to the "API Tokens" tab
|
||||
4. Click "Create Token" and give it a name
|
||||
5. Copy both the **Token ID** and **Token Secret**
|
||||
6. Paste them in the connector form
|
||||
|
||||
### Step 2: Grant necessary access
|
||||
|
||||
Your user account must have "Access System API" permission. The connector will only index content your account can view.
|
||||
|
||||
<Callout type="info" title="Rate Limiting">
|
||||
BookStack API has a rate limit of 180 requests per minute. The connector automatically handles rate limiting to ensure reliable indexing.
|
||||
</Callout>
|
||||
|
||||
<Callout type="info" title="Periodic Sync">
|
||||
Enable periodic sync to automatically re-index pages when content changes. Available frequencies: Every 5 minutes, 15 minutes, hourly, every 6 hours, daily, or weekly.
|
||||
</Callout>
|
||||
|
||||
---
|
||||
|
||||
## Connecting to SurfSense
|
||||
|
||||
1. Navigate to the Connector Dashboard and select the **BookStack** Connector.
|
||||
2. Fill in the required fields:
|
||||
|
||||
| Field | Description | Example |
|
||||
|-------|-------------|---------|
|
||||
| **Connector Name** | A friendly name to identify this connector | `My BookStack Connector` |
|
||||
| **BookStack Base URL** | The full URL of your BookStack instance | `https://docs.example.com` |
|
||||
| **Token ID** | Your API Token ID from BookStack | |
|
||||
| **Token Secret** | Your API Token Secret from BookStack | |
|
||||
|
||||
3. Click **Connect** to establish the connection.
|
||||
4. Once connected, your BookStack pages will be indexed automatically.
|
||||
|
||||
### What Gets Indexed
|
||||
|
||||
The BookStack connector indexes the following data:
|
||||
|
||||
| Data Type | Description |
|
||||
|-----------|-------------|
|
||||
| Pages | All pages from your BookStack instance |
|
||||
| Content | Page content in Markdown format |
|
||||
| Metadata | Page titles and metadata |
|
||||
| Structure | Book and chapter hierarchy information |
|
||||
|
|
|
|||
|
|
@ -1,8 +1,133 @@
|
|||
---
|
||||
title: Circleback
|
||||
description: Connect your circleback to SurfSense
|
||||
description: Connect your Circleback meetings to SurfSense
|
||||
---
|
||||
|
||||
# Documentation in progress
|
||||
# Circleback Integration Setup Guide
|
||||
|
||||
This guide walks you through connecting your Circleback meetings to SurfSense for meeting search and AI-powered insights.
|
||||
|
||||
## How it works
|
||||
|
||||
The Circleback connector uses a **webhook-based integration**. Unlike other connectors that pull data from APIs, Circleback automatically pushes meeting data to SurfSense when meetings are processed.
|
||||
|
||||
- Meetings are sent to SurfSense immediately after Circleback processes them
|
||||
- No API keys or credentials required
|
||||
- No periodic indexing needed - data arrives in real-time
|
||||
- Each meeting is stored with its notes, transcript, action items, and insights
|
||||
|
||||
---
|
||||
|
||||
## What Gets Indexed
|
||||
|
||||
| Content Type | Description |
|
||||
|--------------|-------------|
|
||||
| Meeting Notes | AI-generated meeting notes in Markdown format |
|
||||
| Transcript | Full meeting transcript with speaker identification and timestamps |
|
||||
| Action Items | Tasks with assignees and status |
|
||||
| Attendees | Names and emails of meeting participants |
|
||||
| Insights | Custom insights extracted from the meeting |
|
||||
| Metadata | Date, duration, tags, meeting URL, and Circleback link |
|
||||
|
||||
<Callout type="info">
|
||||
Meeting recordings are not stored in SurfSense, but a link to the recording (valid for 24 hours from Circleback) is included in the indexed document.
|
||||
</Callout>
|
||||
|
||||
---
|
||||
|
||||
## Setup Guide
|
||||
|
||||
### Step 1: Create the Connector in SurfSense
|
||||
|
||||
1. Navigate to **Connectors** → **Add Connector** → **Circleback**
|
||||
2. Enter a connector name (e.g., `My Circleback Meetings`)
|
||||
3. Click **Connect** to create the connector
|
||||
|
||||
<Callout type="info" title="No Credentials Needed">
|
||||
Circleback uses webhooks, so no API key or authentication is required. The webhook URL is unique to your search space.
|
||||
</Callout>
|
||||
|
||||
### Step 2: Copy Your Webhook URL
|
||||
|
||||
After creating the connector:
|
||||
|
||||
1. Open the connector settings
|
||||
2. Find the **Webhook URL** field
|
||||
3. Click **Copy** to copy the URL to your clipboard
|
||||
|
||||
The webhook URL looks like:
|
||||
```
|
||||
https://your-surfsense-url/api/v1/webhooks/circleback/{search_space_id}
|
||||
```
|
||||
|
||||
### Step 3: Configure Circleback Automation
|
||||
|
||||
1. Log into your [Circleback account](https://app.circleback.ai)
|
||||
2. Go to **Automations** (in the main navigation)
|
||||
3. Click **Create automation**
|
||||
4. Add conditions to filter which meetings trigger the webhook (optional):
|
||||
- Specific calendar(s)
|
||||
- Meeting tags
|
||||
- Meeting participants
|
||||
5. Select **Send webhook request** as the action
|
||||
6. Enter your SurfSense webhook URL
|
||||
7. Select the meeting outcomes to include (notes, action items, transcript, insights)
|
||||
8. Optionally test with your most recent meeting
|
||||
9. Name and save the automation
|
||||
|
||||
For detailed instructions, see [Circleback's official webhook documentation](https://circleback.ai/docs/webhook-integration).
|
||||
|
||||
<Callout type="warn">
|
||||
Make sure your SurfSense backend is accessible from the internet for Circleback to send webhooks. If self-hosting, you may need to configure a reverse proxy or use a tunneling service.
|
||||
</Callout>
|
||||
|
||||
---
|
||||
|
||||
## Connector Configuration
|
||||
|
||||
| Field | Description | Required |
|
||||
|-------|-------------|----------|
|
||||
| **Connector Name** | A friendly name to identify this connector | Yes |
|
||||
|
||||
<Callout type="info">
|
||||
Unlike other connectors, Circleback doesn't support periodic indexing since it's webhook-based. Meetings are automatically pushed to SurfSense when processed by Circleback.
|
||||
</Callout>
|
||||
|
||||
---
|
||||
|
||||
## Verifying the Integration
|
||||
|
||||
Once configured, new meetings will automatically appear in SurfSense after Circleback processes them. To verify:
|
||||
|
||||
1. Attend or process a meeting with Circleback
|
||||
2. Wait for Circleback to complete processing (usually a few minutes after the meeting ends)
|
||||
3. Check your SurfSense search space for the new meeting document
|
||||
|
||||
Each meeting document includes:
|
||||
- A direct link to view the meeting on Circleback
|
||||
- Full searchable transcript
|
||||
- Extracted action items with assignees
|
||||
- Meeting metadata and insights
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Meetings not appearing**
|
||||
- Verify the webhook URL is correctly configured in Circleback
|
||||
- Check that your SurfSense backend is accessible from the internet
|
||||
- Look for errors in your SurfSense backend logs
|
||||
|
||||
**Webhook URL not showing**
|
||||
- Refresh the connector configuration page
|
||||
- Ensure the connector was created successfully
|
||||
- Check that your backend is running
|
||||
|
||||
**Duplicate meetings**
|
||||
- Each meeting is uniquely identified by its Circleback meeting ID
|
||||
- If content changes, the existing document is updated rather than duplicated
|
||||
|
||||
**Delayed indexing**
|
||||
- Meeting data is processed asynchronously
|
||||
- Documents should appear within a minute of receiving the webhook
|
||||
- Check the task queue if experiencing longer delays
|
||||
|
|
|
|||
|
|
@ -36,7 +36,9 @@ After creating the app, you'll see your credentials:
|
|||
1. Copy your **Client ID**
|
||||
2. Copy your **Client Secret** (click "Show" to reveal, or "Regenerate" if needed)
|
||||
|
||||
> ⚠️ Never share your client secret publicly.
|
||||
<Callout type="warn">
|
||||
Never share your client secret publicly.
|
||||
</Callout>
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -28,7 +28,9 @@ This guide walks you through setting up an Atlassian OAuth 2.0 (3LO) integration
|
|||
2. Check the box to agree to Atlassian's developer terms
|
||||
3. Click **"Create"**
|
||||
|
||||
> ℹ️ New OAuth 2.0 integrations use rotating refresh tokens, which improve security by limiting token validity and enabling automatic detection of token reuse.
|
||||
<Callout type="info">
|
||||
New OAuth 2.0 integrations use rotating refresh tokens, which improve security by limiting token validity and enabling automatic detection of token reuse.
|
||||
</Callout>
|
||||
|
||||

|
||||
|
||||
|
|
@ -41,7 +43,9 @@ This guide walks you through setting up an Atlassian OAuth 2.0 (3LO) integration
|
|||
```
|
||||
3. Click **"Save changes"**
|
||||
|
||||
> ℹ️ You can enter up to 10 redirect URIs, one per line.
|
||||
<Callout type="info">
|
||||
You can enter up to 10 redirect URIs, one per line.
|
||||
</Callout>
|
||||
|
||||

|
||||
|
||||
|
|
@ -62,7 +66,7 @@ Select the **"Classic scopes"** tab and enable:
|
|||
|
||||
| Scope Name | Code | Description |
|
||||
|------------|------|-------------|
|
||||
| ✅ Read user | `read:confluence-user` | View user information in Confluence that you have access to, including usernames, email addresses and profile pictures |
|
||||
| Read user | `read:confluence-user` | View user information in Confluence that you have access to, including usernames, email addresses and profile pictures |
|
||||
|
||||

|
||||
|
||||
|
|
@ -72,9 +76,9 @@ Select the **"Granular scopes"** tab and enable:
|
|||
|
||||
| Scope Name | Code | Description |
|
||||
|------------|------|-------------|
|
||||
| ✅ View pages | `read:page:confluence` | View page content |
|
||||
| ✅ View comments | `read:comment:confluence` | View comments on pages or blogposts |
|
||||
| ✅ View spaces | `read:space:confluence` | View space details |
|
||||
| View pages | `read:page:confluence` | View page content |
|
||||
| View comments | `read:comment:confluence` | View comments on pages or blogposts |
|
||||
| View spaces | `read:space:confluence` | View space details |
|
||||
|
||||
4. Click **"Save"**
|
||||
|
||||
|
|
@ -85,7 +89,9 @@ Select the **"Granular scopes"** tab and enable:
|
|||
1. In the left sidebar, click **"Settings"**
|
||||
2. Copy your **Client ID** and **Client Secret**
|
||||
|
||||
> ⚠️ Never share your client secret publicly.
|
||||
<Callout type="warn">
|
||||
Never share your client secret publicly.
|
||||
</Callout>
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -38,7 +38,9 @@ You'll also see your **Application ID** and **Public Key** on this page.
|
|||
http://localhost:8000/api/v1/auth/discord/connector/callback
|
||||
```
|
||||
|
||||
> ⚠️ Keep **Public Client** disabled (off) since SurfSense uses a server to make requests.
|
||||
<Callout type="warn">
|
||||
Keep **Public Client** disabled (off) since SurfSense uses a server to make requests.
|
||||
</Callout>
|
||||
|
||||

|
||||
|
||||
|
|
@ -46,13 +48,15 @@ You'll also see your **Application ID** and **Public Key** on this page.
|
|||
|
||||
1. In the left sidebar, click **"Bot"**
|
||||
2. Configure the **Authorization Flow**:
|
||||
- ✅ **Public Bot** - Enable to allow anyone to add the bot to servers
|
||||
- **Public Bot** - Enable to allow anyone to add the bot to servers
|
||||
|
||||
3. Enable **Privileged Gateway Intents**:
|
||||
- ✅ **Server Members Intent** - Required to receive GUILD_MEMBERS events
|
||||
- ✅ **Message Content Intent** - Required to receive message content
|
||||
- **Server Members Intent** - Required to receive GUILD_MEMBERS events
|
||||
- **Message Content Intent** - Required to receive message content
|
||||
|
||||
> ⚠️ Once your bot reaches 100+ servers, these intents will require verification and approval.
|
||||
<Callout type="warn">
|
||||
Once your bot reaches 100+ servers, these intents will require verification and approval.
|
||||
</Callout>
|
||||
|
||||

|
||||
|
||||
|
|
|
|||
|
|
@ -3,4 +3,115 @@ title: Elasticsearch
|
|||
description: Connect your Elasticsearch cluster to SurfSense
|
||||
---
|
||||
|
||||
# Documentation in progress
|
||||
# Elasticsearch Integration Setup Guide
|
||||
|
||||
This guide walks you through connecting your Elasticsearch cluster to SurfSense.
|
||||
|
||||
## How it works
|
||||
|
||||
The Elasticsearch connector allows you to search and retrieve documents from your Elasticsearch cluster. Configure connection details, select specific indices, and set search parameters to make your existing data searchable within SurfSense.
|
||||
|
||||
- For follow-up indexing runs, the connector retrieves documents that have been updated since the last indexing attempt.
|
||||
- Indexing should be configured to run periodically, so updates should appear in your search results within minutes.
|
||||
|
||||
---
|
||||
|
||||
## Authorization
|
||||
|
||||
<Callout type="info" title="Authentication Required">
|
||||
Elasticsearch requires authentication. You can use either an API key or username/password authentication.
|
||||
</Callout>
|
||||
|
||||
### Step 1: Get Your Elasticsearch Endpoint
|
||||
|
||||
You'll need the endpoint URL for your Elasticsearch cluster. This typically looks like:
|
||||
|
||||
- **Cloud:** `https://your-cluster.es.region.aws.com:443`
|
||||
- **Self-hosted:** `https://elasticsearch.example.com:9200`
|
||||
|
||||
### Step 2: Configure Authentication
|
||||
|
||||
Elasticsearch requires authentication. You can use either:
|
||||
|
||||
**API Key:** A base64-encoded API key. You can create one in Elasticsearch by running:
|
||||
|
||||
```bash
|
||||
POST /_security/api_key
|
||||
```
|
||||
|
||||
**Username & Password:** Basic authentication using your Elasticsearch username and password.
|
||||
|
||||
### Step 3: Select Indices
|
||||
|
||||
Specify which indices to search. You can:
|
||||
|
||||
- Use wildcards: `logs-*` to match multiple indices
|
||||
- List specific indices: `logs-2024, documents-2024`
|
||||
- Leave empty to search all accessible indices (not recommended for performance)
|
||||
|
||||
<Callout type="info" title="Periodic Sync">
|
||||
Enable periodic sync to automatically re-index documents when content changes. Available frequencies: Every 5 minutes, 15 minutes, hourly, every 6 hours, daily, or weekly.
|
||||
</Callout>
|
||||
|
||||
---
|
||||
|
||||
## Connecting to SurfSense
|
||||
|
||||
1. Navigate to the Connector Dashboard and select the **Elasticsearch** Connector.
|
||||
2. Fill in the required fields:
|
||||
|
||||
| Field | Description | Example |
|
||||
|-------|-------------|---------|
|
||||
| **Connector Name** | A friendly name to identify this connector | `My Elasticsearch Connector` |
|
||||
| **Elasticsearch Endpoint URL** | The full URL of your Elasticsearch cluster | `https://your-cluster.es.region.aws.com:443` |
|
||||
| **API Key** | Your base64-encoded API key (if using API key auth) | |
|
||||
| **Username** | Your Elasticsearch username (if using basic auth) | |
|
||||
| **Password** | Your Elasticsearch password (if using basic auth) | |
|
||||
| **Indices** | Comma-separated list of indices to search | `logs-*, documents-2024` |
|
||||
|
||||
3. Click **Connect** to establish the connection.
|
||||
4. Once connected, your Elasticsearch documents will be indexed automatically.
|
||||
|
||||
---
|
||||
|
||||
## Advanced Configuration
|
||||
|
||||
### Search Query
|
||||
|
||||
The default query used for searches. Use `*` to match all documents, or specify a more complex Elasticsearch query.
|
||||
|
||||
### Search Fields
|
||||
|
||||
Limit searches to specific fields for better performance. Common fields include:
|
||||
|
||||
- `title` - Document titles
|
||||
- `content` - Main content
|
||||
- `description` - Descriptions
|
||||
|
||||
Leave empty to search all fields in your documents.
|
||||
|
||||
### Maximum Documents
|
||||
|
||||
Set a limit on the number of documents retrieved per search (1-10,000). This helps control response times and resource usage. Leave empty to use Elasticsearch's default limit.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Connection Issues
|
||||
|
||||
- **Invalid URL:** Ensure your endpoint URL includes the protocol (https://) and port number if required.
|
||||
- **SSL/TLS Errors:** Verify that your cluster uses HTTPS and the certificate is valid. Self-signed certificates may require additional configuration.
|
||||
- **Connection Timeout:** Check your network connectivity and firewall settings. Ensure the Elasticsearch cluster is accessible from SurfSense servers.
|
||||
|
||||
### Authentication Issues
|
||||
|
||||
- **Invalid Credentials:** Double-check your username/password or API key. API keys must be base64-encoded.
|
||||
- **Permission Denied:** Ensure your API key or user account has read permissions for the indices you want to search.
|
||||
- **API Key Format:** Elasticsearch API keys are typically base64-encoded strings. Make sure you're using the full key value.
|
||||
|
||||
### Search Issues
|
||||
|
||||
- **No Results:** Verify that your index selection matches existing indices. Use wildcards carefully.
|
||||
- **Slow Searches:** Limit the number of indices or use specific index names instead of wildcards. Reduce the maximum documents limit.
|
||||
- **Field Not Found:** Ensure the search fields you specify actually exist in your Elasticsearch documents.
|
||||
|
|
|
|||
|
|
@ -3,9 +3,18 @@ title: GitHub
|
|||
description: Connect your GitHub repositories to SurfSense
|
||||
---
|
||||
|
||||
# GitHub Connector
|
||||
# GitHub Integration Setup Guide
|
||||
|
||||
Connect your GitHub repositories to SurfSense for code search and AI-powered insights. The connector uses [gitingest](https://gitingest.com) to efficiently index entire codebases.
|
||||
This guide walks you through connecting your GitHub repositories to SurfSense for code search and AI-powered insights.
|
||||
|
||||
## How it works
|
||||
|
||||
The GitHub connector uses [gitingest](https://gitingest.com) to fetch and process repository contents from GitHub.
|
||||
|
||||
- For follow-up indexing runs, the connector retrieves the latest repository state and updates changed files.
|
||||
- Indexing should be configured to run periodically, so updates should appear in your search results within minutes.
|
||||
|
||||
---
|
||||
|
||||
## What Gets Indexed
|
||||
|
||||
|
|
@ -15,7 +24,9 @@ Connect your GitHub repositories to SurfSense for code search and AI-powered ins
|
|||
| Documentation | README files, Markdown documents, text files |
|
||||
| Configuration | JSON, YAML, TOML, .env examples, Dockerfiles |
|
||||
|
||||
> ⚠️ Binary files and files larger than 5MB are automatically excluded.
|
||||
<Callout type="warn">
|
||||
Binary files and files larger than 5MB are automatically excluded.
|
||||
</Callout>
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -39,7 +50,13 @@ For private repos, you need a GitHub Personal Access Token (PAT).
|
|||
2. Set an expiration
|
||||
3. Click **Generate token** and copy it
|
||||
|
||||
> ⚠️ The token starts with `ghp_`. Store it securely.
|
||||
<Callout type="warn">
|
||||
The token starts with `ghp_`. Store it securely.
|
||||
</Callout>
|
||||
|
||||
<Callout type="info" title="Periodic Sync">
|
||||
Enable periodic sync to automatically re-index repositories when content changes. Available frequencies: Every 5 minutes, 15 minutes, hourly, every 6 hours, daily, or weekly.
|
||||
</Callout>
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -53,21 +70,6 @@ For private repos, you need a GitHub Personal Access Token (PAT).
|
|||
|
||||
---
|
||||
|
||||
## Periodic Sync
|
||||
|
||||
Enable periodic sync to automatically re-index repositories when content changes:
|
||||
|
||||
| Frequency | Use Case |
|
||||
|-----------|----------|
|
||||
| Every 5 minutes | Active development |
|
||||
| Every 15 minutes | Frequent commits |
|
||||
| Every hour | Regular workflow |
|
||||
| Every 6 hours | Less active repos |
|
||||
| Daily | Reference repositories |
|
||||
| Weekly | Stable codebases |
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Repository not found**
|
||||
|
|
|
|||
|
|
@ -60,7 +60,9 @@ This guide walks you through setting up a Google OAuth 2.0 integration for SurfS
|
|||
1. After creating the OAuth client, you'll see a dialog with your credentials
|
||||
2. Copy your **Client ID** and **Client Secret**
|
||||
|
||||
> ⚠️ Never share your client secret publicly.
|
||||
<Callout type="warn">
|
||||
Never share your client secret publicly.
|
||||
</Callout>
|
||||
|
||||

|
||||
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue