Merge pull request #258 from Utkarsh-Patel-13/fix/UI-Improvements
Some checks are pending
pre-commit / pre-commit (push) Waiting to run

feat: UI/UX improvements with document page refactor, enhanced sidebar, and navigation updates
This commit is contained in:
Rohan Verma 2025-08-11 11:17:10 -07:00 committed by GitHub
commit 356bbb86f5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 2244 additions and 1655 deletions

View file

@ -6,7 +6,13 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from app.db import Chat, SearchSpace, User, get_async_session
from app.schemas import AISDKChatRequest, ChatCreate, ChatRead, ChatUpdate
from app.schemas import (
AISDKChatRequest,
ChatCreate,
ChatRead,
ChatReadWithoutMessages,
ChatUpdate,
)
from app.tasks.stream_connector_search_results import stream_connector_search_results
from app.users import current_active_user
from app.utils.check_ownership import check_ownership
@ -112,7 +118,7 @@ async def create_chat(
) from None
@router.get("/chats/", response_model=list[ChatRead])
@router.get("/chats/", response_model=list[ChatReadWithoutMessages])
async def read_chats(
skip: int = 0,
limit: int = 100,
@ -121,14 +127,26 @@ async def read_chats(
user: User = Depends(current_active_user),
):
try:
query = select(Chat).join(SearchSpace).filter(SearchSpace.user_id == user.id)
# Select specific fields excluding messages
query = (
select(
Chat.id,
Chat.type,
Chat.title,
Chat.initial_connectors,
Chat.search_space_id,
Chat.created_at,
)
.join(SearchSpace)
.filter(SearchSpace.user_id == user.id)
)
# Filter by search_space_id if provided
if search_space_id is not None:
query = query.filter(Chat.search_space_id == search_space_id)
result = await session.execute(query.offset(skip).limit(limit))
return result.scalars().all()
return result.all()
except OperationalError:
raise HTTPException(
status_code=503, detail="Database operation failed. Please try again later."

View file

@ -1,5 +1,12 @@
from .base import IDModel, TimestampModel
from .chats import AISDKChatRequest, ChatBase, ChatCreate, ChatRead, ChatUpdate
from .chats import (
AISDKChatRequest,
ChatBase,
ChatCreate,
ChatRead,
ChatReadWithoutMessages,
ChatUpdate,
)
from .chunks import ChunkBase, ChunkCreate, ChunkRead, ChunkUpdate
from .documents import (
DocumentBase,
@ -37,6 +44,7 @@ __all__ = [
"ChatBase",
"ChatCreate",
"ChatRead",
"ChatReadWithoutMessages",
"ChatUpdate",
"ChunkBase",
"ChunkCreate",

View file

@ -15,6 +15,12 @@ class ChatBase(BaseModel):
search_space_id: int
class ChatBaseWithoutMessages(BaseModel):
type: ChatType
title: str
search_space_id: int
class ClientAttachment(BaseModel):
name: str
content_type: str
@ -50,3 +56,7 @@ class ChatUpdate(ChatBase):
class ChatRead(ChatBase, IDModel, TimestampModel):
model_config = ConfigDict(from_attributes=True)
class ChatReadWithoutMessages(ChatBaseWithoutMessages, IDModel, TimestampModel):
model_config = ConfigDict(from_attributes=True)

View file

@ -19,14 +19,7 @@ import { useEffect, useState } from "react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Card, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import {
Dialog,
DialogContent,
@ -61,24 +54,16 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { cn } from "@/lib/utils";
interface Chat {
created_at: string;
id: number;
type: string;
title: string;
messages: ChatMessage[];
search_space_id: number;
}
interface ChatMessage {
id: string;
createdAt: string;
role: string;
content: string;
parts?: any;
}
interface ChatsPageClientProps {
searchSpaceId: string;
}
@ -580,12 +565,12 @@ export default function ChatsPageClient({ searchSpaceId }: ChatsPageClientProps)
animate="animate"
exit="exit"
transition={{ duration: 0.2, delay: index * 0.05 }}
className={`overflow-hidden hover:shadow-md transition-shadow
${
selectionMode && selectedChats.includes(chat.id)
? "ring-2 ring-primary ring-offset-2"
: ""
}`}
className={cn(
"overflow-hidden hover:shadow-md transition-shadow",
selectionMode && selectedChats.includes(chat.id)
? "ring-2 ring-primary ring-offset-2"
: ""
)}
onClick={(e) => {
if (!selectionMode) return;
// Ignore clicks coming from interactive elements
@ -672,24 +657,21 @@ export default function ChatsPageClient({ searchSpaceId }: ChatsPageClientProps)
)}
</div>
</CardHeader>
<CardContent>
<div className="text-sm text-muted-foreground line-clamp-3">
{chat.messages && chat.messages.length > 0
? typeof chat.messages[0] === "string"
? chat.messages[0]
: chat.messages[0]?.content || "No message content"
: "No messages in this chat."}
</div>
</CardContent>
<CardFooter className="flex justify-between pt-2">
<div className="flex items-center text-xs text-muted-foreground">
<MessageCircleMore className="mr-1 h-3.5 w-3.5" />
<span>{chat.messages?.length || 0} messages</span>
</div>
<CardFooter className="flex items-center justify-between gap-2 w-full">
<Badge variant="secondary" className="text-xs">
<Tag className="mr-1 h-3 w-3" />
{chat.type || "Unknown"}
</Badge>
<Button
size="sm"
onClick={() =>
router.push(`/dashboard/${chat.search_space_id}/researcher/${chat.id}`)
}
>
<MessageCircleMore className="h-4 w-4" />
<span>View Chat</span>
</Button>
</CardFooter>
</MotionCard>
))}

View file

@ -1,6 +1,8 @@
"use client";
import type React from "react";
import { useState } from "react";
import { DashboardBreadcrumb } from "@/components/dashboard-breadcrumb";
import { AppSidebarProvider } from "@/components/sidebar/AppSidebarProvider";
import { ThemeTogglerComponent } from "@/components/theme/theme-toggle";
import { Separator } from "@/components/ui/separator";
@ -17,8 +19,18 @@ export function DashboardClientLayout({
navSecondary: any[];
navMain: any[];
}) {
const [open, setOpen] = useState<boolean>(() => {
try {
const match = document.cookie.match(/(?:^|; )sidebar_state=([^;]+)/);
if (match) return match[1] === "true";
} catch {
// ignore
}
return true;
});
return (
<SidebarProvider>
<SidebarProvider open={open} onOpenChange={setOpen}>
{/* Use AppSidebarProvider which fetches user, search space, and recent chats */}
<AppSidebarProvider
searchSpaceId={searchSpaceId}
@ -26,10 +38,13 @@ export function DashboardClientLayout({
navMain={navMain}
/>
<SidebarInset>
<header className="flex h-16 shrink-0 items-center gap-2">
<div className="flex items-center gap-2 px-4">
<SidebarTrigger className="-ml-1" />
<Separator orientation="vertical" className="h-6" />
<header className="sticky top-0 z-50 flex h-16 shrink-0 items-center gap-2 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 border-b">
<div className="flex items-center justify-between w-full gap-2 px-4">
<div className="flex items-center gap-2">
<SidebarTrigger className="-ml-1" />
<Separator orientation="vertical" className="h-6" />
<DashboardBreadcrumb />
</div>
<ThemeTogglerComponent />
</div>
</header>

View file

@ -0,0 +1,60 @@
"use client";
import {
IconBook,
IconBrandDiscord,
IconBrandGithub,
IconBrandNotion,
IconBrandSlack,
IconBrandYoutube,
IconCalendar,
IconChecklist,
IconLayoutKanban,
IconTicket,
} from "@tabler/icons-react";
import { File, Globe, Webhook } from "lucide-react";
import type React from "react";
type IconComponent = React.ComponentType<{ size?: number; className?: string }>;
const documentTypeIcons: Record<string, IconComponent> = {
EXTENSION: Webhook,
CRAWLED_URL: Globe,
SLACK_CONNECTOR: IconBrandSlack,
NOTION_CONNECTOR: IconBrandNotion,
FILE: File,
YOUTUBE_VIDEO: IconBrandYoutube,
GITHUB_CONNECTOR: IconBrandGithub,
LINEAR_CONNECTOR: IconLayoutKanban,
JIRA_CONNECTOR: IconTicket,
DISCORD_CONNECTOR: IconBrandDiscord,
CONFLUENCE_CONNECTOR: IconBook,
CLICKUP_CONNECTOR: IconChecklist,
GOOGLE_CALENDAR_CONNECTOR: IconCalendar,
};
export function getDocumentTypeIcon(type: string): IconComponent {
return documentTypeIcons[type] ?? File;
}
export function getDocumentTypeLabel(type: string): string {
return type
.split("_")
.map((word) => word.charAt(0) + word.slice(1).toLowerCase())
.join(" ");
}
export function DocumentTypeChip({ type, className }: { type: string; className?: string }) {
const Icon = getDocumentTypeIcon(type);
return (
<span
className={
"inline-flex items-center gap-1.5 rounded-full border border-border bg-primary/5 px-2 py-1 text-xs font-medium " +
(className ?? "")
}
>
<Icon size={14} className="text-primary" />
{getDocumentTypeLabel(type)}
</span>
);
}

View file

@ -0,0 +1,276 @@
"use client";
import { AnimatePresence, motion, type Variants } from "framer-motion";
import { CircleAlert, CircleX, Columns3, Filter, ListFilter, Trash } from "lucide-react";
import React, { useMemo, useRef } from "react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import type { ColumnVisibility, Document } from "./types";
const fadeInScale: Variants = {
hidden: { opacity: 0, scale: 0.95 },
visible: { opacity: 1, scale: 1, transition: { type: "spring", stiffness: 300, damping: 30 } },
exit: { opacity: 0, scale: 0.95, transition: { duration: 0.15 } },
};
export function DocumentsFilters({
allDocuments,
visibleDocuments: _visibleDocuments,
selectedIds,
onSearch,
searchValue,
onBulkDelete,
onToggleType,
activeTypes,
columnVisibility,
onToggleColumn,
}: {
allDocuments: Document[];
visibleDocuments: Document[];
selectedIds: Set<number>;
onSearch: (v: string) => void;
searchValue: string;
onBulkDelete: () => Promise<void>;
onToggleType: (type: string, checked: boolean) => void;
activeTypes: string[];
columnVisibility: ColumnVisibility;
onToggleColumn: (id: keyof ColumnVisibility, checked: boolean) => void;
}) {
const id = React.useId();
const inputRef = useRef<HTMLInputElement>(null);
const uniqueTypes = useMemo(() => {
const set = new Set<string>();
for (const d of allDocuments) set.add(d.document_type);
return Array.from(set).sort();
}, [allDocuments]);
const typeCounts = useMemo(() => {
const map = new Map<string, number>();
for (const d of allDocuments) map.set(d.document_type, (map.get(d.document_type) ?? 0) + 1);
return map;
}, [allDocuments]);
return (
<motion.div
className="flex flex-wrap items-center justify-between gap-3"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ type: "spring", stiffness: 300, damping: 30, delay: 0.1 }}
>
<div className="flex items-center gap-3">
<motion.div
className="relative"
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ type: "spring", stiffness: 300, damping: 30 }}
>
<Input
id={`${id}-input`}
ref={inputRef}
className="peer min-w-60 ps-9"
value={searchValue}
onChange={(e) => onSearch(e.target.value)}
placeholder="Filter by title..."
type="text"
aria-label="Filter by title"
/>
<motion.div
className="pointer-events-none absolute inset-y-0 start-0 flex items-center justify-center ps-3 text-muted-foreground/80 peer-disabled:opacity-50"
initial={{ scale: 0.8 }}
animate={{ scale: 1 }}
transition={{ delay: 0.1 }}
>
<ListFilter size={16} strokeWidth={2} aria-hidden="true" />
</motion.div>
{Boolean(searchValue) && (
<motion.button
className="absolute inset-y-0 end-0 flex h-full w-9 items-center justify-center rounded-e-lg text-muted-foreground/80 outline-offset-2 transition-colors hover:text-foreground focus:z-10 focus-visible:outline focus-visible:outline-ring/70"
aria-label="Clear filter"
onClick={() => {
onSearch("");
inputRef.current?.focus();
}}
initial={{ opacity: 0, rotate: -90 }}
animate={{ opacity: 1, rotate: 0 }}
exit={{ opacity: 0, rotate: 90 }}
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
>
<CircleX size={16} strokeWidth={2} aria-hidden="true" />
</motion.button>
)}
</motion.div>
<Popover>
<PopoverTrigger asChild>
<motion.div
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
transition={{ type: "spring", stiffness: 400, damping: 17 }}
>
<Button variant="outline">
<Filter
className="-ms-1 me-2 opacity-60"
size={16}
strokeWidth={2}
aria-hidden="true"
/>
Type
{activeTypes.length > 0 && (
<motion.span
initial={{ scale: 0.8 }}
animate={{ scale: 1 }}
className="-me-1 ms-3 inline-flex h-5 max-h-full items-center rounded border border-border bg-background px-1 text-[0.625rem] font-medium text-muted-foreground/70"
>
{activeTypes.length}
</motion.span>
)}
</Button>
</motion.div>
</PopoverTrigger>
<PopoverContent className="min-w-36 p-3" align="start">
<motion.div initial="hidden" animate="visible" exit="exit" variants={fadeInScale}>
<div className="space-y-3">
<div className="text-xs font-medium text-muted-foreground">Filters</div>
<div className="space-y-3">
<AnimatePresence>
{uniqueTypes.map((value, i) => (
<motion.div
key={value}
className="flex items-center gap-2"
initial={{ opacity: 0, y: -5 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 5 }}
transition={{ delay: i * 0.05 }}
>
<Checkbox
id={`${id}-${i}`}
checked={activeTypes.includes(value)}
onCheckedChange={(checked: boolean) => onToggleType(value, !!checked)}
/>
<Label
htmlFor={`${id}-${i}`}
className="flex grow justify-between gap-2 font-normal"
>
{value}{" "}
<span className="ms-2 text-xs text-muted-foreground">
{typeCounts.get(value)}
</span>
</Label>
</motion.div>
))}
</AnimatePresence>
</div>
</div>
</motion.div>
</PopoverContent>
</Popover>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<motion.div
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
transition={{ type: "spring", stiffness: 400, damping: 17 }}
>
<Button variant="outline">
<Columns3
className="-ms-1 me-2 opacity-60"
size={16}
strokeWidth={2}
aria-hidden="true"
/>
View
</Button>
</motion.div>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Toggle columns</DropdownMenuLabel>
{(
[
["title", "Title"],
["document_type", "Type"],
["content", "Content"],
["created_at", "Created At"],
] as Array<[keyof ColumnVisibility, string]>
).map(([key, label]) => (
<DropdownMenuCheckboxItem
key={key}
className="capitalize"
checked={columnVisibility[key]}
onCheckedChange={(v) => onToggleColumn(key, !!v)}
onSelect={(e) => e.preventDefault()}
>
{label}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="flex items-center gap-3">
{selectedIds.size > 0 && (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button className="ml-auto" variant="outline">
<Trash
className="-ms-1 me-2 opacity-60"
size={16}
strokeWidth={2}
aria-hidden="true"
/>
Delete
<span className="-me-1 ms-3 inline-flex h-5 max-h-full items-center rounded border border-border bg-background px-1 text-[0.625rem] font-medium text-muted-foreground/70">
{selectedIds.size}
</span>
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<div className="flex flex-col gap-2 max-sm:items-center sm:flex-row sm:gap-4">
<div
className="flex size-9 shrink-0 items-center justify-center rounded-full border border-border"
aria-hidden="true"
>
<CircleAlert className="opacity-80" size={16} strokeWidth={2} />
</div>
<AlertDialogHeader>
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete {selectedIds.size}{" "}
selected {selectedIds.size === 1 ? "row" : "rows"}.
</AlertDialogDescription>
</AlertDialogHeader>
</div>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={onBulkDelete}>Delete</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</div>
</motion.div>
);
}

View file

@ -0,0 +1,355 @@
"use client";
import { motion } from "framer-motion";
import { ChevronDown, ChevronUp, FileX } from "lucide-react";
import React from "react";
import { DocumentViewer } from "@/components/document-viewer";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { DocumentTypeChip, getDocumentTypeIcon } from "./DocumentTypeIcon";
import { RowActions } from "./RowActions";
import type { ColumnVisibility, Document } from "./types";
export type SortKey = keyof Pick<Document, "title" | "document_type" | "created_at">;
function sortDocuments(docs: Document[], key: SortKey, desc: boolean): Document[] {
const sorted = [...docs].sort((a, b) => {
const av = a[key] ?? "";
const bv = b[key] ?? "";
if (key === "created_at")
return new Date(av as string).getTime() - new Date(bv as string).getTime();
return String(av).localeCompare(String(bv));
});
return desc ? sorted.reverse() : sorted;
}
function truncate(text: string, len = 150): string {
const plain = text
.replace(/[#*_`>\-[\]()]+/g, " ")
.replace(/\s+/g, " ")
.trim();
if (plain.length <= len) return plain;
return `${plain.slice(0, len)}...`;
}
export function DocumentsTableShell({
documents,
loading,
error,
onRefresh,
selectedIds,
setSelectedIds,
columnVisibility,
deleteDocument,
sortKey,
sortDesc,
onSortChange,
}: {
documents: Document[];
loading: boolean;
error: boolean;
onRefresh: () => Promise<void>;
selectedIds: Set<number>;
setSelectedIds: (update: Set<number>) => void;
columnVisibility: ColumnVisibility;
deleteDocument: (id: number) => Promise<boolean>;
sortKey: SortKey;
sortDesc: boolean;
onSortChange: (key: SortKey) => void;
}) {
const sorted = React.useMemo(
() => sortDocuments(documents, sortKey, sortDesc),
[documents, sortKey, sortDesc]
);
const allSelectedOnPage = sorted.length > 0 && sorted.every((d) => selectedIds.has(d.id));
const someSelectedOnPage = sorted.some((d) => selectedIds.has(d.id)) && !allSelectedOnPage;
const toggleAll = (checked: boolean) => {
const next = new Set(selectedIds);
if (checked) sorted.forEach((d) => next.add(d.id));
else sorted.forEach((d) => next.delete(d.id));
setSelectedIds(next);
};
const toggleOne = (id: number, checked: boolean) => {
const next = new Set(selectedIds);
if (checked) next.add(id);
else next.delete(id);
setSelectedIds(next);
};
const onSortHeader = (key: SortKey) => onSortChange(key);
return (
<motion.div
className="rounded-md border mt-6 overflow-hidden"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ type: "spring", stiffness: 300, damping: 30, delay: 0.2 }}
>
{loading ? (
<div className="flex h-[400px] w-full items-center justify-center">
<div className="flex flex-col items-center gap-2">
<div className="h-8 w-8 animate-spin rounded-full border-b-2 border-primary"></div>
<p className="text-sm text-muted-foreground">Loading documents...</p>
</div>
</div>
) : error ? (
<div className="flex h-[400px] w-full items-center justify-center">
<div className="flex flex-col items-center gap-2">
<p className="text-sm text-destructive">Error loading documents</p>
<Button variant="outline" size="sm" onClick={() => onRefresh()} className="mt-2">
Retry
</Button>
</div>
</div>
) : sorted.length === 0 ? (
<div className="flex h-[400px] w-full items-center justify-center">
<div className="flex flex-col items-center gap-2">
<FileX className="h-8 w-8 text-muted-foreground" />
<p className="text-sm text-muted-foreground">No documents found</p>
</div>
</div>
) : (
<>
<div className="hidden md:block max-h-[60vh] overflow-auto">
<Table className="table-fixed w-full">
<TableHeader className="sticky top-0 bg-background z-10">
<TableRow className="hover:bg-transparent">
<TableHead style={{ width: 28 }}>
<Checkbox
checked={allSelectedOnPage || (someSelectedOnPage && "indeterminate")}
onCheckedChange={(v) => toggleAll(!!v)}
aria-label="Select all"
/>
</TableHead>
{columnVisibility.title && (
<TableHead style={{ width: 250 }}>
<Button
variant="ghost"
className="flex h-full w-full cursor-pointer select-none items-center justify-between gap-2"
onClick={() => onSortHeader("title")}
>
Title
{sortKey === "title" ? (
sortDesc ? (
<ChevronDown className="shrink-0 opacity-60" size={16} />
) : (
<ChevronUp className="shrink-0 opacity-60" size={16} />
)
) : null}
</Button>
</TableHead>
)}
{columnVisibility.document_type && (
<TableHead style={{ width: 180 }}>
<Button
variant="ghost"
className="flex h-full w-full cursor-pointer select-none items-center justify-between gap-2"
onClick={() => onSortHeader("document_type")}
>
Type
{sortKey === "document_type" ? (
sortDesc ? (
<ChevronDown className="shrink-0 opacity-60" size={16} />
) : (
<ChevronUp className="shrink-0 opacity-60" size={16} />
)
) : null}
</Button>
</TableHead>
)}
{columnVisibility.content && (
<TableHead style={{ width: 300 }}>Content Summary</TableHead>
)}
{columnVisibility.created_at && (
<TableHead style={{ width: 120 }}>
<Button
variant="ghost"
className="flex h-full w-full cursor-pointer select-none items-center justify-between gap-2"
onClick={() => onSortHeader("created_at")}
>
Created At
{sortKey === "created_at" ? (
sortDesc ? (
<ChevronDown className="shrink-0 opacity-60" size={16} />
) : (
<ChevronUp className="shrink-0 opacity-60" size={16} />
)
) : null}
</Button>
</TableHead>
)}
<TableHead style={{ width: 60 }}>
<span className="sr-only">Actions</span>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sorted.map((doc, index) => {
const Icon = getDocumentTypeIcon(doc.document_type);
const title = doc.title;
const truncatedTitle = title.length > 30 ? `${title.slice(0, 30)}...` : title;
return (
<motion.tr
key={doc.id}
initial={{ opacity: 0, y: 10 }}
animate={{
opacity: 1,
y: 0,
transition: {
type: "spring",
stiffness: 300,
damping: 30,
delay: index * 0.03,
},
}}
exit={{ opacity: 0, y: -10 }}
className="border-b transition-colors hover:bg-muted/50"
>
<TableCell className="px-4 py-3">
<Checkbox
checked={selectedIds.has(doc.id)}
onCheckedChange={(v) => toggleOne(doc.id, !!v)}
aria-label="Select row"
/>
</TableCell>
{columnVisibility.title && (
<TableCell className="px-4 py-3">
<motion.div
className="flex items-center gap-2 font-medium"
whileHover={{ scale: 1.02 }}
transition={{ type: "spring", stiffness: 300 }}
style={{ display: "flex" }}
>
<Tooltip>
<TooltipTrigger asChild>
<span className="flex items-center gap-2">
<Icon size={16} className="text-muted-foreground shrink-0" />
<span>{truncatedTitle}</span>
</span>
</TooltipTrigger>
<TooltipContent>
<p>{title}</p>
</TooltipContent>
</Tooltip>
</motion.div>
</TableCell>
)}
{columnVisibility.document_type && (
<TableCell className="px-4 py-3">
<div className="flex items-center gap-2">
<DocumentTypeChip type={doc.document_type} />
</div>
</TableCell>
)}
{columnVisibility.content && (
<TableCell className="px-4 py-3">
<div className="flex flex-col gap-2">
<div className="max-w-[300px] max-h-[60px] overflow-hidden text-sm text-muted-foreground">
{truncate(doc.content)}
</div>
<DocumentViewer
title={doc.title}
content={doc.content}
trigger={
<Button variant="ghost" size="sm" className="w-fit text-xs">
View Full Content
</Button>
}
/>
</div>
</TableCell>
)}
{columnVisibility.created_at && (
<TableCell className="px-4 py-3">
{new Date(doc.created_at).toLocaleDateString()}
</TableCell>
)}
<TableCell className="px-4 py-3">
<RowActions
document={doc}
deleteDocument={deleteDocument}
refreshDocuments={async () => {
await onRefresh();
}}
/>
</TableCell>
</motion.tr>
);
})}
</TableBody>
</Table>
</div>
<div className="md:hidden divide-y">
{sorted.map((doc) => {
const Icon = getDocumentTypeIcon(doc.document_type);
return (
<div key={doc.id} className="p-3">
<div className="flex items-start gap-3">
<Checkbox
checked={selectedIds.has(doc.id)}
onCheckedChange={(v) => toggleOne(doc.id, !!v)}
aria-label="Select row"
/>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<Icon size={16} className="text-muted-foreground shrink-0" />
<div className="font-medium truncate">{doc.title}</div>
</div>
<RowActions
document={doc}
deleteDocument={deleteDocument}
refreshDocuments={async () => {
await onRefresh();
}}
/>
</div>
<div className="mt-1 flex flex-wrap items-center gap-2">
<DocumentTypeChip type={doc.document_type} />
<span className="text-xs text-muted-foreground">
{new Date(doc.created_at).toLocaleDateString()}
</span>
</div>
{columnVisibility.content && (
<div className="mt-2 text-sm text-muted-foreground">
{truncate(doc.content)}
<div className="mt-1">
<DocumentViewer
title={doc.title}
content={doc.content}
trigger={
<Button
variant="ghost"
size="sm"
className="w-fit text-xs p-0 h-auto"
>
View Full Content
</Button>
}
/>
</div>
</div>
)}
</div>
</div>
</div>
);
})}
</div>
</>
)}
</motion.div>
);
}

View file

@ -0,0 +1,163 @@
"use client";
import { motion } from "framer-motion";
import { ChevronFirst, ChevronLast, ChevronLeft, ChevronRight } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Pagination, PaginationContent, PaginationItem } from "@/components/ui/pagination";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
export function PaginationControls({
pageIndex,
pageSize,
total,
onPageSizeChange,
onFirst,
onPrev,
onNext,
onLast,
canPrev,
canNext,
id,
}: {
pageIndex: number;
pageSize: number;
total: number;
onPageSizeChange: (size: number) => void;
onFirst: () => void;
onPrev: () => void;
onNext: () => void;
onLast: () => void;
canPrev: boolean;
canNext: boolean;
id: string;
}) {
const start = total === 0 ? 0 : pageIndex * pageSize + 1;
const end = Math.min((pageIndex + 1) * pageSize, total);
return (
<div className="flex items-center justify-between gap-8 mt-6">
<motion.div
className="flex items-center gap-3"
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ type: "spring", stiffness: 300, damping: 30 }}
>
<Label htmlFor={id} className="max-sm:sr-only">
Rows per page
</Label>
<Select value={String(pageSize)} onValueChange={(v) => onPageSizeChange(Number(v))}>
<SelectTrigger id={id} className="w-fit whitespace-nowrap">
<SelectValue placeholder="Select number of results" />
</SelectTrigger>
<SelectContent>
{[5, 10, 25, 50].map((s) => (
<SelectItem key={s} value={String(s)}>
{s}
</SelectItem>
))}
</SelectContent>
</Select>
</motion.div>
<motion.div
className="flex grow justify-end whitespace-nowrap text-sm text-muted-foreground"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.2 }}
>
<p className="whitespace-nowrap text-sm text-muted-foreground" aria-live="polite">
<span className="text-foreground">
{start}-{end}
</span>{" "}
of <span className="text-foreground">{total}</span>
</p>
</motion.div>
<div>
<Pagination>
<PaginationContent>
<PaginationItem>
<motion.div
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
transition={{ type: "spring", stiffness: 400, damping: 17 }}
>
<Button
size="icon"
variant="outline"
className="disabled:pointer-events-none disabled:opacity-50"
onClick={onFirst}
disabled={!canPrev}
aria-label="Go to first page"
>
<ChevronFirst size={16} strokeWidth={2} aria-hidden="true" />
</Button>
</motion.div>
</PaginationItem>
<PaginationItem>
<motion.div
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
transition={{ type: "spring", stiffness: 400, damping: 17 }}
>
<Button
size="icon"
variant="outline"
className="disabled:pointer-events-none disabled:opacity-50"
onClick={onPrev}
disabled={!canPrev}
aria-label="Go to previous page"
>
<ChevronLeft size={16} strokeWidth={2} aria-hidden="true" />
</Button>
</motion.div>
</PaginationItem>
<PaginationItem>
<motion.div
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
transition={{ type: "spring", stiffness: 400, damping: 17 }}
>
<Button
size="icon"
variant="outline"
className="disabled:pointer-events-none disabled:opacity-50"
onClick={onNext}
disabled={!canNext}
aria-label="Go to next page"
>
<ChevronRight size={16} strokeWidth={2} aria-hidden="true" />
</Button>
</motion.div>
</PaginationItem>
<PaginationItem>
<motion.div
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
transition={{ type: "spring", stiffness: 400, damping: 17 }}
>
<Button
size="icon"
variant="outline"
className="disabled:pointer-events-none disabled:opacity-50"
onClick={onLast}
disabled={!canNext}
aria-label="Go to last page"
>
<ChevronLast size={16} strokeWidth={2} aria-hidden="true" />
</Button>
</motion.div>
</PaginationItem>
</PaginationContent>
</Pagination>
</div>
</div>
);
}

View file

@ -0,0 +1,109 @@
"use client";
import { MoreHorizontal } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { JsonMetadataViewer } from "@/components/json-metadata-viewer";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
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 type { Document } from "./types";
export function RowActions({
document,
deleteDocument,
refreshDocuments,
}: {
document: Document;
deleteDocument: (id: number) => Promise<boolean>;
refreshDocuments: () => Promise<void>;
}) {
const [isOpen, setIsOpen] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const handleDelete = async () => {
setIsDeleting(true);
try {
const ok = await deleteDocument(document.id);
if (ok) toast.success("Document deleted successfully");
else toast.error("Failed to delete document");
await refreshDocuments();
} catch (error) {
console.error("Error deleting document:", error);
toast.error("Failed to delete document");
} finally {
setIsDeleting(false);
setIsOpen(false);
}
};
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>
);
}

View file

@ -0,0 +1,18 @@
export type DocumentType = string;
export type Document = {
id: number;
title: string;
document_type: DocumentType;
document_metadata: any;
content: string;
created_at: string;
search_space_id: number;
};
export type ColumnVisibility = {
title: boolean;
document_type: boolean;
content: boolean;
created_at: boolean;
};

View file

@ -1,12 +1,17 @@
"use client";
import { AnimatePresence, motion } from "framer-motion";
import { Calendar, CheckCircle2, FileType, Tag, Upload, X } from "lucide-react";
import { CheckCircle2, FileType, Info, Tag, Upload, X } from "lucide-react";
import { useParams, useRouter } from "next/navigation";
import { useCallback, useRef, useState } from "react";
import { useCallback, useState } from "react";
import { useDropzone } from "react-dropzone";
import { toast } from "sonner";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
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";
// Grid pattern component inspired by Aceternity UI
function GridPattern() {
@ -34,14 +39,13 @@ function GridPattern() {
}
export default function FileUploader() {
// Use the useParams hook to get the params
const params = useParams();
const search_space_id = params.search_space_id as string;
const [files, setFiles] = useState<File[]>([]);
const [isUploading, setIsUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
const router = useRouter();
const fileInputRef = useRef<HTMLInputElement>(null);
// Audio files are always supported (using whisper)
const audioFileTypes = {
@ -204,7 +208,6 @@ export default function FileUploader() {
};
const acceptedFileTypes = getAcceptedFileTypes();
const supportedExtensions = Array.from(new Set(Object.values(acceptedFileTypes).flat())).sort();
const onDrop = useCallback((acceptedFiles: File[]) => {
@ -215,12 +218,10 @@ export default function FileUploader() {
onDrop,
accept: acceptedFileTypes,
maxSize: 50 * 1024 * 1024, // 50MB
noClick: false, // Ensure clicking is enabled
noKeyboard: false, // Ensure keyboard navigation is enabled
});
const handleClick = () => {
fileInputRef.current?.click();
};
const removeFile = (index: number) => {
setFiles((prevFiles) => prevFiles.filter((_, i) => i !== index));
};
@ -235,6 +236,7 @@ export default function FileUploader() {
const handleUpload = async () => {
setIsUploading(true);
setUploadProgress(0);
const formData = new FormData();
files.forEach((file) => {
@ -244,12 +246,16 @@ export default function FileUploader() {
formData.append("search_space_id", search_space_id);
try {
// toast("File Upload", {
// description: "Files Uploading Initiated",
// })
// Simulate progress for better UX
const progressInterval = setInterval(() => {
setUploadProgress((prev) => {
if (prev >= 90) return prev;
return prev + Math.random() * 10;
});
}, 200);
const response = await fetch(
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL!}/api/v1/documents/fileupload`,
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/documents/fileupload`,
{
method: "POST",
headers: {
@ -259,6 +265,9 @@ export default function FileUploader() {
}
);
clearInterval(progressInterval);
setUploadProgress(100);
if (!response.ok) {
throw new Error("Upload failed");
}
@ -272,31 +281,15 @@ export default function FileUploader() {
router.push(`/dashboard/${search_space_id}/documents`);
} catch (error: any) {
setIsUploading(false);
setUploadProgress(0);
toast("Upload Error", {
description: `Error uploading files: ${error.message}`,
});
}
};
const mainVariant = {
initial: {
x: 0,
y: 0,
},
animate: {
x: 20,
y: -20,
opacity: 0.9,
},
};
const secondaryVariant = {
initial: {
opacity: 0,
},
animate: {
opacity: 1,
},
const getTotalFileSize = () => {
return files.reduce((total, file) => total + file.size, 0);
};
const containerVariants = {
@ -326,251 +319,252 @@ export default function FileUploader() {
return (
<div className="grow flex items-center justify-center p-4 md:p-8">
<motion.div
className="w-full max-w-3xl mx-auto"
className="w-full max-w-4xl mx-auto space-y-6"
initial="hidden"
animate="visible"
variants={containerVariants}
>
<motion.div
className="bg-background rounded-xl shadow-lg overflow-hidden border border-border"
variants={itemVariants}
>
<motion.div
className="p-10 group/file block rounded-lg cursor-pointer w-full relative overflow-hidden"
whileHover="animate"
onClick={handleClick}
>
{/* Header Card */}
<motion.div variants={itemVariants}>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Upload className="h-5 w-5" />
Upload Documents
</CardTitle>
<CardDescription>
Upload your files to make them searchable and accessible through AI-powered
conversations.
</CardDescription>
</CardHeader>
<CardContent>
<Alert>
<Info className="h-4 w-4" />
<AlertDescription>
Maximum file size: 50MB per file. Supported formats vary based on your ETL service
configuration.
</AlertDescription>
</Alert>
</CardContent>
</Card>
</motion.div>
{/* Upload Area Card */}
<motion.div variants={itemVariants}>
<Card className="relative overflow-hidden">
{/* Grid background pattern */}
<div className="absolute inset-0 [mask-image:radial-gradient(ellipse_at_center,white,transparent)]">
<div className="absolute inset-0 [mask-image:radial-gradient(ellipse_at_center,white,transparent)] opacity-30">
<GridPattern />
</div>
<div className="relative z-10">
{/* Dropzone area */}
<div {...getRootProps()} className="flex flex-col items-center justify-center">
<input {...getInputProps()} ref={fileInputRef} className="hidden" />
<p className="relative z-20 font-sans font-bold text-neutral-700 dark:text-neutral-300 text-xl">
Upload files
</p>
<p className="relative z-20 font-sans font-normal text-neutral-400 dark:text-neutral-400 text-base mt-2">
Drag or drop your files here or click to upload
</p>
<div className="relative w-full mt-10 max-w-xl mx-auto">
{!files.length && (
<motion.div
layoutId="file-upload"
variants={mainVariant}
transition={{
type: "spring",
stiffness: 300,
damping: 20,
}}
className="relative group-hover/file:shadow-2xl z-40 bg-white dark:bg-neutral-900 flex items-center justify-center h-32 mt-4 w-full max-w-[8rem] mx-auto rounded-md shadow-[0px_10px_50px_rgba(0,0,0,0.1)]"
key="upload-icon"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
{isDragActive ? (
<motion.p
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="text-neutral-600 flex flex-col items-center"
>
Drop it
<Upload className="h-4 w-4 text-neutral-600 dark:text-neutral-400 mt-2" />
</motion.p>
) : (
<Upload className="h-8 w-8 text-neutral-600 dark:text-neutral-300" />
)}
</motion.div>
)}
{!files.length && (
<motion.div
variants={secondaryVariant}
className="absolute opacity-0 border border-dashed border-primary inset-0 z-30 bg-transparent flex items-center justify-center h-32 mt-4 w-full max-w-[8rem] mx-auto rounded-md"
key="upload-border"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
></motion.div>
)}
</div>
</div>
</div>
</motion.div>
{/* File list section */}
<AnimatePresence mode="wait">
{files.length > 0 && (
<motion.div
className="px-8 pb-8"
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.3 }}
<CardContent className="p-10 relative z-10">
<div
{...getRootProps()}
className="flex flex-col items-center justify-center min-h-[300px] border-2 border-dashed border-muted-foreground/25 rounded-lg hover:border-primary/50 transition-colors cursor-pointer"
>
<div className="mb-4 flex items-center justify-between">
<h3 className="font-medium">Selected Files ({files.length})</h3>
<Button
variant="ghost"
size="sm"
onClick={() => {
// Use AnimatePresence to properly handle the transition
// This will ensure the file icon reappears properly
setFiles([]);
<input {...getInputProps()} className="hidden" />
// Force a re-render after animation completes
setTimeout(() => {
const event = new Event("resize");
window.dispatchEvent(event);
}, 350);
}}
disabled={isUploading}
{isDragActive ? (
<motion.div
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
className="flex flex-col items-center gap-4"
>
Clear all
<Upload className="h-12 w-12 text-primary" />
<p className="text-lg font-medium text-primary">Drop files here</p>
</motion.div>
) : (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="flex flex-col items-center gap-4"
>
<Upload className="h-12 w-12 text-muted-foreground" />
<div className="text-center">
<p className="text-lg font-medium">Drag & drop files here</p>
<p className="text-sm text-muted-foreground mt-1">or click to browse</p>
</div>
</motion.div>
)}
{/* Fallback button for better accessibility */}
<div className="mt-4">
<Button
variant="outline"
size="sm"
onClick={(e) => {
e.stopPropagation();
const input = document.querySelector(
'input[type="file"]'
) as HTMLInputElement;
if (input) input.click();
}}
>
Browse Files
</Button>
</div>
</div>
</CardContent>
</Card>
</motion.div>
<div className="space-y-4 max-h-[400px] overflow-y-auto pr-2 custom-scrollbar">
<AnimatePresence>
{files.map((file, index) => (
<motion.div
key={`${file.name}-${index}`}
layoutId={index === 0 ? "file-upload" : `file-upload-${index}`}
className="relative overflow-hidden z-40 bg-white dark:bg-neutral-900 flex flex-col items-start justify-start p-4 w-full mx-auto rounded-md shadow-sm border border-border"
initial="hidden"
animate="visible"
exit="exit"
variants={fileItemVariants}
>
<div className="flex justify-between w-full items-center gap-4">
<motion.p
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
layout
className="text-base text-neutral-700 dark:text-neutral-300 truncate max-w-xs font-medium"
>
{file.name}
</motion.p>
{/* File List Card */}
<AnimatePresence mode="wait">
{files.length > 0 && (
<motion.div
variants={itemVariants}
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.3 }}
>
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Selected Files ({files.length})</CardTitle>
<CardDescription>
Total size: {formatFileSize(getTotalFileSize())}
</CardDescription>
</div>
<Button
variant="outline"
size="sm"
onClick={() => setFiles([])}
disabled={isUploading}
>
Clear all
</Button>
</div>
</CardHeader>
<CardContent>
<div className="space-y-3 max-h-[400px] overflow-y-auto pr-2 custom-scrollbar">
<AnimatePresence>
{files.map((file, index) => (
<motion.div
key={`${file.name}-${index}`}
variants={fileItemVariants}
initial="hidden"
animate="visible"
exit="exit"
className="flex items-center justify-between p-4 rounded-lg border bg-card hover:bg-accent/50 transition-colors"
>
<div className="flex items-center gap-3 flex-1 min-w-0">
<div className="flex-shrink-0">
<FileType className="h-5 w-5 text-muted-foreground" />
</div>
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{file.name}</p>
<div className="flex items-center gap-2 mt-1">
<Badge variant="secondary" className="text-xs">
{formatFileSize(file.size)}
</Badge>
<Badge variant="outline" className="text-xs">
{file.type || "Unknown type"}
</Badge>
</div>
</div>
</div>
<div className="flex items-center gap-2">
<motion.p
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
layout
className="rounded-lg px-2 py-1 w-fit flex-shrink-0 text-sm text-neutral-600 dark:bg-neutral-800 dark:text-white bg-neutral-100"
>
{formatFileSize(file.size)}
</motion.p>
<Button
variant="ghost"
size="icon"
onClick={() => removeFile(index)}
disabled={isUploading}
className="h-8 w-8"
aria-label={`Remove ${file.name}`}
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
<div className="flex text-sm md:flex-row flex-col items-start md:items-center w-full mt-2 justify-between text-neutral-600 dark:text-neutral-400">
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
layout
className="flex items-center gap-1 px-2 py-1 rounded-md bg-gray-100 dark:bg-neutral-800"
>
<FileType className="h-3 w-3" />
<span>{file.type || "Unknown type"}</span>
</motion.div>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
layout
className="flex items-center gap-1 mt-2 md:mt-0"
>
<Calendar className="h-3 w-3" />
<span>modified {new Date(file.lastModified).toLocaleDateString()}</span>
</motion.div>
</div>
</motion.div>
))}
</AnimatePresence>
</div>
<motion.div
className="mt-6"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
>
<Button
className="w-full py-6 text-base font-medium"
onClick={handleUpload}
disabled={isUploading || files.length === 0}
>
{isUploading ? (
<motion.div
className="flex items-center gap-2"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
>
<motion.div
animate={{ rotate: 360 }}
transition={{ duration: 1, repeat: Infinity, ease: "linear" }}
>
<Upload className="h-5 w-5" />
</motion.div>
<span>Uploading...</span>
</motion.div>
) : (
<motion.div
className="flex items-center gap-2"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<CheckCircle2 className="h-5 w-5" />
<span>
Upload {files.length} {files.length === 1 ? "file" : "files"}
</span>
</motion.div>
)}
</Button>
</motion.div>
</motion.div>
)}
</AnimatePresence>
))}
</AnimatePresence>
</div>
{/* File type information */}
<motion.div className="px-8 pb-8" variants={itemVariants}>
<div className="p-4 bg-muted rounded-lg">
<div className="flex items-center gap-2 mb-3">
<Tag className="h-4 w-4 text-primary" />
<p className="text-sm font-medium">Supported file types:</p>
</div>
{isUploading && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="mt-6 space-y-3"
>
<Separator />
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span>Uploading files...</span>
<span>{Math.round(uploadProgress)}%</span>
</div>
<Progress value={uploadProgress} className="h-2" />
</div>
</motion.div>
)}
<motion.div
className="mt-6"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
>
<Button
className="w-full py-6 text-base font-medium"
onClick={handleUpload}
disabled={isUploading || files.length === 0}
>
{isUploading ? (
<motion.div
className="flex items-center gap-2"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
>
<motion.div
animate={{ rotate: 360 }}
transition={{ duration: 1, repeat: Infinity, ease: "linear" }}
>
<Upload className="h-5 w-5" />
</motion.div>
<span>Uploading...</span>
</motion.div>
) : (
<motion.div
className="flex items-center gap-2"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<CheckCircle2 className="h-5 w-5" />
<span>
Upload {files.length} {files.length === 1 ? "file" : "files"}
</span>
</motion.div>
)}
</Button>
</motion.div>
</CardContent>
</Card>
</motion.div>
)}
</AnimatePresence>
{/* Supported File Types Card */}
<motion.div variants={itemVariants}>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Tag className="h-5 w-5" />
Supported File Types
</CardTitle>
<CardDescription>
These file types are supported based on your current ETL service configuration.
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
{supportedExtensions.map((ext) => (
<motion.span
key={ext}
className="px-2 py-1 bg-primary/10 text-primary text-xs rounded-full"
whileHover={{ scale: 1.05, backgroundColor: "rgba(var(--primary), 0.2)" }}
initial={{ opacity: 1 }}
animate={{ opacity: 1 }}
exit={{ opacity: 1 }}
layout
>
<Badge key={ext} variant="outline" className="text-xs">
{ext}
</motion.span>
</Badge>
))}
</div>
</div>
</motion.div>
</CardContent>
</Card>
</motion.div>
</motion.div>

View file

@ -31,7 +31,6 @@ export default function DashboardLayout({
title: "Researcher",
url: `/dashboard/${search_space_id}/researcher`,
icon: "SquareTerminal",
isActive: true,
items: [],
},

View file

@ -0,0 +1,15 @@
"use client";
import { useParams, useRouter } from "next/navigation";
import { useEffect } from "react";
export default function SearchSpaceDashboardPage() {
const router = useRouter();
const { search_space_id } = useParams();
useEffect(() => {
router.push(`/dashboard/${search_space_id}/chats`);
}, []);
return <></>;
}

View file

@ -0,0 +1,169 @@
"use client";
import { usePathname } from "next/navigation";
import React from "react";
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb";
interface BreadcrumbItemInterface {
label: string;
href?: string;
}
export function DashboardBreadcrumb() {
const pathname = usePathname();
// Parse the pathname to create breadcrumb items
const generateBreadcrumbs = (path: string): BreadcrumbItemInterface[] => {
const segments = path.split("/").filter(Boolean);
const breadcrumbs: BreadcrumbItemInterface[] = [];
// Always start with Dashboard
breadcrumbs.push({ label: "Dashboard", href: "/dashboard" });
// Handle search space
if (segments[0] === "dashboard" && segments[1]) {
breadcrumbs.push({ label: `Search Space ${segments[1]}`, href: `/dashboard/${segments[1]}` });
// Handle specific sections
if (segments[2]) {
const section = segments[2];
let sectionLabel = section.charAt(0).toUpperCase() + section.slice(1);
// Map section names to more readable labels
const sectionLabels: Record<string, string> = {
researcher: "Researcher",
documents: "Documents",
connectors: "Connectors",
podcasts: "Podcasts",
logs: "Logs",
chats: "Chats",
};
sectionLabel = sectionLabels[section] || sectionLabel;
// Handle sub-sections
if (segments[3]) {
const subSection = segments[3];
let subSectionLabel = subSection.charAt(0).toUpperCase() + subSection.slice(1);
// Handle documents sub-sections
if (section === "documents") {
const documentLabels: Record<string, string> = {
upload: "Upload Documents",
youtube: "Add YouTube Videos",
webpage: "Add Webpages",
};
const documentLabel = documentLabels[subSection] || subSectionLabel;
breadcrumbs.push({
label: "Documents",
href: `/dashboard/${segments[1]}/documents`,
});
breadcrumbs.push({ label: documentLabel });
return breadcrumbs;
}
// Handle connector sub-sections
if (section === "connectors") {
// Handle specific connector types
if (subSection === "add" && segments[4]) {
const connectorType = segments[4];
const connectorLabels: Record<string, string> = {
"github-connector": "GitHub",
"jira-connector": "Jira",
"confluence-connector": "Confluence",
"discord-connector": "Discord",
"linear-connector": "Linear",
"clickup-connector": "ClickUp",
"slack-connector": "Slack",
"notion-connector": "Notion",
"tavily-api": "Tavily API",
"serper-api": "Serper API",
"linkup-api": "LinkUp API",
};
const connectorLabel = connectorLabels[connectorType] || connectorType;
breadcrumbs.push({
label: "Connectors",
href: `/dashboard/${segments[1]}/connectors`,
});
breadcrumbs.push({
label: "Add Connector",
href: `/dashboard/${segments[1]}/connectors/add`,
});
breadcrumbs.push({ label: connectorLabel });
return breadcrumbs;
}
const connectorLabels: Record<string, string> = {
add: "Add Connector",
manage: "Manage Connectors",
};
const connectorLabel = connectorLabels[subSection] || subSectionLabel;
breadcrumbs.push({
label: "Connectors",
href: `/dashboard/${segments[1]}/connectors`,
});
breadcrumbs.push({ label: connectorLabel });
return breadcrumbs;
}
// Handle other sub-sections
const subSectionLabels: Record<string, string> = {
upload: "Upload Documents",
youtube: "Add YouTube Videos",
webpage: "Add Webpages",
add: "Add Connector",
edit: "Edit Connector",
manage: "Manage",
};
subSectionLabel = subSectionLabels[subSection] || subSectionLabel;
breadcrumbs.push({
label: sectionLabel,
href: `/dashboard/${segments[1]}/${section}`,
});
breadcrumbs.push({ label: subSectionLabel });
} else {
breadcrumbs.push({ label: sectionLabel });
}
}
}
return breadcrumbs;
};
const breadcrumbs = generateBreadcrumbs(pathname);
if (breadcrumbs.length <= 1) {
return null; // Don't show breadcrumbs for root dashboard
}
return (
<Breadcrumb>
<BreadcrumbList>
{breadcrumbs.map((item, index) => (
<React.Fragment key={index}>
<BreadcrumbItem>
{index === breadcrumbs.length - 1 ? (
<BreadcrumbPage>{item.label}</BreadcrumbPage>
) : (
<BreadcrumbLink href={item.href}>{item.label}</BreadcrumbLink>
)}
</BreadcrumbItem>
{index < breadcrumbs.length - 1 && <BreadcrumbSeparator />}
</React.Fragment>
))}
</BreadcrumbList>
</Breadcrumb>
);
}

View file

@ -1,7 +1,7 @@
"use client";
import { Trash2 } from "lucide-react";
import { useEffect, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { AppSidebar } from "@/components/sidebar/app-sidebar";
import { Button } from "@/components/ui/button";
import {
@ -12,7 +12,7 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { apiClient } from "@/lib/api"; // Import the API client
import { apiClient } from "@/lib/api";
interface Chat {
created_at: string;
@ -80,66 +80,82 @@ export function AppSidebarProvider({
setIsClient(true);
}, []);
// Memoized fetch function for chats
const fetchRecentChats = useCallback(async () => {
try {
// Only run on client-side
if (typeof window === "undefined") return;
const chats: Chat[] = await apiClient.get<Chat[]>(
`api/v1/chats/?limit=5&skip=0&search_space_id=${searchSpaceId}`
);
// Sort chats by created_at in descending order (newest first)
const sortedChats = chats.sort(
(a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
);
// Transform API response to the format expected by AppSidebar
const formattedChats = sortedChats.map((chat) => ({
name: chat.title || `Chat ${chat.id}`,
url: `/dashboard/${chat.search_space_id}/researcher/${chat.id}`,
icon: "MessageCircleMore",
id: chat.id,
search_space_id: chat.search_space_id,
actions: [
{
name: "Delete",
icon: "Trash2",
onClick: () => {
setChatToDelete({ id: chat.id, name: chat.title || `Chat ${chat.id}` });
setShowDeleteDialog(true);
},
},
],
}));
setRecentChats(formattedChats);
setChatError(null);
} catch (error) {
console.error("Error fetching chats:", error);
setChatError(error instanceof Error ? error.message : "Unknown error occurred");
setRecentChats([]);
} finally {
setIsLoadingChats(false);
}
}, [searchSpaceId]);
// Memoized fetch function for search space
const fetchSearchSpace = useCallback(async () => {
try {
// Only run on client-side
if (typeof window === "undefined") return;
const data: SearchSpace = await apiClient.get<SearchSpace>(
`api/v1/searchspaces/${searchSpaceId}`
);
setSearchSpace(data);
setSearchSpaceError(null);
} catch (error) {
console.error("Error fetching search space:", error);
setSearchSpaceError(error instanceof Error ? error.message : "Unknown error occurred");
} finally {
setIsLoadingSearchSpace(false);
}
}, [searchSpaceId]);
// Retry function
const retryFetch = useCallback(() => {
setChatError(null);
setSearchSpaceError(null);
setIsLoadingChats(true);
setIsLoadingSearchSpace(true);
fetchRecentChats();
fetchSearchSpace();
}, [fetchRecentChats, fetchSearchSpace]);
// Fetch recent chats
useEffect(() => {
const fetchRecentChats = async () => {
try {
// Only run on client-side
if (typeof window === "undefined") return;
try {
// Use the API client instead of direct fetch - filter by current search space ID
const chats: Chat[] = await apiClient.get<Chat[]>(
`api/v1/chats/?limit=5&skip=0&search_space_id=${searchSpaceId}`
);
// Sort chats by created_at in descending order (newest first)
const sortedChats = chats.sort(
(a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
);
// console.log("sortedChats", sortedChats);
// Transform API response to the format expected by AppSidebar
const formattedChats = sortedChats.map((chat) => ({
name: chat.title || `Chat ${chat.id}`, // Fallback if title is empty
url: `/dashboard/${chat.search_space_id}/researcher/${chat.id}`,
icon: "MessageCircleMore",
id: chat.id,
search_space_id: chat.search_space_id,
actions: [
{
name: "View Details",
icon: "ExternalLink",
onClick: () => {
window.location.href = `/dashboard/${chat.search_space_id}/researcher/${chat.id}`;
},
},
{
name: "Delete",
icon: "Trash2",
onClick: () => {
setChatToDelete({ id: chat.id, name: chat.title || `Chat ${chat.id}` });
setShowDeleteDialog(true);
},
},
],
}));
setRecentChats(formattedChats);
setChatError(null);
} catch (error) {
console.error("Error fetching chats:", error);
setChatError(error instanceof Error ? error.message : "Unknown error occurred");
// Provide empty array to ensure UI still renders
setRecentChats([]);
} finally {
setIsLoadingChats(false);
}
} catch (error) {
console.error("Error in fetchRecentChats:", error);
setIsLoadingChats(false);
}
};
fetchRecentChats();
// Set up a refresh interval (every 5 minutes)
@ -147,144 +163,144 @@ export function AppSidebarProvider({
// Clean up interval on component unmount
return () => clearInterval(intervalId);
}, [searchSpaceId]);
}, [fetchRecentChats]);
// Handle delete chat
const handleDeleteChat = async () => {
// Fetch search space details
useEffect(() => {
fetchSearchSpace();
}, [fetchSearchSpace]);
// Handle delete chat with better error handling
const handleDeleteChat = useCallback(async () => {
if (!chatToDelete) return;
try {
setIsDeleting(true);
// Use the API client instead of direct fetch
await apiClient.delete(`api/v1/chats/${chatToDelete.id}`);
// Close dialog and refresh chats
setRecentChats(recentChats.filter((chat) => chat.id !== chatToDelete.id));
// Update local state
setRecentChats((prev) => prev.filter((chat) => chat.id !== chatToDelete.id));
} catch (error) {
console.error("Error deleting chat:", error);
// You could show a toast notification here
} finally {
setIsDeleting(false);
setShowDeleteDialog(false);
setChatToDelete(null);
}
};
}, [chatToDelete]);
// Fetch search space details
useEffect(() => {
const fetchSearchSpace = async () => {
try {
// Only run on client-side
if (typeof window === "undefined") return;
// Memoized fallback chats
const fallbackChats = useMemo(() => {
if (chatError) {
return [
{
name: "Error loading chats",
url: "#",
icon: "AlertCircle",
id: 0,
search_space_id: Number(searchSpaceId),
actions: [
{
name: "Retry",
icon: "RefreshCw",
onClick: retryFetch,
},
],
},
];
}
try {
// Use the API client instead of direct fetch
const data: SearchSpace = await apiClient.get<SearchSpace>(
`api/v1/searchspaces/${searchSpaceId}`
);
setSearchSpace(data);
setSearchSpaceError(null);
} catch (error) {
console.error("Error fetching search space:", error);
setSearchSpaceError(error instanceof Error ? error.message : "Unknown error occurred");
} finally {
setIsLoadingSearchSpace(false);
}
} catch (error) {
console.error("Error in fetchSearchSpace:", error);
setIsLoadingSearchSpace(false);
}
};
if (!isLoadingChats && recentChats.length === 0) {
return [
{
name: "No recent chats",
url: "#",
icon: "MessageCircleMore",
id: 0,
search_space_id: Number(searchSpaceId),
actions: [],
},
];
}
fetchSearchSpace();
}, [searchSpaceId]);
// Create a fallback chat if there's an error or no chats
const fallbackChats =
chatError || (!isLoadingChats && recentChats.length === 0)
? [
{
name: chatError ? "Error loading chats" : "No recent chats",
url: "#",
icon: chatError ? "AlertCircle" : "MessageCircleMore",
id: 0,
search_space_id: Number(searchSpaceId),
actions: [],
},
]
: [];
return [];
}, [chatError, isLoadingChats, recentChats.length, searchSpaceId, retryFetch]);
// Use fallback chats if there's an error or no chats
const displayChats = recentChats.length > 0 ? recentChats : fallbackChats;
// Update the first item in navSecondary to show the search space name
const updatedNavSecondary = [...navSecondary];
if (updatedNavSecondary.length > 0 && isClient) {
updatedNavSecondary[0] = {
...updatedNavSecondary[0],
title:
searchSpace?.name ||
(isLoadingSearchSpace
? "Loading..."
: searchSpaceError
? "Error loading search space"
: "Unknown Search Space"),
};
// Memoized updated navSecondary
const updatedNavSecondary = useMemo(() => {
const updated = [...navSecondary];
if (updated.length > 0 && isClient) {
updated[0] = {
...updated[0],
title:
searchSpace?.name ||
(isLoadingSearchSpace
? "Loading..."
: searchSpaceError
? "Error loading search space"
: "Unknown Search Space"),
};
}
return updated;
}, [navSecondary, isClient, searchSpace?.name, isLoadingSearchSpace, searchSpaceError]);
// Show loading state if not client-side
if (!isClient) {
return <AppSidebar navSecondary={navSecondary} navMain={navMain} RecentChats={[]} />;
}
return (
<>
<AppSidebar
navSecondary={updatedNavSecondary}
navMain={navMain}
RecentChats={isClient ? displayChats : []}
/>
<AppSidebar navSecondary={updatedNavSecondary} navMain={navMain} RecentChats={displayChats} />
{/* Delete Confirmation Dialog - Only render on client */}
{isClient && (
<Dialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Trash2 className="h-5 w-5 text-destructive" />
<span>Delete Chat</span>
</DialogTitle>
<DialogDescription>
Are you sure you want to delete{" "}
<span className="font-medium">{chatToDelete?.name}</span>? This action cannot be
undone.
</DialogDescription>
</DialogHeader>
<DialogFooter className="flex gap-2 sm:justify-end">
<Button
variant="outline"
onClick={() => setShowDeleteDialog(false)}
disabled={isDeleting}
>
Cancel
</Button>
<Button
variant="destructive"
onClick={handleDeleteChat}
disabled={isDeleting}
className="gap-2"
>
{isDeleting ? (
<>
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
Deleting...
</>
) : (
<>
<Trash2 className="h-4 w-4" />
Delete
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)}
{/* Delete Confirmation Dialog */}
<Dialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Trash2 className="h-5 w-5 text-destructive" />
<span>Delete Chat</span>
</DialogTitle>
<DialogDescription>
Are you sure you want to delete{" "}
<span className="font-medium">{chatToDelete?.name}</span>? This action cannot be
undone.
</DialogDescription>
</DialogHeader>
<DialogFooter className="flex gap-2 sm:justify-end">
<Button
variant="outline"
onClick={() => setShowDeleteDialog(false)}
disabled={isDeleting}
>
Cancel
</Button>
<Button
variant="destructive"
onClick={handleDeleteChat}
disabled={isDeleting}
className="gap-2"
>
{isDeleting ? (
<>
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
Deleting...
</>
) : (
<>
<Trash2 className="h-4 w-4" />
Delete
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View file

@ -17,15 +17,17 @@ import {
Trash2,
Undo2,
} from "lucide-react";
import { useMemo } from "react";
import { memo, useMemo } from "react";
import { Logo } from "@/components/Logo";
import { NavMain } from "@/components/sidebar/nav-main";
import { NavProjects } from "@/components/sidebar/nav-projects";
import { NavSecondary } from "@/components/sidebar/nav-secondary";
import { NavUser } from "@/components/sidebar/nav-user";
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
@ -64,7 +66,6 @@ const defaultData = {
isActive: true,
items: [],
},
{
title: "Documents",
url: "#",
@ -154,12 +155,12 @@ interface AppSidebarProps extends React.ComponentProps<typeof Sidebar> {
navSecondary?: {
title: string;
url: string;
icon: string; // Changed to string (icon name)
icon: string;
}[];
RecentChats?: {
name: string;
url: string;
icon: string; // Changed to string (icon name)
icon: string;
id?: number;
search_space_id?: number;
actions?: {
@ -168,9 +169,15 @@ interface AppSidebarProps extends React.ComponentProps<typeof Sidebar> {
onClick: () => void;
}[];
}[];
user?: {
name: string;
email: string;
avatar: string;
};
}
export function AppSidebar({
// Memoized AppSidebar component for better performance
export const AppSidebar = memo(function AppSidebar({
navMain = defaultData.navMain,
navSecondary = defaultData.navSecondary,
RecentChats = defaultData.RecentChats,
@ -180,7 +187,7 @@ export function AppSidebar({
const processedNavMain = useMemo(() => {
return navMain.map((item) => ({
...item,
icon: iconMap[item.icon] || SquareTerminal, // Fallback to SquareTerminal if icon not found
icon: iconMap[item.icon] || SquareTerminal,
}));
}, [navMain]);
@ -188,7 +195,7 @@ export function AppSidebar({
const processedNavSecondary = useMemo(() => {
return navSecondary.map((item) => ({
...item,
icon: iconMap[item.icon] || Undo2, // Fallback to Undo2 if icon not found
icon: iconMap[item.icon] || Undo2,
}));
}, [navSecondary]);
@ -197,17 +204,17 @@ export function AppSidebar({
return (
RecentChats?.map((item) => ({
...item,
icon: iconMap[item.icon] || MessageCircleMore, // Fallback to MessageCircleMore if icon not found
icon: iconMap[item.icon] || MessageCircleMore,
})) || []
);
}, [RecentChats]);
return (
<Sidebar variant="inset" {...props}>
<Sidebar variant="inset" collapsible="icon" aria-label="Main navigation" {...props}>
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton size="lg" asChild>
<SidebarMenuButton size="lg" asChild aria-label="Go to home page">
<div>
<div className="bg-sidebar-primary text-sidebar-primary-foreground flex aspect-square size-8 items-center justify-center rounded-lg">
<Logo className="rounded-lg" />
@ -221,11 +228,22 @@ export function AppSidebar({
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarContent>
<SidebarContent className="space-y-6">
<NavMain items={processedNavMain} />
{processedRecentChats.length > 0 && <NavProjects chats={processedRecentChats} />}
<NavSecondary items={processedNavSecondary} className="mt-auto" />
{processedRecentChats.length > 0 && (
<div className="space-y-2">
<NavProjects chats={processedRecentChats} />
</div>
)}
</SidebarContent>
<SidebarFooter>
<NavSecondary items={processedNavSecondary} className="mt-auto" />
{/* User Profile Section */}
<NavUser />
</SidebarFooter>
</Sidebar>
);
}
});

View file

@ -1,6 +1,7 @@
"use client";
import { ChevronRight, type LucideIcon } from "lucide-react";
import { useMemo } from "react";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import {
@ -15,46 +16,56 @@ import {
SidebarMenuSubItem,
} from "@/components/ui/sidebar";
export function NavMain({
items,
}: {
items: {
interface NavItem {
title: string;
url: string;
icon: LucideIcon;
isActive?: boolean;
items?: {
title: string;
url: string;
icon: LucideIcon;
isActive?: boolean;
items?: {
title: string;
url: string;
}[];
}[];
}) {
}
export function NavMain({ items }: { items: NavItem[] }) {
// Memoize items to prevent unnecessary re-renders
const memoizedItems = useMemo(() => items, [items]);
return (
<SidebarGroup>
<SidebarGroupLabel>Platform</SidebarGroupLabel>
<SidebarMenu>
{items.map((item, index) => (
{memoizedItems.map((item, index) => (
<Collapsible key={`${item.title}-${index}`} asChild defaultOpen={item.isActive}>
<SidebarMenuItem>
<SidebarMenuButton asChild tooltip={item.title}>
<SidebarMenuButton
asChild
tooltip={item.title}
isActive={item.isActive}
aria-label={`${item.title}${item.items?.length ? " with submenu" : ""}`}
>
<a href={item.url}>
<item.icon />
<span>{item.title}</span>
</a>
</SidebarMenuButton>
{item.items?.length ? (
<>
<CollapsibleTrigger asChild>
<SidebarMenuAction className="data-[state=open]:rotate-90">
<SidebarMenuAction
className="data-[state=open]:rotate-90 transition-transform duration-200"
aria-label={`Toggle ${item.title} submenu`}
>
<ChevronRight />
<span className="sr-only">Toggle</span>
<span className="sr-only">Toggle submenu</span>
</SidebarMenuAction>
</CollapsibleTrigger>
<CollapsibleContent>
<CollapsibleContent className="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]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 duration-200">
<SidebarMenuSub>
{item.items?.map((subItem, subIndex) => (
<SidebarMenuSubItem key={`${subItem.title}-${subIndex}`}>
<SidebarMenuSubButton asChild>
<SidebarMenuSubButton asChild aria-label={subItem.title}>
<a href={subItem.url}>
<span>{subItem.title}</span>
</a>

View file

@ -1,17 +1,27 @@
"use client";
import { ExternalLink, Folder, type LucideIcon, MoreHorizontal, Share, Trash2 } from "lucide-react";
import {
ExternalLink,
Folder,
type LucideIcon,
MoreHorizontal,
RefreshCw,
Search,
Share,
Trash2,
} from "lucide-react";
import { useRouter } from "next/navigation";
import { useCallback, useMemo, useState } from "react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
SidebarGroup,
SidebarGroupLabel,
SidebarInput,
SidebarMenu,
SidebarMenuAction,
SidebarMenuButton,
@ -26,6 +36,8 @@ const actionIconMap: Record<string, LucideIcon> = {
Share,
Trash2,
MoreHorizontal,
Search,
RefreshCw,
};
interface ChatAction {
@ -34,33 +46,57 @@ interface ChatAction {
onClick: () => void;
}
export function NavProjects({
chats,
}: {
chats: {
name: string;
url: string;
icon: LucideIcon;
id?: number;
search_space_id?: number;
actions?: ChatAction[];
}[];
}) {
interface ChatItem {
name: string;
url: string;
icon: LucideIcon;
id?: number;
search_space_id?: number;
actions?: ChatAction[];
}
export function NavProjects({ chats }: { chats: ChatItem[] }) {
const { isMobile } = useSidebar();
const router = useRouter();
const [searchQuery, setSearchQuery] = useState("");
const [isDeleting, setIsDeleting] = useState<number | null>(null);
const searchSpaceId = chats[0]?.search_space_id || "";
return (
<SidebarGroup className="group-data-[collapsible=icon]:hidden">
<SidebarGroupLabel>Recent Chats</SidebarGroupLabel>
<SidebarMenu>
{chats.map((item, index) => (
<SidebarMenuItem key={item.id ? `chat-${item.id}` : `chat-${item.name}-${index}`}>
<SidebarMenuButton>
<item.icon />
<span>{item.name}</span>
</SidebarMenuButton>
// Memoized filtered chats
const filteredChats = useMemo(() => {
if (!searchQuery.trim()) return chats;
return chats.filter((chat) => chat.name.toLowerCase().includes(searchQuery.toLowerCase()));
}, [chats, searchQuery]);
// Handle chat deletion with loading state
const handleDeleteChat = useCallback(async (chatId: number, deleteAction: () => void) => {
setIsDeleting(chatId);
try {
await deleteAction();
} finally {
setIsDeleting(null);
}
}, []);
// Enhanced chat item component
const ChatItemComponent = useCallback(
({ chat }: { chat: ChatItem }) => {
const isDeletingChat = isDeleting === chat.id;
return (
<SidebarMenuItem key={chat.id ? `chat-${chat.id}` : `chat-${chat.name}`}>
<SidebarMenuButton
onClick={() => router.push(chat.url)}
disabled={isDeletingChat}
className={isDeletingChat ? "opacity-50" : ""}
>
<chat.icon />
<span className={isDeletingChat ? "opacity-50" : ""}>{chat.name}</span>
</SidebarMenuButton>
{chat.actions && chat.actions.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuAction showOnHover>
@ -73,44 +109,79 @@ export function NavProjects({
side={isMobile ? "bottom" : "right"}
align={isMobile ? "end" : "start"}
>
{item.actions ? (
// Use the actions provided by the item
item.actions.map((action, actionIndex) => {
const ActionIcon = actionIconMap[action.icon] || Folder;
return (
<DropdownMenuItem
key={`${action.name}-${actionIndex}`}
onClick={action.onClick}
>
<ActionIcon className="text-muted-foreground" />
<span>{action.name}</span>
</DropdownMenuItem>
);
})
) : (
// Default actions if none provided
<>
<DropdownMenuItem>
<Folder className="text-muted-foreground" />
<span>View Chat</span>
{chat.actions.map((action, actionIndex) => {
const ActionIcon = actionIconMap[action.icon] || Folder;
const isDeleteAction = action.name.toLowerCase().includes("delete");
return (
<DropdownMenuItem
key={`${action.name}-${actionIndex}`}
onClick={() => {
if (isDeleteAction) {
handleDeleteChat(chat.id || 0, action.onClick);
} else {
action.onClick();
}
}}
disabled={isDeletingChat}
className={isDeleteAction ? "text-destructive" : ""}
>
<ActionIcon className="text-muted-foreground" />
<span>{isDeletingChat && isDeleteAction ? "Deleting..." : action.name}</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem>
<Trash2 className="text-muted-foreground" />
<span>Delete Chat</span>
</DropdownMenuItem>
</>
)}
);
})}
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
))}
<SidebarMenuItem>
<SidebarMenuButton onClick={() => router.push(`/dashboard/${searchSpaceId}/chats`)}>
<MoreHorizontal />
<span>View All Chats</span>
</SidebarMenuButton>
)}
</SidebarMenuItem>
);
},
[isDeleting, router, isMobile, handleDeleteChat]
);
// Show search input if there are chats
const showSearch = chats.length > 0;
return (
<SidebarGroup className="group-data-[collapsible=icon]:hidden">
<SidebarGroupLabel>Recent Chats</SidebarGroupLabel>
{/* Search Input */}
{showSearch && (
<div className="px-2 pb-2">
<SidebarInput
placeholder="Search chats..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="h-8"
/>
</div>
)}
<SidebarMenu>
{/* Chat Items */}
{filteredChats.length > 0 ? (
filteredChats.map((chat) => <ChatItemComponent key={chat.id || chat.name} chat={chat} />)
) : (
/* No results state */
<SidebarMenuItem>
<SidebarMenuButton disabled className="text-muted-foreground">
<Search className="h-4 w-4" />
<span>{searchQuery ? "No chats found" : "No recent chats"}</span>
</SidebarMenuButton>
</SidebarMenuItem>
)}
{/* View All Chats */}
{chats.length > 0 && (
<SidebarMenuItem>
<SidebarMenuButton onClick={() => router.push(`/dashboard/${searchSpaceId}/chats`)}>
<MoreHorizontal />
<span>View All Chats</span>
</SidebarMenuButton>
</SidebarMenuItem>
)}
</SidebarMenu>
</SidebarGroup>
);

View file

@ -2,6 +2,7 @@
import type { LucideIcon } from "lucide-react";
import type * as React from "react";
import { useMemo } from "react";
import {
SidebarGroup,
@ -11,23 +12,28 @@ import {
SidebarMenuItem,
} from "@/components/ui/sidebar";
interface NavSecondaryItem {
title: string;
url: string;
icon: LucideIcon;
}
export function NavSecondary({
items,
...props
}: {
items: {
title: string;
url: string;
icon: LucideIcon;
}[];
items: NavSecondaryItem[];
} & React.ComponentPropsWithoutRef<typeof SidebarGroup>) {
// Memoize items to prevent unnecessary re-renders
const memoizedItems = useMemo(() => items, [items]);
return (
<SidebarGroup {...props}>
<SidebarGroupLabel>SearchSpace</SidebarGroupLabel>
<SidebarMenu>
{items.map((item, index) => (
{memoizedItems.map((item, index) => (
<SidebarMenuItem key={`${item.title}-${index}`}>
<SidebarMenuButton asChild size="sm">
<SidebarMenuButton asChild size="sm" aria-label={item.title}>
<a href={item.url}>
<item.icon />
<span>{item.title}</span>

View file

@ -1,7 +1,8 @@
"use client";
import { BadgeCheck, ChevronsUpDown, LogOut, Settings } from "lucide-react";
import { BadgeCheck, ChevronsUpDown, LogOut, Settings, User as UserIcon } from "lucide-react";
import { useParams, useRouter } from "next/navigation";
import { memo, useCallback, useEffect, useState } from "react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import {
DropdownMenu,
@ -13,90 +14,163 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
SidebarGroup,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from "@/components/ui/sidebar";
import { apiClient } from "@/lib/api";
export function NavUser({
user,
}: {
user: {
name: string;
email: string;
avatar: string;
};
}) {
interface User {
id: string;
email: string;
is_active: boolean;
is_superuser: boolean;
is_verified: boolean;
}
interface UserData {
name: string;
email: string;
avatar: string;
}
// Memoized NavUser component for better performance
export const NavUser = memo(function NavUser() {
const { isMobile } = useSidebar();
const router = useRouter();
const { search_space_id } = useParams();
const handleLogout = () => {
// User state management
const [user, setUser] = useState<User | null>(null);
const [isLoadingUser, setIsLoadingUser] = useState(true);
const [userError, setUserError] = useState<string | null>(null);
// Fetch user details
useEffect(() => {
const fetchUser = async () => {
try {
if (typeof window === "undefined") return;
try {
const userData = await apiClient.get<User>("users/me");
setUser(userData);
setUserError(null);
} catch (error) {
console.error("Error fetching user:", error);
setUserError(error instanceof Error ? error.message : "Unknown error occurred");
} finally {
setIsLoadingUser(false);
}
} catch (error) {
console.error("Error in fetchUser:", error);
setIsLoadingUser(false);
}
};
fetchUser();
}, []);
// Create user object for display
const userData: UserData = {
name: user?.email ? user.email.split("@")[0] : "User",
email:
user?.email ||
(isLoadingUser ? "Loading..." : userError ? "Error loading user" : "Unknown User"),
avatar: "/icon-128.png", // Default avatar
};
// Memoized logout handler
const handleLogout = useCallback(() => {
if (typeof window !== "undefined") {
localStorage.removeItem("surfsense_bearer_token");
router.push("/");
}
};
}, [router]);
// Get user initials for avatar fallback
const userInitials = userData.name
.split(" ")
.map((n: string) => n[0])
.join("")
.toUpperCase()
.slice(0, 2);
return (
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
size="lg"
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
>
<Avatar className="h-8 w-8 rounded-lg">
<AvatarImage src={user.avatar} alt={user.name} />
<AvatarFallback className="rounded-lg">CN</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">{user.name}</span>
<span className="truncate text-xs">{user.email}</span>
</div>
<ChevronsUpDown className="ml-auto size-4" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
side={isMobile ? "bottom" : "right"}
align="end"
sideOffset={4}
>
<DropdownMenuLabel className="p-0 font-normal">
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<SidebarGroup className="mt-auto">
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
size="lg"
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
aria-label="User menu"
>
<Avatar className="h-8 w-8 rounded-lg">
<AvatarImage src={user.avatar} alt={user.name} />
<AvatarFallback className="rounded-lg">CN</AvatarFallback>
<AvatarImage src={userData.avatar} alt={userData.name} />
<AvatarFallback className="rounded-lg">
{userInitials || <UserIcon className="h-4 w-4" />}
</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">{user.name}</span>
<span className="truncate text-xs">{user.email}</span>
<span className="truncate font-medium">{userData.name}</span>
<span className="truncate text-xs text-muted-foreground">{userData.email}</span>
</div>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<ChevronsUpDown className="ml-auto size-4" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
side={isMobile ? "bottom" : "right"}
align="end"
sideOffset={4}
>
<DropdownMenuLabel className="p-0 font-normal">
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<Avatar className="h-8 w-8 rounded-lg">
<AvatarImage src={userData.avatar} alt={userData.name} />
<AvatarFallback className="rounded-lg">
{userInitials || <UserIcon className="h-4 w-4" />}
</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">{userData.name}</span>
<span className="truncate text-xs text-muted-foreground">{userData.email}</span>
</div>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem
onClick={() => router.push(`/dashboard/${search_space_id}/api-key`)}
aria-label="Manage API key"
>
<BadgeCheck className="h-4 w-4" />
API Key
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => router.push(`/dashboard/${search_space_id}/api-key`)}
onClick={() => router.push(`/settings`)}
aria-label="Go to settings"
>
<BadgeCheck />
API Key
<Settings className="h-4 w-4" />
Settings
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => router.push(`/settings`)}>
<Settings />
Settings
</DropdownMenuItem>
<DropdownMenuItem onClick={handleLogout}>
<LogOut />
Log out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
<DropdownMenuItem
onClick={handleLogout}
aria-label="Sign out"
className="text-destructive focus:text-destructive"
>
<LogOut className="h-4 w-4" />
Sign out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroup>
);
}
});

View file

@ -0,0 +1,100 @@
import { Slot } from "@radix-ui/react-slot";
import { ChevronRight, MoreHorizontal } from "lucide-react";
import { cn } from "@/lib/utils";
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />;
}
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
return (
<ol
data-slot="breadcrumb-list"
className={cn(
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
className
)}
{...props}
/>
);
}
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-item"
className={cn("inline-flex items-center gap-1.5", className)}
{...props}
/>
);
}
function BreadcrumbLink({
asChild,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot : "a";
return (
<Comp
data-slot="breadcrumb-link"
className={cn("hover:text-foreground transition-colors", className)}
{...props}
/>
);
}
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-page"
aria-disabled="true"
aria-current="page"
className={cn("text-foreground font-normal", className)}
{...props}
/>
);
}
function BreadcrumbSeparator({ children, className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-separator"
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:size-3.5", className)}
{...props}
>
{children ?? <ChevronRight />}
</li>
);
}
function BreadcrumbEllipsis({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-ellipsis"
role="presentation"
aria-hidden="true"
className={cn("flex size-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="size-4" />
<span className="sr-only">More</span>
</span>
);
}
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
};