diff --git a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsFilters.tsx b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsFilters.tsx index eb7a4517c..26e53cfe0 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsFilters.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsFilters.tsx @@ -1,13 +1,6 @@ "use client"; -import { - CircleAlert, - ListFilter, - Search, - Trash, - Upload, - X, -} from "lucide-react"; +import { CircleAlert, ListFilter, Search, Trash, Upload, X } from "lucide-react"; import { motion } from "motion/react"; import { useTranslations } from "next-intl"; import React, { useMemo, useRef, useState } from "react"; @@ -97,78 +90,78 @@ export function DocumentsFilters({ )} - -
- {/* Search input */} -
-
- - setTypeSearchQuery(e.target.value)} - className="h-6 pl-6 text-sm bg-transparent border-0 focus-visible:ring-0" - /> -
+ +
+ {/* Search input */} +
+
+ + setTypeSearchQuery(e.target.value)} + className="h-6 pl-6 text-sm bg-transparent border-0 focus-visible:ring-0" + />
+
-
- {filteredTypes.length === 0 ? ( -
- No types found -
- ) : ( - filteredTypes.map((value: DocumentTypeEnum, i) => ( - - )) - )} -
- {activeTypes.length > 0 && ( -
- +
+ {filteredTypes.length === 0 ? ( +
+ No types found
+ ) : ( + filteredTypes.map((value: DocumentTypeEnum, i) => ( + + )) )}
- - + {activeTypes.length > 0 && ( +
+ +
+ )} +
+ + {/* Search Input */}
@@ -202,44 +195,44 @@ export function DocumentsFilters({ {/* Bulk Delete Button */} {selectedIds.size > 0 && ( - - - - - -
- - - - Delete {selectedIds.size} document{selectedIds.size !== 1 ? "s" : ""}? - - - This action cannot be undone. This will permanently delete the selected{" "} - {selectedIds.size === 1 ? "document" : "documents"} from your search space. - - + + + + + +
+ - - Cancel - - Delete - - - - + + + Delete {selectedIds.size} document{selectedIds.size !== 1 ? "s" : ""}? + + + This action cannot be undone. This will permanently delete the selected{" "} + {selectedIds.size === 1 ? "document" : "documents"} from your search space. + + +
+ + Cancel + + Delete + + +
+
)} {/* Upload Button */} diff --git a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsTableShell.tsx b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsTableShell.tsx index 218b85da2..dd870dd8a 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsTableShell.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsTableShell.tsx @@ -137,13 +137,7 @@ function formatAbsoluteDate(dateStr: string): string { }); } -function DocumentNameTooltip({ - doc, - className, -}: { - doc: Document; - className?: string; -}) { +function DocumentNameTooltip({ doc, className }: { doc: Document; className?: string }) { const textRef = useRef(null); const [isTruncated, setIsTruncated] = useState(false); @@ -167,9 +161,7 @@ function DocumentNameTooltip({
- {isTruncated && ( -

{doc.title}

- )} + {isTruncated &&

{doc.title}

}

Owner:{" "} {doc.created_by_name || doc.created_by_email || "—"} @@ -235,8 +227,7 @@ function RowContextMenu({ const isEditable = EDITABLE_DOCUMENT_TYPES.includes( doc.document_type as (typeof EDITABLE_DOCUMENT_TYPES)[number] ); - const isBeingProcessed = - doc.status?.state === "pending" || doc.status?.state === "processing"; + const isBeingProcessed = doc.status?.state === "pending" || doc.status?.state === "processing"; const isFileFailed = doc.document_type === "FILE" && doc.status?.state === "failed"; const shouldShowDelete = !NON_DELETABLE_DOCUMENT_TYPES.includes( doc.document_type as (typeof NON_DELETABLE_DOCUMENT_TYPES)[number] @@ -255,8 +246,7 @@ function RowContextMenu({ {isEditable && ( - !isEditDisabled && - router.push(`/dashboard/${searchSpaceId}/editor/${doc.id}`) + !isEditDisabled && router.push(`/dashboard/${searchSpaceId}/editor/${doc.id}`) } disabled={isEditDisabled} > @@ -348,7 +338,9 @@ export function DocumentsTableShell({ observe(desktopScrollRef.current, desktopSentinelRef.current); observe(mobileScrollRef.current, mobileSentinelRef.current); - return () => { for (const o of observers) o.disconnect(); }; + return () => { + for (const o of observers) o.disconnect(); + }; }, [onLoadMore, hasMore, loadingMore]); const handleViewDocument = useCallback(async (doc: Document) => { @@ -375,30 +367,27 @@ export function DocumentsTableShell({ setViewingLoading(false); }, []); - const handleDeleteFromMenu = useCallback( - async () => { - if (!deleteDoc) return; - setIsDeleting(true); - try { - const ok = await deleteDocument(deleteDoc.id); - if (!ok) toast.error("Failed to delete document"); - } catch (error: unknown) { - console.error("Error deleting document:", error); - const status = - (error as { response?: { status?: number } })?.response?.status ?? - (error as { status?: number })?.status; - if (status === 409) { - toast.error("Document is now being processed. Please try again later."); - } else { - toast.error("Failed to delete document"); - } - } finally { - setIsDeleting(false); - setDeleteDoc(null); + const handleDeleteFromMenu = useCallback(async () => { + if (!deleteDoc) return; + setIsDeleting(true); + try { + const ok = await deleteDocument(deleteDoc.id); + if (!ok) toast.error("Failed to delete document"); + } catch (error: unknown) { + console.error("Error deleting document:", error); + const status = + (error as { response?: { status?: number } })?.response?.status ?? + (error as { status?: number })?.status; + if (status === 409) { + toast.error("Document is now being processed. Please try again later."); + } else { + toast.error("Failed to delete document"); } - }, - [deleteDoc, deleteDocument] - ); + } finally { + setIsDeleting(false); + setDeleteDoc(null); + } + }, [deleteDoc, deleteDocument]); const sorted = React.useMemo( () => sortDocuments(documents, sortKey, sortDesc), @@ -477,9 +466,7 @@ export function DocumentsTableShell({ - - Status - + Status @@ -560,25 +547,23 @@ export function DocumentsTableShell({

- canSelect && toggleOne(doc.id, !!v) - } + onCheckedChange={(v) => canSelect && toggleOne(doc.id, !!v)} disabled={!canSelect} aria-label={ - canSelect - ? "Select row" - : "Cannot select while processing" + canSelect ? "Select row" : "Cannot select while processing" } className={`border-foreground data-[state=checked]:bg-primary data-[state=checked]:border-primary ${!canSelect ? "opacity-40 cursor-not-allowed" : ""}`} /> @@ -594,10 +579,7 @@ export function DocumentsTableShell({ - {getDocumentTypeIcon( - doc.document_type, - "h-4 w-4" - )} + {getDocumentTypeIcon(doc.document_type, "h-4 w-4")} @@ -614,9 +596,7 @@ export function DocumentsTableShell({ })} - {hasMore && ( -
- )} + {hasMore &&
}
)}
@@ -670,7 +650,10 @@ export function DocumentsTableShell({
) : ( -
+
{sorted.map((doc, index) => { const isSelected = selectedIds.has(doc.id); const canSelect = isSelectable(doc); @@ -685,7 +668,11 @@ export function DocumentsTableShell({ - canSelect && toggleOne(doc.id, !!v) - } + onCheckedChange={(v) => canSelect && toggleOne(doc.id, !!v)} disabled={!canSelect} - aria-label={ - canSelect - ? "Select row" - : "Cannot select while processing" - } + aria-label={canSelect ? "Select row" : "Cannot select while processing"} className={`border-foreground data-[state=checked]:bg-primary data-[state=checked]:border-primary shrink-0 ${!canSelect ? "opacity-40 cursor-not-allowed" : ""}`} />
@@ -714,10 +695,7 @@ export function DocumentsTableShell({ - {getDocumentTypeIcon( - doc.document_type, - "h-4 w-4" - )} + {getDocumentTypeIcon(doc.document_type, "h-4 w-4")} @@ -731,9 +709,7 @@ export function DocumentsTableShell({ ); })} - {hasMore && ( -
- )} + {hasMore &&
}
)} @@ -761,8 +737,8 @@ export function DocumentsTableShell({ Delete document? - This action cannot be undone. This will permanently delete this document from - your search space. + This action cannot be undone. This will permanently delete this document from your + search space. diff --git a/surfsense_web/app/dashboard/[search_space_id]/layout.tsx b/surfsense_web/app/dashboard/[search_space_id]/layout.tsx index 1bf9609d5..12f3b3963 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/layout.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/layout.tsx @@ -12,9 +12,5 @@ export default function DashboardLayout({ }) { const { search_space_id } = use(params); - return ( - - {children} - - ); + return {children}; } diff --git a/surfsense_web/components/assistant-ui/connector-popup.tsx b/surfsense_web/components/assistant-ui/connector-popup.tsx index 81b4b7bd1..43f0d6e4b 100644 --- a/surfsense_web/components/assistant-ui/connector-popup.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup.tsx @@ -190,35 +190,33 @@ export const ConnectorIndicator: FC = () => { return ( handleOpenChange(true)} - > - {isLoading ? ( - - ) : ( - <> - - {activeConnectorsCount > 0 && ( - - {activeConnectorsCount > 99 ? "99+" : activeConnectorsCount} - - )} - - )} - + data-joyride="connector-icon" + tooltip={hasConnectors ? `Manage ${activeConnectorsCount} connectors` : "Connect your data"} + side="bottom" + className={cn( + "size-[34px] rounded-full p-1 flex items-center justify-center transition-colors relative", + "hover:bg-muted-foreground/15 dark:hover:bg-muted-foreground/30", + "outline-none focus:outline-none focus-visible:outline-none font-semibold text-xs", + "border-0 ring-0 focus:ring-0 shadow-none focus:shadow-none" + )} + aria-label={ + hasConnectors ? `View ${activeConnectorsCount} connectors` : "Add your first connector" + } + onClick={() => handleOpenChange(true)} + > + {isLoading ? ( + + ) : ( + <> + + {activeConnectorsCount > 0 && ( + + {activeConnectorsCount > 99 ? "99+" : activeConnectorsCount} + + )} + + )} + Manage Connectors diff --git a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx index ea081b5cd..048f0019f 100644 --- a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx +++ b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx @@ -316,7 +316,13 @@ export function LayoutDataProvider({ badge: announcementUnreadCount > 0 ? formatInboxCount(announcementUnreadCount) : undefined, }, ], - [pathname, isInboxSidebarOpen, isDocumentsSidebarOpen, totalUnreadCount, announcementUnreadCount] + [ + pathname, + isInboxSidebarOpen, + isDocumentsSidebarOpen, + totalUnreadCount, + announcementUnreadCount, + ] ); // Handlers diff --git a/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx index 0451f8824..734504255 100644 --- a/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx @@ -12,9 +12,7 @@ import type { DocumentTypeEnum } from "@/contracts/types/document.types"; import { useDocuments } from "@/hooks/use-documents"; import { documentsApiService } from "@/lib/apis/documents-api.service"; import { useMediaQuery } from "@/hooks/use-media-query"; -import { - DocumentsFilters, -} from "@/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsFilters"; +import { DocumentsFilters } from "@/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsFilters"; import { DocumentsTableShell, type SortKey, @@ -68,17 +66,19 @@ export function DocumentsSidebar({ open, onOpenChange }: DocumentsSidebarProps) // --- Search mode state --- const searchApiLoadedRef = useRef(0); - const [searchItems, setSearchItems] = useState>([]); + const [searchItems, setSearchItems] = useState< + Array<{ + id: number; + search_space_id: number; + document_type: string; + title: string; + created_by_id: string | null; + created_by_name: string | null; + created_by_email: string | null; + created_at: string; + status: { state: "ready" | "pending" | "processing" | "failed"; reason?: string }; + }> + >([]); const [searchLoadingMore, setSearchLoadingMore] = useState(false); const [searchInitialLoading, setSearchInitialLoading] = useState(false); const [searchHasMore, setSearchHasMore] = useState(false); diff --git a/surfsense_web/docker-entrypoint.js b/surfsense_web/docker-entrypoint.js index 7fe697e46..a4110657d 100644 --- a/surfsense_web/docker-entrypoint.js +++ b/surfsense_web/docker-entrypoint.js @@ -13,88 +13,74 @@ const fs = require("fs"); const path = require("path"); const replacements = [ - [ - "__NEXT_PUBLIC_FASTAPI_BACKEND_URL__", - process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL || "http://localhost:8000", - ], - [ - "__NEXT_PUBLIC_ELECTRIC_URL__", - process.env.NEXT_PUBLIC_ELECTRIC_URL || "http://localhost:5133", - ], - [ - "__NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE__", - process.env.NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE || "LOCAL", - ], - [ - "__NEXT_PUBLIC_ETL_SERVICE__", - process.env.NEXT_PUBLIC_ETL_SERVICE || "DOCLING", - ], - [ - "__NEXT_PUBLIC_DEPLOYMENT_MODE__", - process.env.NEXT_PUBLIC_DEPLOYMENT_MODE || "self-hosted", - ], - [ - "__NEXT_PUBLIC_ELECTRIC_AUTH_MODE__", - process.env.NEXT_PUBLIC_ELECTRIC_AUTH_MODE || "insecure", - ], + [ + "__NEXT_PUBLIC_FASTAPI_BACKEND_URL__", + process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL || "http://localhost:8000", + ], + ["__NEXT_PUBLIC_ELECTRIC_URL__", process.env.NEXT_PUBLIC_ELECTRIC_URL || "http://localhost:5133"], + [ + "__NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE__", + process.env.NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE || "LOCAL", + ], + ["__NEXT_PUBLIC_ETL_SERVICE__", process.env.NEXT_PUBLIC_ETL_SERVICE || "DOCLING"], + ["__NEXT_PUBLIC_DEPLOYMENT_MODE__", process.env.NEXT_PUBLIC_DEPLOYMENT_MODE || "self-hosted"], + ["__NEXT_PUBLIC_ELECTRIC_AUTH_MODE__", process.env.NEXT_PUBLIC_ELECTRIC_AUTH_MODE || "insecure"], ]; let filesProcessed = 0; let filesModified = 0; function walk(dir) { - let entries; - try { - entries = fs.readdirSync(dir, { withFileTypes: true }); - } catch { - return; - } - for (const entry of entries) { - const full = path.join(dir, entry.name); - if (entry.isDirectory()) { - walk(full); - } else if (entry.name.endsWith(".js")) { - filesProcessed++; - let content = fs.readFileSync(full, "utf8"); - let changed = false; - for (const [placeholder, value] of replacements) { - if (content.includes(placeholder)) { - content = content.replaceAll(placeholder, value); - changed = true; - } - } - if (changed) { - fs.writeFileSync(full, content); - filesModified++; - } - } - } + let entries; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + walk(full); + } else if (entry.name.endsWith(".js")) { + filesProcessed++; + let content = fs.readFileSync(full, "utf8"); + let changed = false; + for (const [placeholder, value] of replacements) { + if (content.includes(placeholder)) { + content = content.replaceAll(placeholder, value); + changed = true; + } + } + if (changed) { + fs.writeFileSync(full, content); + filesModified++; + } + } + } } console.log("[entrypoint] Replacing environment variable placeholders..."); for (const [placeholder, value] of replacements) { - console.log(` ${placeholder} -> ${value}`); + console.log(` ${placeholder} -> ${value}`); } walk(path.join(__dirname, ".next")); const serverJs = path.join(__dirname, "server.js"); if (fs.existsSync(serverJs)) { - let content = fs.readFileSync(serverJs, "utf8"); - let changed = false; - filesProcessed++; - for (const [placeholder, value] of replacements) { - if (content.includes(placeholder)) { - content = content.replaceAll(placeholder, value); - changed = true; - } - } - if (changed) { - fs.writeFileSync(serverJs, content); - filesModified++; - } + let content = fs.readFileSync(serverJs, "utf8"); + let changed = false; + filesProcessed++; + for (const [placeholder, value] of replacements) { + if (content.includes(placeholder)) { + content = content.replaceAll(placeholder, value); + changed = true; + } + } + if (changed) { + fs.writeFileSync(serverJs, content); + filesModified++; + } } -console.log( - `[entrypoint] Done. Scanned ${filesProcessed} files, modified ${filesModified}.` -); +console.log(`[entrypoint] Done. Scanned ${filesProcessed} files, modified ${filesModified}.`); diff --git a/surfsense_web/hooks/use-documents.ts b/surfsense_web/hooks/use-documents.ts index a8e38a62a..7d55620f9 100644 --- a/surfsense_web/hooks/use-documents.ts +++ b/surfsense_web/hooks/use-documents.ts @@ -1,11 +1,7 @@ "use client"; import { useCallback, useEffect, useRef, useState } from "react"; -import type { - DocumentSortBy, - DocumentTypeEnum, - SortOrder, -} from "@/contracts/types/document.types"; +import type { DocumentSortBy, DocumentTypeEnum, SortOrder } from "@/contracts/types/document.types"; import { documentsApiService } from "@/lib/apis/documents-api.service"; import type { SyncHandle } from "@/lib/electric/client"; import { useElectricClient } from "@/lib/electric/context"; @@ -77,7 +73,9 @@ export function useDocuments( const apiLoadedCountRef = useRef(0); const initialLoadDoneRef = useRef(false); - const prevParamsRef = useRef<{ sortBy: string; sortOrder: string; typeFilterKey: string } | null>(null); + const prevParamsRef = useRef<{ sortBy: string; sortOrder: string; typeFilterKey: string } | null>( + null + ); // Snapshot of all doc IDs from Electric's first callback after initial load. // Anything appearing in subsequent callbacks NOT in this set is genuinely new. const electricBaselineIdsRef = useRef | null>(null); @@ -211,9 +209,7 @@ export function useDocuments( } catch (err) { if (cancelled) return; console.error("[useDocuments] Initial load failed:", err); - setError( - err instanceof Error ? err : new Error("Failed to load documents") - ); + setError(err instanceof Error ? err : new Error("Failed to load documents")); } finally { if (!cancelled) setLoading(false); } @@ -302,9 +298,7 @@ export function useDocuments( WHERE search_space_id = $1 ORDER BY created_at DESC`; - const liveQuery = await db.live.query(query, [ - spaceId, - ]); + const liveQuery = await db.live.query(query, [spaceId]); if (!mounted) { liveQuery.unsubscribe?.(); @@ -319,11 +313,8 @@ export function useDocuments( const unknownUserIds = validItems .filter( - ( - doc - ): doc is DocumentElectric & { created_by_id: string } => - doc.created_by_id !== null && - !userCacheRef.current.has(doc.created_by_id) + (doc): doc is DocumentElectric & { created_by_id: string } => + doc.created_by_id !== null && !userCacheRef.current.has(doc.created_by_id) ) .map((doc) => doc.created_by_id); @@ -343,14 +334,10 @@ export function useDocuments( prev.map((doc) => ({ ...doc, created_by_name: doc.created_by_id - ? (userCacheRef.current.get( - doc.created_by_id - ) ?? null) + ? (userCacheRef.current.get(doc.created_by_id) ?? null) : null, created_by_email: doc.created_by_id - ? (emailCacheRef.current.get( - doc.created_by_id - ) ?? null) + ? (emailCacheRef.current.get(doc.created_by_id) ?? null) : null, })) ); @@ -389,9 +376,7 @@ export function useDocuments( // Update existing docs (status changes, title edits) let updated = prev.map((doc) => { if (liveIds.has(doc.id)) { - const liveItem = validItems.find( - (v) => v.id === doc.id - ); + const liveItem = validItems.find((v) => v.id === doc.id); if (liveItem) { return electricToDisplayDoc(liveItem); } @@ -415,8 +400,7 @@ export function useDocuments( if (isFullySynced && validItems.length > 0) { const counts: Record = {}; for (const item of validItems) { - counts[item.document_type] = - (counts[item.document_type] || 0) + 1; + counts[item.document_type] = (counts[item.document_type] || 0) + 1; } setTypeCounts(counts); setTotal(validItems.length); @@ -456,10 +440,7 @@ export function useDocuments( const prevSearchSpaceIdRef = useRef(null); useEffect(() => { - if ( - prevSearchSpaceIdRef.current !== null && - prevSearchSpaceIdRef.current !== searchSpaceId - ) { + if (prevSearchSpaceIdRef.current !== null && prevSearchSpaceIdRef.current !== searchSpaceId) { setDocuments([]); setTypeCounts({}); setTotal(0);