mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-15 18:25:18 +02:00
Merge remote-tracking branch 'upstream/main' into feat/bookstack-connector
This commit is contained in:
commit
e238fab638
110 changed files with 10076 additions and 1671 deletions
40
surfsense_web/app/api/convert-to-blocknote/route.ts
Normal file
40
surfsense_web/app/api/convert-to-blocknote/route.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { ServerBlockNoteEditor } from "@blocknote/server-util";
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { markdown } = await request.json();
|
||||
|
||||
if (!markdown || typeof markdown !== "string") {
|
||||
return NextResponse.json({ error: "Markdown string is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Log raw markdown input before conversion
|
||||
// console.log(`\n${"=".repeat(80)}`);
|
||||
// console.log("RAW MARKDOWN INPUT (BEFORE CONVERSION):");
|
||||
// console.log("=".repeat(80));
|
||||
// console.log(markdown);
|
||||
// console.log(`${"=".repeat(80)}\n`);
|
||||
|
||||
// Create server-side editor instance
|
||||
const editor = ServerBlockNoteEditor.create();
|
||||
|
||||
// Convert markdown directly to BlockNote blocks
|
||||
const blocks = await editor.tryParseMarkdownToBlocks(markdown);
|
||||
|
||||
if (!blocks || blocks.length === 0) {
|
||||
throw new Error("Markdown parsing returned no blocks");
|
||||
}
|
||||
|
||||
return NextResponse.json({ blocknote_document: blocks });
|
||||
} catch (error: any) {
|
||||
console.error("Failed to convert markdown to BlockNote:", error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Failed to convert markdown to BlockNote blocks",
|
||||
details: error.message,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
28
surfsense_web/app/api/convert-to-markdown/route.ts
Normal file
28
surfsense_web/app/api/convert-to-markdown/route.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { ServerBlockNoteEditor } from "@blocknote/server-util";
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { blocknote_document } = await request.json();
|
||||
|
||||
if (!blocknote_document || !Array.isArray(blocknote_document)) {
|
||||
return NextResponse.json({ error: "BlockNote document array is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Create server-side editor instance
|
||||
const editor = ServerBlockNoteEditor.create();
|
||||
|
||||
// Convert BlockNote blocks to markdown
|
||||
const markdown = await editor.blocksToMarkdownLossy(blocknote_document);
|
||||
|
||||
return NextResponse.json({
|
||||
markdown,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to convert BlockNote to markdown:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to convert BlockNote blocks to markdown" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -18,6 +18,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
|
|||
import { Separator } from "@/components/ui/separator";
|
||||
import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
|
||||
import { useLLMPreferences } from "@/hooks/use-llm-configs";
|
||||
import { useUserAccess } from "@/hooks/use-rbac";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function DashboardClientLayout({
|
||||
|
|
@ -60,11 +61,15 @@ export function DashboardClientLayout({
|
|||
}, [activeChatId, isChatPannelOpen]);
|
||||
|
||||
const { loading, error, isOnboardingComplete } = useLLMPreferences(searchSpaceIdNum);
|
||||
const { access, loading: accessLoading } = useUserAccess(searchSpaceIdNum);
|
||||
const [hasCheckedOnboarding, setHasCheckedOnboarding] = useState(false);
|
||||
|
||||
// Skip onboarding check if we're already on the onboarding page
|
||||
const isOnboardingPage = pathname?.includes("/onboard");
|
||||
|
||||
// Only owners should see onboarding - invited members use existing config
|
||||
const isOwner = access?.is_owner ?? false;
|
||||
|
||||
// Translate navigation items
|
||||
const tNavMenu = useTranslations("nav_menu");
|
||||
const translatedNavMain = useMemo(() => {
|
||||
|
|
@ -102,11 +107,13 @@ export function DashboardClientLayout({
|
|||
return;
|
||||
}
|
||||
|
||||
// Only check once after preferences have loaded
|
||||
if (!loading && !hasCheckedOnboarding) {
|
||||
// Wait for both preferences and access data to load
|
||||
if (!loading && !accessLoading && !hasCheckedOnboarding) {
|
||||
const onboardingComplete = isOnboardingComplete();
|
||||
|
||||
if (!onboardingComplete) {
|
||||
// Only redirect to onboarding if user is the owner and onboarding is not complete
|
||||
// Invited members (non-owners) should skip onboarding and use existing config
|
||||
if (!onboardingComplete && isOwner) {
|
||||
router.push(`/dashboard/${searchSpaceId}/onboard`);
|
||||
}
|
||||
|
||||
|
|
@ -114,8 +121,10 @@ export function DashboardClientLayout({
|
|||
}
|
||||
}, [
|
||||
loading,
|
||||
accessLoading,
|
||||
isOnboardingComplete,
|
||||
isOnboardingPage,
|
||||
isOwner,
|
||||
router,
|
||||
searchSpaceId,
|
||||
hasCheckedOnboarding,
|
||||
|
|
@ -145,7 +154,7 @@ export function DashboardClientLayout({
|
|||
}, [chat_id, search_space_id]);
|
||||
|
||||
// Show loading screen while checking onboarding status (only on first load)
|
||||
if (!hasCheckedOnboarding && loading && !isOnboardingPage) {
|
||||
if (!hasCheckedOnboarding && (loading || accessLoading) && !isOnboardingPage) {
|
||||
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">
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import {
|
|||
type SearchSourceConnector,
|
||||
useSearchSourceConnectors,
|
||||
} from "@/hooks/use-search-source-connectors";
|
||||
import { authenticatedFetch } from "@/lib/auth-utils";
|
||||
|
||||
export default function AirtableConnectorPage() {
|
||||
const router = useRouter();
|
||||
|
|
@ -46,14 +47,9 @@ export default function AirtableConnectorPage() {
|
|||
const handleConnectAirtable = async () => {
|
||||
setIsConnecting(true);
|
||||
try {
|
||||
const response = await fetch(
|
||||
const response = await authenticatedFetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/auth/airtable/connector/add/?space_id=${searchSpaceId}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("surfsense_bearer_token")}`,
|
||||
},
|
||||
}
|
||||
{ method: "GET" }
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ import { EnumConnectorName } from "@/contracts/enums/connector";
|
|||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||
// Assuming useSearchSourceConnectors hook exists and works similarly
|
||||
import { useSearchSourceConnectors } from "@/hooks/use-search-source-connectors";
|
||||
import { authenticatedFetch, redirectToLogin } from "@/lib/auth-utils";
|
||||
|
||||
// Define the form schema with Zod for GitHub PAT entry step
|
||||
const githubPatFormSchema = z.object({
|
||||
|
|
@ -101,19 +102,11 @@ export default function GithubConnectorPage() {
|
|||
setConnectorName(values.name); // Store the name
|
||||
setValidatedPat(values.github_pat); // Store the PAT temporarily
|
||||
try {
|
||||
const token = localStorage.getItem("surfsense_bearer_token");
|
||||
if (!token) {
|
||||
throw new Error("No authentication token found");
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
const response = await authenticatedFetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/github/repositories`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ github_pat: values.github_pat }),
|
||||
}
|
||||
);
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import {
|
|||
type SearchSourceConnector,
|
||||
useSearchSourceConnectors,
|
||||
} from "@/hooks/use-search-source-connectors";
|
||||
import { authenticatedFetch } from "@/lib/auth-utils";
|
||||
|
||||
export default function GoogleCalendarConnectorPage() {
|
||||
const router = useRouter();
|
||||
|
|
@ -51,14 +52,9 @@ export default function GoogleCalendarConnectorPage() {
|
|||
try {
|
||||
setIsConnecting(true);
|
||||
// Call backend to initiate authorization flow
|
||||
const response = await fetch(
|
||||
const response = await authenticatedFetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/auth/google/calendar/connector/add/?space_id=${searchSpaceId}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("surfsense_bearer_token")}`,
|
||||
},
|
||||
}
|
||||
{ method: "GET" }
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import {
|
|||
type SearchSourceConnector,
|
||||
useSearchSourceConnectors,
|
||||
} from "@/hooks/use-search-source-connectors";
|
||||
import { authenticatedFetch } from "@/lib/auth-utils";
|
||||
|
||||
export default function GoogleGmailConnectorPage() {
|
||||
const router = useRouter();
|
||||
|
|
@ -50,14 +51,9 @@ export default function GoogleGmailConnectorPage() {
|
|||
try {
|
||||
setIsConnecting(true);
|
||||
// Call backend to initiate authorization flow
|
||||
const response = await fetch(
|
||||
const response = await authenticatedFetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/auth/google/gmail/connector/add/?space_id=${searchSpaceId}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("surfsense_bearer_token")}`,
|
||||
},
|
||||
}
|
||||
{ method: "GET" }
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
|
|
|
|||
|
|
@ -309,6 +309,7 @@ export function DocumentsTableShell({
|
|||
refreshDocuments={async () => {
|
||||
await onRefresh();
|
||||
}}
|
||||
searchSpaceId={searchSpaceId as string}
|
||||
/>
|
||||
</TableCell>
|
||||
</motion.tr>
|
||||
|
|
@ -340,6 +341,7 @@ export function DocumentsTableShell({
|
|||
refreshDocuments={async () => {
|
||||
await onRefresh();
|
||||
}}
|
||||
searchSpaceId={searchSpaceId as string}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-2">
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
"use client";
|
||||
|
||||
import { MoreHorizontal } from "lucide-react";
|
||||
import { FileText, Pencil, Trash2 } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { JsonMetadataViewer } from "@/components/json-metadata-viewer";
|
||||
|
|
@ -12,29 +14,26 @@ import {
|
|||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import type { Document } from "./types";
|
||||
|
||||
export function RowActions({
|
||||
document,
|
||||
deleteDocument,
|
||||
refreshDocuments,
|
||||
searchSpaceId,
|
||||
}: {
|
||||
document: Document;
|
||||
deleteDocument: (id: number) => Promise<boolean>;
|
||||
refreshDocuments: () => Promise<void>;
|
||||
searchSpaceId: string;
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
|
||||
const [isMetadataOpen, setIsMetadataOpen] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
const handleDelete = async () => {
|
||||
setIsDeleting(true);
|
||||
|
|
@ -48,62 +47,114 @@ export function RowActions({
|
|||
toast.error("Failed to delete document");
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
setIsOpen(false);
|
||||
setIsDeleteOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = () => {
|
||||
router.push(`/dashboard/${searchSpaceId}/editor/${document.id}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex justify-end">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<span className="sr-only">Open menu</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<JsonMetadataViewer
|
||||
title={document.title}
|
||||
metadata={document.document_metadata}
|
||||
trigger={
|
||||
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
|
||||
View Metadata
|
||||
</DropdownMenuItem>
|
||||
}
|
||||
/>
|
||||
<DropdownMenuSeparator />
|
||||
<AlertDialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<AlertDialogTrigger asChild>
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onSelect={(e) => {
|
||||
e.preventDefault();
|
||||
setIsOpen(true);
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Are you sure?</AlertDialogTitle>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleDelete();
|
||||
}}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{isDeleting ? "Deleting..." : "Delete"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{/* Edit Button */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
transition={{ type: "spring", stiffness: 400, damping: 17 }}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-muted-foreground hover:text-foreground hover:bg-muted/80"
|
||||
onClick={handleEdit}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
<span className="sr-only">Edit Document</span>
|
||||
</Button>
|
||||
</motion.div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
<p>Edit Document</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* View Metadata Button */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
transition={{ type: "spring", stiffness: 400, damping: 17 }}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-muted-foreground hover:text-foreground hover:bg-muted/80"
|
||||
onClick={() => setIsMetadataOpen(true)}
|
||||
>
|
||||
<FileText className="h-4 w-4" />
|
||||
<span className="sr-only">View Metadata</span>
|
||||
</Button>
|
||||
</motion.div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
<p>View Metadata</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<JsonMetadataViewer
|
||||
title={document.title}
|
||||
metadata={document.document_metadata}
|
||||
open={isMetadataOpen}
|
||||
onOpenChange={setIsMetadataOpen}
|
||||
/>
|
||||
|
||||
{/* Delete Button */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
transition={{ type: "spring", stiffness: 400, damping: 17 }}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-muted-foreground hover:text-destructive hover:bg-destructive/10"
|
||||
onClick={() => setIsDeleteOpen(true)}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<span className="sr-only">Delete</span>
|
||||
</Button>
|
||||
</motion.div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
<p>Delete</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<AlertDialog open={isDeleteOpen} onOpenChange={setIsDeleteOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Are you sure?</AlertDialogTitle>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleDelete();
|
||||
}}
|
||||
disabled={isDeleting}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{isDeleting ? "Deleting..." : "Delete"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,256 @@
|
|||
"use client";
|
||||
|
||||
import { AlertCircle, FileText, Loader2, Save, X } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { BlockNoteEditor } from "@/components/DynamicBlockNoteEditor";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { authenticatedFetch, getBearerToken, redirectToLogin } from "@/lib/auth-utils";
|
||||
|
||||
interface EditorContent {
|
||||
document_id: number;
|
||||
title: string;
|
||||
blocknote_document: any;
|
||||
last_edited_at: string | null;
|
||||
}
|
||||
|
||||
export default function EditorPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const documentId = params.documentId as string;
|
||||
|
||||
const [document, setDocument] = useState<EditorContent | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [editorContent, setEditorContent] = useState<any>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
||||
|
||||
// Fetch document content - DIRECT CALL TO FASTAPI
|
||||
useEffect(() => {
|
||||
async function fetchDocument() {
|
||||
const token = getBearerToken();
|
||||
if (!token) {
|
||||
console.error("No auth token found");
|
||||
// Redirect to login with current path saved
|
||||
redirectToLogin();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await authenticatedFetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${params.search_space_id}/documents/${documentId}/editor-content`,
|
||||
{ method: "GET" }
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response
|
||||
.json()
|
||||
.catch(() => ({ detail: "Failed to fetch document" }));
|
||||
throw new Error(errorData.detail || "Failed to fetch document");
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Check if blocknote_document exists
|
||||
if (!data.blocknote_document) {
|
||||
setError(
|
||||
"This document does not have BlockNote content. Please re-upload the document to enable editing."
|
||||
);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setDocument(data);
|
||||
setEditorContent(data.blocknote_document);
|
||||
setError(null);
|
||||
} catch (error) {
|
||||
console.error("Error fetching document:", error);
|
||||
setError(
|
||||
error instanceof Error ? error.message : "Failed to fetch document. Please try again."
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (documentId) {
|
||||
fetchDocument();
|
||||
}
|
||||
}, [documentId, params.search_space_id]);
|
||||
|
||||
// Track changes to mark as unsaved
|
||||
useEffect(() => {
|
||||
if (editorContent && document) {
|
||||
setHasUnsavedChanges(true);
|
||||
}
|
||||
}, [editorContent, document]);
|
||||
|
||||
// TODO: Maybe add Auto-save every 30 seconds - DIRECT CALL TO FASTAPI
|
||||
|
||||
// Save and exit - DIRECT CALL TO FASTAPI
|
||||
const handleSave = async () => {
|
||||
const token = getBearerToken();
|
||||
if (!token) {
|
||||
toast.error("Please login to save");
|
||||
redirectToLogin();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!editorContent) {
|
||||
toast.error("No content to save");
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
// Save blocknote_document and trigger reindexing in background
|
||||
const response = await authenticatedFetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${params.search_space_id}/documents/${documentId}/save`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ blocknote_document: editorContent }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response
|
||||
.json()
|
||||
.catch(() => ({ detail: "Failed to save document" }));
|
||||
throw new Error(errorData.detail || "Failed to save document");
|
||||
}
|
||||
|
||||
setHasUnsavedChanges(false);
|
||||
toast.success("Document saved! Reindexing in background...");
|
||||
|
||||
// Small delay before redirect to show success message
|
||||
setTimeout(() => {
|
||||
router.push(`/dashboard/${params.search_space_id}/documents`);
|
||||
}, 500);
|
||||
} catch (error) {
|
||||
console.error("Error saving document:", error);
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Failed to save document. Please try again."
|
||||
);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
if (hasUnsavedChanges) {
|
||||
if (confirm("You have unsaved changes. Are you sure you want to leave?")) {
|
||||
router.back();
|
||||
}
|
||||
} else {
|
||||
router.back();
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px] p-6">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="w-full max-w-md"
|
||||
>
|
||||
<Card className="border-destructive/50">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertCircle className="h-5 w-5 text-destructive" />
|
||||
<CardTitle className="text-destructive">Error</CardTitle>
|
||||
</div>
|
||||
<CardDescription>{error}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button onClick={() => router.back()} variant="outline" className="w-full">
|
||||
<X className="mr-2 h-4 w-4" />
|
||||
Go Back
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!document) {
|
||||
return (
|
||||
<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">
|
||||
<FileText className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<p className="text-muted-foreground">Document not found</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="flex flex-col h-full w-full"
|
||||
>
|
||||
{/* Toolbar */}
|
||||
<div className="sticky top-0 z-40 flex h-16 shrink-0 items-center gap-4 border-b bg-background/95 backdrop-blur supports-backdrop-filter:bg-background/60 px-6">
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
<FileText className="h-5 w-5 text-muted-foreground shrink-0" />
|
||||
<div className="flex flex-col min-w-0">
|
||||
<h1 className="text-lg font-semibold truncate">{document.title}</h1>
|
||||
{hasUnsavedChanges && <p className="text-xs text-muted-foreground">Unsaved changes</p>}
|
||||
</div>
|
||||
</div>
|
||||
<Separator orientation="vertical" className="h-6" />
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={handleCancel} disabled={saving} className="gap-2">
|
||||
<X className="h-4 w-4" />
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={saving} className="gap-2">
|
||||
{saving ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="h-4 w-4" />
|
||||
Save & Exit
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Editor Container */}
|
||||
<div className="flex-1 overflow-hidden relative">
|
||||
<div className="h-full w-full overflow-auto p-6">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<BlockNoteEditor initialContent={editorContent} onChange={setEditorContent} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
|
@ -52,6 +52,12 @@ export default function DashboardLayout({
|
|||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Team",
|
||||
url: `/dashboard/${search_space_id}/team`,
|
||||
icon: "Users",
|
||||
items: [],
|
||||
},
|
||||
{
|
||||
title: "Settings",
|
||||
url: `/dashboard/${search_space_id}/settings`,
|
||||
|
|
|
|||
|
|
@ -1126,7 +1126,7 @@ function LogRowActions({ row, t }: { row: Row<Log>; t: (key: string) => string }
|
|||
setIsDeleting(true);
|
||||
try {
|
||||
await deleteLog(log.id);
|
||||
toast.success(t("log_deleted_success"));
|
||||
// toast.success(t("log_deleted_success"));
|
||||
await refreshLogs();
|
||||
} catch (error) {
|
||||
console.error("Error deleting log:", error);
|
||||
|
|
|
|||
|
|
@ -1,20 +1,19 @@
|
|||
"use client";
|
||||
|
||||
import { ArrowLeft, ArrowRight, Bot, CheckCircle, MessageSquare, Sparkles } from "lucide-react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { FileText, MessageSquare, UserPlus, Users } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Logo } from "@/components/Logo";
|
||||
import { CompletionStep } from "@/components/onboard/completion-step";
|
||||
import { SetupLLMStep } from "@/components/onboard/setup-llm-step";
|
||||
import { SetupPromptStep } from "@/components/onboard/setup-prompt-step";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { OnboardActionCard } from "@/components/onboard/onboard-action-card";
|
||||
import { OnboardAdvancedSettings } from "@/components/onboard/onboard-advanced-settings";
|
||||
import { OnboardHeader } from "@/components/onboard/onboard-header";
|
||||
import { OnboardLLMSetup } from "@/components/onboard/onboard-llm-setup";
|
||||
import { OnboardLoading } from "@/components/onboard/onboard-loading";
|
||||
import { OnboardStats } from "@/components/onboard/onboard-stats";
|
||||
import { useGlobalLLMConfigs, useLLMConfigs, useLLMPreferences } from "@/hooks/use-llm-configs";
|
||||
|
||||
const TOTAL_STEPS = 3;
|
||||
import { getBearerToken, redirectToLogin } from "@/lib/auth-utils";
|
||||
|
||||
const OnboardPage = () => {
|
||||
const t = useTranslations("onboard");
|
||||
|
|
@ -28,10 +27,17 @@ const OnboardPage = () => {
|
|||
preferences,
|
||||
loading: preferencesLoading,
|
||||
isOnboardingComplete,
|
||||
updatePreferences,
|
||||
refreshPreferences,
|
||||
} = useLLMPreferences(searchSpaceId);
|
||||
const [currentStep, setCurrentStep] = useState(1);
|
||||
const [hasUserProgressed, setHasUserProgressed] = useState(false);
|
||||
|
||||
const [isAutoConfiguring, setIsAutoConfiguring] = useState(false);
|
||||
const [autoConfigComplete, setAutoConfigComplete] = useState(false);
|
||||
const [showAdvancedSettings, setShowAdvancedSettings] = useState(false);
|
||||
const [showPromptSettings, setShowPromptSettings] = useState(false);
|
||||
|
||||
// Track if we've already attempted auto-configuration
|
||||
const hasAttemptedAutoConfig = useRef(false);
|
||||
|
||||
// Track if onboarding was complete on initial mount
|
||||
const wasCompleteOnMount = useRef<boolean | null>(null);
|
||||
|
|
@ -39,12 +45,13 @@ const OnboardPage = () => {
|
|||
|
||||
// Check if user is authenticated
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem("surfsense_bearer_token");
|
||||
const token = getBearerToken();
|
||||
if (!token) {
|
||||
router.push("/login");
|
||||
// Save current path and redirect to login
|
||||
redirectToLogin();
|
||||
return;
|
||||
}
|
||||
}, [router]);
|
||||
}, []);
|
||||
|
||||
// Capture onboarding state on first load
|
||||
useEffect(() => {
|
||||
|
|
@ -59,231 +66,215 @@ const OnboardPage = () => {
|
|||
}
|
||||
}, [preferencesLoading, configsLoading, globalConfigsLoading, isOnboardingComplete]);
|
||||
|
||||
// Track if user has progressed beyond step 1
|
||||
// Redirect to dashboard if onboarding was already complete
|
||||
useEffect(() => {
|
||||
if (currentStep > 1) {
|
||||
setHasUserProgressed(true);
|
||||
}
|
||||
}, [currentStep]);
|
||||
|
||||
// Redirect to dashboard if onboarding was already complete on mount (not during this session)
|
||||
useEffect(() => {
|
||||
// Only redirect if:
|
||||
// 1. Onboarding was complete when page loaded
|
||||
// 2. User hasn't progressed past step 1
|
||||
// 3. All data is loaded
|
||||
if (
|
||||
wasCompleteOnMount.current === true &&
|
||||
!hasUserProgressed &&
|
||||
!preferencesLoading &&
|
||||
!configsLoading &&
|
||||
!globalConfigsLoading
|
||||
) {
|
||||
// Small delay to ensure the check is stable on initial load
|
||||
const timer = setTimeout(() => {
|
||||
router.push(`/dashboard/${searchSpaceId}`);
|
||||
}, 300);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [
|
||||
hasUserProgressed,
|
||||
preferencesLoading,
|
||||
configsLoading,
|
||||
globalConfigsLoading,
|
||||
router,
|
||||
searchSpaceId,
|
||||
]);
|
||||
}, [preferencesLoading, configsLoading, globalConfigsLoading, router, searchSpaceId]);
|
||||
|
||||
const progress = (currentStep / TOTAL_STEPS) * 100;
|
||||
|
||||
const stepTitles = [t("setup_llm_configuration"), "Configure AI Responses", t("setup_complete")];
|
||||
|
||||
const stepDescriptions = [
|
||||
t("configure_providers_and_assign_roles"),
|
||||
"Customize how the AI responds to your queries (Optional)",
|
||||
t("all_set"),
|
||||
];
|
||||
|
||||
// User can proceed to step 2 if all roles are assigned
|
||||
const canProceedToStep2 =
|
||||
!preferencesLoading &&
|
||||
preferences.long_context_llm_id &&
|
||||
preferences.fast_llm_id &&
|
||||
preferences.strategic_llm_id;
|
||||
|
||||
// User can always proceed from step 2 to step 3 (prompt config is optional)
|
||||
const canProceedToStep3 = true;
|
||||
|
||||
const handleNext = () => {
|
||||
if (currentStep < TOTAL_STEPS) {
|
||||
setCurrentStep(currentStep + 1);
|
||||
// Auto-configure LLM roles if global configs are available
|
||||
const autoConfigureLLMs = useCallback(async () => {
|
||||
if (hasAttemptedAutoConfig.current) return;
|
||||
if (globalConfigs.length === 0) return;
|
||||
if (isOnboardingComplete()) {
|
||||
setAutoConfigComplete(true);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const handlePrevious = () => {
|
||||
if (currentStep > 1) {
|
||||
setCurrentStep(currentStep - 1);
|
||||
hasAttemptedAutoConfig.current = true;
|
||||
setIsAutoConfiguring(true);
|
||||
|
||||
try {
|
||||
const allConfigs = [...globalConfigs, ...llmConfigs];
|
||||
|
||||
if (allConfigs.length === 0) {
|
||||
setIsAutoConfiguring(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Use first available config for all roles
|
||||
const defaultConfigId = allConfigs[0].id;
|
||||
|
||||
const newPreferences = {
|
||||
long_context_llm_id: defaultConfigId,
|
||||
fast_llm_id: defaultConfigId,
|
||||
strategic_llm_id: defaultConfigId,
|
||||
};
|
||||
|
||||
const success = await updatePreferences(newPreferences);
|
||||
|
||||
if (success) {
|
||||
await refreshPreferences();
|
||||
setAutoConfigComplete(true);
|
||||
toast.success("AI models configured automatically!", {
|
||||
description: "You can customize these in advanced settings.",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Auto-configuration failed:", error);
|
||||
} finally {
|
||||
setIsAutoConfiguring(false);
|
||||
}
|
||||
};
|
||||
}, [globalConfigs, llmConfigs, isOnboardingComplete, updatePreferences, refreshPreferences]);
|
||||
|
||||
if (configsLoading || preferencesLoading || globalConfigsLoading) {
|
||||
// Trigger auto-configuration once data is loaded
|
||||
useEffect(() => {
|
||||
if (!configsLoading && !globalConfigsLoading && !preferencesLoading) {
|
||||
autoConfigureLLMs();
|
||||
}
|
||||
}, [configsLoading, globalConfigsLoading, preferencesLoading, autoConfigureLLMs]);
|
||||
|
||||
const allConfigs = [...globalConfigs, ...llmConfigs];
|
||||
const isReady = autoConfigComplete || isOnboardingComplete();
|
||||
|
||||
// Loading state
|
||||
if (configsLoading || preferencesLoading || globalConfigsLoading || isAutoConfiguring) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen">
|
||||
<Card className="w-[350px] bg-background/60 backdrop-blur-sm">
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<Bot className="h-12 w-12 text-primary animate-pulse mb-4" />
|
||||
<p className="text-sm text-muted-foreground">{t("loading_config")}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<OnboardLoading
|
||||
title={isAutoConfiguring ? "Setting up your AI assistant..." : t("loading_config")}
|
||||
subtitle={
|
||||
isAutoConfiguring
|
||||
? "Auto-configuring optimal settings for you"
|
||||
: "Please wait while we load your configuration"
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// No configs available - show LLM setup
|
||||
if (allConfigs.length === 0) {
|
||||
return (
|
||||
<OnboardLLMSetup
|
||||
searchSpaceId={searchSpaceId}
|
||||
title={t("welcome_title")}
|
||||
configTitle={t("setup_llm_configuration")}
|
||||
configDescription={t("configure_providers_and_assign_roles")}
|
||||
onConfigCreated={refreshConfigs}
|
||||
onConfigDeleted={refreshConfigs}
|
||||
onPreferencesUpdated={refreshPreferences}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Main onboarding view
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-background via-background to-muted/20 flex items-center justify-center p-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="w-full max-w-4xl"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="flex items-center justify-center mb-4">
|
||||
<Logo className="w-12 h-12 mr-3 rounded-full" />
|
||||
<h1 className="text-3xl font-bold">{t("welcome_title")}</h1>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-lg">{t("welcome_subtitle")}</p>
|
||||
</div>
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="flex items-center justify-center min-h-screen p-4 md:p-8">
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.6 }}
|
||||
className="w-full max-w-5xl"
|
||||
>
|
||||
{/* Header */}
|
||||
<OnboardHeader
|
||||
title={t("welcome_title")}
|
||||
subtitle={
|
||||
isReady ? "You're all set! Choose what you'd like to do next." : t("welcome_subtitle")
|
||||
}
|
||||
isReady={isReady}
|
||||
/>
|
||||
|
||||
{/* Progress */}
|
||||
<Card className="mb-8 bg-background/60 backdrop-blur-sm">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="text-sm font-medium">
|
||||
{t("step_of", { current: currentStep, total: TOTAL_STEPS })}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t("percent_complete", { percent: Math.round(progress) })}
|
||||
</div>
|
||||
</div>
|
||||
<Progress value={progress} className="mb-4" />
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{Array.from({ length: TOTAL_STEPS }, (_, i) => {
|
||||
const stepNum = i + 1;
|
||||
const isCompleted = stepNum < currentStep;
|
||||
const isCurrent = stepNum === currentStep;
|
||||
{/* Quick Stats */}
|
||||
<OnboardStats
|
||||
globalConfigsCount={globalConfigs.length}
|
||||
userConfigsCount={llmConfigs.length}
|
||||
/>
|
||||
|
||||
return (
|
||||
<div key={stepNum} className="flex items-center space-x-2">
|
||||
<div
|
||||
className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium ${
|
||||
isCompleted
|
||||
? "bg-primary text-primary-foreground"
|
||||
: isCurrent
|
||||
? "bg-primary/20 text-primary border-2 border-primary"
|
||||
: "bg-muted text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{isCompleted ? <CheckCircle className="w-4 h-4" /> : stepNum}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p
|
||||
className={`text-sm font-medium truncate ${
|
||||
isCurrent ? "text-foreground" : "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{stepTitles[i]}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* Action Cards */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.6 }}
|
||||
className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-10"
|
||||
>
|
||||
<OnboardActionCard
|
||||
title="Manage Team"
|
||||
description="Invite team members and collaborate on your search space"
|
||||
icon={Users}
|
||||
features={[
|
||||
"Invite team members",
|
||||
"Assign roles & permissions",
|
||||
"Collaborate together",
|
||||
]}
|
||||
buttonText="Manage Team"
|
||||
onClick={() => router.push(`/dashboard/${searchSpaceId}/team`)}
|
||||
colorScheme="emerald"
|
||||
delay={0.7}
|
||||
/>
|
||||
|
||||
{/* Step Content */}
|
||||
<Card className="min-h-[500px] bg-background/60 backdrop-blur-sm">
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle className="text-2xl flex items-center justify-center gap-2">
|
||||
{currentStep === 1 && <Sparkles className="w-6 h-6" />}
|
||||
{currentStep === 2 && <MessageSquare className="w-6 h-6" />}
|
||||
{currentStep === 3 && <CheckCircle className="w-6 h-6" />}
|
||||
{stepTitles[currentStep - 1]}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-base">
|
||||
{stepDescriptions[currentStep - 1]}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={currentStep}
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -20 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
{currentStep === 1 && (
|
||||
<SetupLLMStep
|
||||
searchSpaceId={searchSpaceId}
|
||||
onConfigCreated={refreshConfigs}
|
||||
onConfigDeleted={refreshConfigs}
|
||||
onPreferencesUpdated={refreshPreferences}
|
||||
/>
|
||||
)}
|
||||
{currentStep === 2 && (
|
||||
<SetupPromptStep searchSpaceId={searchSpaceId} onComplete={handleNext} />
|
||||
)}
|
||||
{currentStep === 3 && <CompletionStep searchSpaceId={searchSpaceId} />}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<OnboardActionCard
|
||||
title="Add Sources"
|
||||
description="Connect your data sources to start building your knowledge base"
|
||||
icon={FileText}
|
||||
features={[
|
||||
"Connect documents and files",
|
||||
"Import from various sources",
|
||||
"Build your knowledge base",
|
||||
]}
|
||||
buttonText="Add Sources"
|
||||
onClick={() => router.push(`/dashboard/${searchSpaceId}/sources/add`)}
|
||||
colorScheme="blue"
|
||||
delay={0.8}
|
||||
/>
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="flex justify-between mt-8">
|
||||
{currentStep === 1 ? (
|
||||
<>
|
||||
<div />
|
||||
<Button
|
||||
onClick={handleNext}
|
||||
disabled={!canProceedToStep2}
|
||||
className="flex items-center gap-2"
|
||||
<OnboardActionCard
|
||||
title="Start Chatting"
|
||||
description="Jump right into the AI researcher and start asking questions"
|
||||
icon={MessageSquare}
|
||||
features={[
|
||||
"AI-powered conversations",
|
||||
"Research and explore topics",
|
||||
"Get instant insights",
|
||||
]}
|
||||
buttonText="Start Chatting"
|
||||
onClick={() => router.push(`/dashboard/${searchSpaceId}/researcher`)}
|
||||
colorScheme="violet"
|
||||
delay={0.9}
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
{/* Advanced Settings */}
|
||||
<OnboardAdvancedSettings
|
||||
searchSpaceId={searchSpaceId}
|
||||
showLLMSettings={showAdvancedSettings}
|
||||
setShowLLMSettings={setShowAdvancedSettings}
|
||||
showPromptSettings={showPromptSettings}
|
||||
setShowPromptSettings={setShowPromptSettings}
|
||||
onConfigCreated={refreshConfigs}
|
||||
onConfigDeleted={refreshConfigs}
|
||||
onPreferencesUpdated={refreshPreferences}
|
||||
/>
|
||||
|
||||
{/* Footer */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 1.1 }}
|
||||
className="text-center mt-10 text-muted-foreground text-sm"
|
||||
>
|
||||
<p>
|
||||
You can always adjust these settings later in{" "}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push(`/dashboard/${searchSpaceId}/settings`)}
|
||||
className="text-primary hover:underline underline-offset-2 transition-colors"
|
||||
>
|
||||
{t("next")}
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</Button>
|
||||
</>
|
||||
) : currentStep === 2 ? (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handlePrevious}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
{t("previous")}
|
||||
</Button>
|
||||
{/* Next button is handled by SetupPromptStep component */}
|
||||
<div />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handlePrevious}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
{t("previous")}
|
||||
</Button>
|
||||
<div />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
Settings
|
||||
</button>
|
||||
</p>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -17,13 +17,14 @@ export default function ResearcherPage() {
|
|||
const { search_space_id } = useParams();
|
||||
const router = useRouter();
|
||||
const hasSetInitialConnectors = useRef(false);
|
||||
const hasInitiatedResponse = useRef<string | null>(null);
|
||||
const activeChatId = useAtomValue(activeChatIdAtom);
|
||||
const { data: activeChatState, isFetching: isChatLoading } = useAtomValue(activeChatAtom);
|
||||
const { mutateAsync: createChat } = useAtomValue(createChatMutationAtom);
|
||||
const { mutateAsync: updateChat } = useAtomValue(updateChatMutationAtom);
|
||||
const isNewChat = !activeChatId;
|
||||
|
||||
// Reset the flag when chat ID changes
|
||||
// Reset the flag when chat ID changes (but not hasInitiatedResponse - we need to remember if we already initiated)
|
||||
useEffect(() => {
|
||||
hasSetInitialConnectors.current = false;
|
||||
}, [activeChatId]);
|
||||
|
|
@ -167,10 +168,14 @@ export default function ResearcherPage() {
|
|||
if (chatData.messages && Array.isArray(chatData.messages)) {
|
||||
if (chatData.messages.length === 1 && chatData.messages[0].role === "user") {
|
||||
// Single user message - append to trigger LLM response
|
||||
handler.append({
|
||||
role: "user",
|
||||
content: chatData.messages[0].content,
|
||||
});
|
||||
// Only if we haven't already initiated for this chat and handler doesn't have messages yet
|
||||
if (hasInitiatedResponse.current !== activeChatId && handler.messages.length === 0) {
|
||||
hasInitiatedResponse.current = activeChatId;
|
||||
handler.append({
|
||||
role: "user",
|
||||
content: chatData.messages[0].content,
|
||||
});
|
||||
}
|
||||
} else if (chatData.messages.length > 1) {
|
||||
// Multiple messages - set them all
|
||||
handler.setMessages(chatData.messages);
|
||||
|
|
|
|||
1325
surfsense_web/app/dashboard/[search_space_id]/team/page.tsx
Normal file
1325
surfsense_web/app/dashboard/[search_space_id]/team/page.tsx
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,28 +1,28 @@
|
|||
"use client";
|
||||
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { AnnouncementBanner } from "@/components/announcement-banner";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { getBearerToken, redirectToLogin } from "@/lib/auth-utils";
|
||||
|
||||
interface DashboardLayoutProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function DashboardLayout({ children }: DashboardLayoutProps) {
|
||||
const router = useRouter();
|
||||
const [isCheckingAuth, setIsCheckingAuth] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
// Check if user is authenticated
|
||||
const token = localStorage.getItem("surfsense_bearer_token");
|
||||
const token = getBearerToken();
|
||||
if (!token) {
|
||||
router.push("/login");
|
||||
// Save current path and redirect to login
|
||||
redirectToLogin();
|
||||
return;
|
||||
}
|
||||
setIsCheckingAuth(false);
|
||||
}, [router]);
|
||||
}, []);
|
||||
|
||||
// Show loading screen while checking authentication
|
||||
if (isCheckingAuth) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { AlertCircle, Loader2, Plus, Search, Trash2 } from "lucide-react";
|
||||
import { AlertCircle, Loader2, Plus, Search, Trash2, UserCheck, Users } from "lucide-react";
|
||||
import { motion, type Variants } from "motion/react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
|
|
@ -22,6 +22,7 @@ import {
|
|||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
|
|
@ -35,6 +36,7 @@ import { Spotlight } from "@/components/ui/spotlight";
|
|||
import { Tilt } from "@/components/ui/tilt";
|
||||
import { useUser } from "@/hooks";
|
||||
import { useSearchSpaces } from "@/hooks/use-search-spaces";
|
||||
import { authenticatedFetch } from "@/lib/auth-utils";
|
||||
|
||||
/**
|
||||
* Formats a date string into a readable format
|
||||
|
|
@ -172,14 +174,9 @@ const DashboardPage = () => {
|
|||
const handleDeleteSearchSpace = async (id: number) => {
|
||||
// Send DELETE request to the API
|
||||
try {
|
||||
const response = await fetch(
|
||||
const response = await authenticatedFetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces/${id}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("surfsense_bearer_token")}`,
|
||||
},
|
||||
}
|
||||
{ method: "DELETE" }
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
|
|
@ -308,16 +305,30 @@ const DashboardPage = () => {
|
|||
>
|
||||
<div className="flex flex-1 flex-col justify-between p-1">
|
||||
<div>
|
||||
<h3 className="font-medium text-lg">{space.name}</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-medium text-lg">{space.name}</h3>
|
||||
{!space.is_owner && (
|
||||
<Badge variant="secondary" className="text-xs font-normal">
|
||||
{t("shared")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{space.description}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-4 text-xs text-muted-foreground">
|
||||
{/* <span>{space.title}</span> */}
|
||||
<div className="mt-4 flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>
|
||||
{t("created")} {formatDate(space.created_at)}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{space.is_owner ? (
|
||||
<UserCheck className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Users className="h-3.5 w-3.5" />
|
||||
)}
|
||||
<span>{space.member_count}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
|
|
|
|||
|
|
@ -4,19 +4,21 @@ import { motion } from "motion/react";
|
|||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import { SearchSpaceForm } from "@/components/search-space-form";
|
||||
import { authenticatedFetch } from "@/lib/auth-utils";
|
||||
|
||||
export default function SearchSpacesPage() {
|
||||
const router = useRouter();
|
||||
const handleCreateSearchSpace = async (data: { name: string; description: string }) => {
|
||||
const handleCreateSearchSpace = async (data: { name: string; description?: string }) => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
const response = await authenticatedFetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${localStorage.getItem("surfsense_bearer_token")}`,
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name: data.name,
|
||||
description: data.description || "",
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
|
|
@ -31,7 +33,8 @@ export default function SearchSpacesPage() {
|
|||
description: `"${data.name}" has been created.`,
|
||||
});
|
||||
|
||||
router.push(`/dashboard`);
|
||||
// Redirect to the newly created search space's onboarding
|
||||
router.push(`/dashboard/${result.id}/onboard`);
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@
|
|||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--destructive-foreground: oklch(0.577 0.245 27.325);
|
||||
--destructive-foreground: oklch(0.985 0 0);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
|
|
@ -63,8 +63,8 @@
|
|||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.396 0.141 25.723);
|
||||
--destructive-foreground: oklch(0.637 0.237 25.331);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--destructive-foreground: oklch(0.985 0 0);
|
||||
--border: oklch(0.269 0 0);
|
||||
--input: oklch(0.269 0 0);
|
||||
--ring: oklch(0.439 0 0);
|
||||
|
|
|
|||
340
surfsense_web/app/invite/[invite_code]/page.tsx
Normal file
340
surfsense_web/app/invite/[invite_code]/page.tsx
Normal file
|
|
@ -0,0 +1,340 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
AlertCircle,
|
||||
ArrowRight,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
Loader2,
|
||||
LogIn,
|
||||
Shield,
|
||||
Sparkles,
|
||||
Users,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { use, useEffect, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { useInviteInfo } from "@/hooks/use-rbac";
|
||||
import { getBearerToken } from "@/lib/auth-utils";
|
||||
|
||||
export default function InviteAcceptPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const inviteCode = params.invite_code as string;
|
||||
|
||||
const { inviteInfo, loading, acceptInvite } = useInviteInfo(inviteCode);
|
||||
const [accepting, setAccepting] = useState(false);
|
||||
const [accepted, setAccepted] = useState(false);
|
||||
const [acceptedData, setAcceptedData] = useState<{
|
||||
search_space_id: number;
|
||||
search_space_name: string;
|
||||
role_name: string;
|
||||
} | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isLoggedIn, setIsLoggedIn] = useState<boolean | null>(null);
|
||||
|
||||
// Check if user is logged in
|
||||
useEffect(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
const token = getBearerToken();
|
||||
setIsLoggedIn(!!token);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleAccept = async () => {
|
||||
setAccepting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await acceptInvite();
|
||||
if (result) {
|
||||
setAccepted(true);
|
||||
setAcceptedData(result);
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Failed to accept invite");
|
||||
} finally {
|
||||
setAccepting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLoginRedirect = () => {
|
||||
// Store the invite code to redirect back after login
|
||||
localStorage.setItem("pending_invite_code", inviteCode);
|
||||
// Save the current invite page URL so we can return after authentication
|
||||
localStorage.setItem("surfsense_redirect_path", `/invite/${inviteCode}`);
|
||||
// Redirect to login (we manually set the path above since invite pages need special handling)
|
||||
window.location.href = "/login";
|
||||
};
|
||||
|
||||
// Check for pending invite after login
|
||||
useEffect(() => {
|
||||
if (isLoggedIn && typeof window !== "undefined") {
|
||||
const pendingInvite = localStorage.getItem("pending_invite_code");
|
||||
if (pendingInvite === inviteCode) {
|
||||
localStorage.removeItem("pending_invite_code");
|
||||
// Auto-accept the invite after redirect
|
||||
handleAccept();
|
||||
}
|
||||
}
|
||||
}, [isLoggedIn, inviteCode]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-4 bg-gradient-to-br from-background via-background to-primary/5">
|
||||
{/* Background decoration */}
|
||||
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
||||
<div className="absolute -top-1/2 -right-1/2 w-full h-full bg-gradient-to-bl from-primary/10 via-transparent to-transparent rounded-full blur-3xl" />
|
||||
<div className="absolute -bottom-1/2 -left-1/2 w-full h-full bg-gradient-to-tr from-violet-500/10 via-transparent to-transparent rounded-full blur-3xl" />
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20, scale: 0.95 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
transition={{ duration: 0.5, ease: "easeOut" }}
|
||||
className="w-full max-w-md relative z-10"
|
||||
>
|
||||
<Card className="border-none shadow-2xl bg-card/80 backdrop-blur-xl">
|
||||
{loading || isLoggedIn === null ? (
|
||||
<CardContent className="flex flex-col items-center justify-center py-16">
|
||||
<motion.div
|
||||
animate={{ rotate: 360 }}
|
||||
transition={{ duration: 1, repeat: Infinity, ease: "linear" }}
|
||||
>
|
||||
<Loader2 className="h-12 w-12 text-primary" />
|
||||
</motion.div>
|
||||
<p className="mt-4 text-muted-foreground">Loading invite details...</p>
|
||||
</CardContent>
|
||||
) : accepted && acceptedData ? (
|
||||
<>
|
||||
<CardHeader className="text-center pb-4">
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ type: "spring", stiffness: 200, damping: 15 }}
|
||||
className="mx-auto mb-4 h-20 w-20 rounded-full bg-gradient-to-br from-emerald-500/20 to-emerald-500/5 flex items-center justify-center ring-4 ring-emerald-500/20"
|
||||
>
|
||||
<CheckCircle2 className="h-10 w-10 text-emerald-500" />
|
||||
</motion.div>
|
||||
<CardTitle className="text-2xl">Welcome to the team!</CardTitle>
|
||||
<CardDescription>
|
||||
You've successfully joined {acceptedData.search_space_name}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="bg-muted/50 rounded-lg p-4 space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-10 w-10 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||
<Users className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">{acceptedData.search_space_name}</p>
|
||||
<p className="text-sm text-muted-foreground">Search Space</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-10 w-10 rounded-lg bg-violet-500/10 flex items-center justify-center">
|
||||
<Shield className="h-5 w-5 text-violet-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">{acceptedData.role_name}</p>
|
||||
<p className="text-sm text-muted-foreground">Your Role</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button
|
||||
className="w-full gap-2"
|
||||
onClick={() => router.push(`/dashboard/${acceptedData.search_space_id}`)}
|
||||
>
|
||||
Go to Search Space
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</>
|
||||
) : !inviteInfo?.is_valid ? (
|
||||
<>
|
||||
<CardHeader className="text-center pb-4">
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ type: "spring", stiffness: 200, damping: 15 }}
|
||||
className="mx-auto mb-4 h-20 w-20 rounded-full bg-gradient-to-br from-destructive/20 to-destructive/5 flex items-center justify-center ring-4 ring-destructive/20"
|
||||
>
|
||||
<XCircle className="h-10 w-10 text-destructive" />
|
||||
</motion.div>
|
||||
<CardTitle className="text-2xl">Invalid Invite</CardTitle>
|
||||
<CardDescription>
|
||||
{inviteInfo?.message || "This invite link is no longer valid"}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
The invite may have expired, reached its maximum uses, or been revoked by the
|
||||
owner.
|
||||
</p>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => router.push("/dashboard")}
|
||||
>
|
||||
Go to Dashboard
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</>
|
||||
) : !isLoggedIn ? (
|
||||
<>
|
||||
<CardHeader className="text-center pb-4">
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ type: "spring", stiffness: 200, damping: 15 }}
|
||||
className="mx-auto mb-4 h-20 w-20 rounded-full bg-gradient-to-br from-primary/20 to-primary/5 flex items-center justify-center ring-4 ring-primary/20"
|
||||
>
|
||||
<Sparkles className="h-10 w-10 text-primary" />
|
||||
</motion.div>
|
||||
<CardTitle className="text-2xl">You're Invited!</CardTitle>
|
||||
<CardDescription>
|
||||
Sign in to join {inviteInfo?.search_space_name || "this search space"}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="bg-muted/50 rounded-lg p-4 space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-10 w-10 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||
<Users className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">{inviteInfo?.search_space_name}</p>
|
||||
<p className="text-sm text-muted-foreground">Search Space</p>
|
||||
</div>
|
||||
</div>
|
||||
{inviteInfo?.role_name && (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-10 w-10 rounded-lg bg-violet-500/10 flex items-center justify-center">
|
||||
<Shield className="h-5 w-5 text-violet-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">{inviteInfo.role_name}</p>
|
||||
<p className="text-sm text-muted-foreground">Role you'll receive</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button className="w-full gap-2" onClick={handleLoginRedirect}>
|
||||
<LogIn className="h-4 w-4" />
|
||||
Sign in to Accept
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CardHeader className="text-center pb-4">
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ type: "spring", stiffness: 200, damping: 15 }}
|
||||
className="mx-auto mb-4 h-20 w-20 rounded-full bg-gradient-to-br from-primary/20 to-primary/5 flex items-center justify-center ring-4 ring-primary/20"
|
||||
>
|
||||
<Sparkles className="h-10 w-10 text-primary" />
|
||||
</motion.div>
|
||||
<CardTitle className="text-2xl">You're Invited!</CardTitle>
|
||||
<CardDescription>
|
||||
Accept this invite to join {inviteInfo?.search_space_name || "this search space"}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="bg-muted/50 rounded-lg p-4 space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-10 w-10 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||
<Users className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">{inviteInfo?.search_space_name}</p>
|
||||
<p className="text-sm text-muted-foreground">Search Space</p>
|
||||
</div>
|
||||
</div>
|
||||
{inviteInfo?.role_name && (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-10 w-10 rounded-lg bg-violet-500/10 flex items-center justify-center">
|
||||
<Shield className="h-5 w-5 text-violet-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">{inviteInfo.role_name}</p>
|
||||
<p className="text-sm text-muted-foreground">Role you'll receive</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="flex items-center gap-2 p-3 bg-destructive/10 text-destructive rounded-lg text-sm"
|
||||
>
|
||||
<AlertCircle className="h-4 w-4 shrink-0" />
|
||||
{error}
|
||||
</motion.div>
|
||||
)}
|
||||
</CardContent>
|
||||
<CardFooter className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
onClick={() => router.push("/dashboard")}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button className="flex-1 gap-2" onClick={handleAccept} disabled={accepting}>
|
||||
{accepting ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Accepting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
Accept Invite
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Branding */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.3 }}
|
||||
className="mt-6 text-center"
|
||||
>
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex items-center gap-2 text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<Image src="/icon-128.png" alt="SurfSense" width={24} height={24} className="rounded" />
|
||||
<span className="text-sm font-medium">SurfSense</span>
|
||||
</Link>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue