mirror of
https://github.com/MODSetter/SurfSense.git
synced 2025-09-05 03:59:06 +00:00
Merge pull request #258 from Utkarsh-Patel-13/fix/UI-Improvements
Some checks are pending
pre-commit / pre-commit (push) Waiting to run
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:
commit
356bbb86f5
23 changed files with 2244 additions and 1655 deletions
|
@ -6,7 +6,13 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.future import select
|
from sqlalchemy.future import select
|
||||||
|
|
||||||
from app.db import Chat, SearchSpace, User, get_async_session
|
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.tasks.stream_connector_search_results import stream_connector_search_results
|
||||||
from app.users import current_active_user
|
from app.users import current_active_user
|
||||||
from app.utils.check_ownership import check_ownership
|
from app.utils.check_ownership import check_ownership
|
||||||
|
@ -112,7 +118,7 @@ async def create_chat(
|
||||||
) from None
|
) from None
|
||||||
|
|
||||||
|
|
||||||
@router.get("/chats/", response_model=list[ChatRead])
|
@router.get("/chats/", response_model=list[ChatReadWithoutMessages])
|
||||||
async def read_chats(
|
async def read_chats(
|
||||||
skip: int = 0,
|
skip: int = 0,
|
||||||
limit: int = 100,
|
limit: int = 100,
|
||||||
|
@ -121,14 +127,26 @@ async def read_chats(
|
||||||
user: User = Depends(current_active_user),
|
user: User = Depends(current_active_user),
|
||||||
):
|
):
|
||||||
try:
|
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
|
# Filter by search_space_id if provided
|
||||||
if search_space_id is not None:
|
if search_space_id is not None:
|
||||||
query = query.filter(Chat.search_space_id == search_space_id)
|
query = query.filter(Chat.search_space_id == search_space_id)
|
||||||
|
|
||||||
result = await session.execute(query.offset(skip).limit(limit))
|
result = await session.execute(query.offset(skip).limit(limit))
|
||||||
return result.scalars().all()
|
return result.all()
|
||||||
except OperationalError:
|
except OperationalError:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=503, detail="Database operation failed. Please try again later."
|
status_code=503, detail="Database operation failed. Please try again later."
|
||||||
|
|
|
@ -1,5 +1,12 @@
|
||||||
from .base import IDModel, TimestampModel
|
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 .chunks import ChunkBase, ChunkCreate, ChunkRead, ChunkUpdate
|
||||||
from .documents import (
|
from .documents import (
|
||||||
DocumentBase,
|
DocumentBase,
|
||||||
|
@ -37,6 +44,7 @@ __all__ = [
|
||||||
"ChatBase",
|
"ChatBase",
|
||||||
"ChatCreate",
|
"ChatCreate",
|
||||||
"ChatRead",
|
"ChatRead",
|
||||||
|
"ChatReadWithoutMessages",
|
||||||
"ChatUpdate",
|
"ChatUpdate",
|
||||||
"ChunkBase",
|
"ChunkBase",
|
||||||
"ChunkCreate",
|
"ChunkCreate",
|
||||||
|
|
|
@ -15,6 +15,12 @@ class ChatBase(BaseModel):
|
||||||
search_space_id: int
|
search_space_id: int
|
||||||
|
|
||||||
|
|
||||||
|
class ChatBaseWithoutMessages(BaseModel):
|
||||||
|
type: ChatType
|
||||||
|
title: str
|
||||||
|
search_space_id: int
|
||||||
|
|
||||||
|
|
||||||
class ClientAttachment(BaseModel):
|
class ClientAttachment(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
content_type: str
|
content_type: str
|
||||||
|
@ -50,3 +56,7 @@ class ChatUpdate(ChatBase):
|
||||||
|
|
||||||
class ChatRead(ChatBase, IDModel, TimestampModel):
|
class ChatRead(ChatBase, IDModel, TimestampModel):
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
class ChatReadWithoutMessages(ChatBaseWithoutMessages, IDModel, TimestampModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
|
@ -19,14 +19,7 @@ import { useEffect, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import { Card, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardFooter,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/components/ui/card";
|
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
|
@ -61,24 +54,16 @@ import {
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
interface Chat {
|
interface Chat {
|
||||||
created_at: string;
|
created_at: string;
|
||||||
id: number;
|
id: number;
|
||||||
type: string;
|
type: string;
|
||||||
title: string;
|
title: string;
|
||||||
messages: ChatMessage[];
|
|
||||||
search_space_id: number;
|
search_space_id: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ChatMessage {
|
|
||||||
id: string;
|
|
||||||
createdAt: string;
|
|
||||||
role: string;
|
|
||||||
content: string;
|
|
||||||
parts?: any;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ChatsPageClientProps {
|
interface ChatsPageClientProps {
|
||||||
searchSpaceId: string;
|
searchSpaceId: string;
|
||||||
}
|
}
|
||||||
|
@ -580,12 +565,12 @@ export default function ChatsPageClient({ searchSpaceId }: ChatsPageClientProps)
|
||||||
animate="animate"
|
animate="animate"
|
||||||
exit="exit"
|
exit="exit"
|
||||||
transition={{ duration: 0.2, delay: index * 0.05 }}
|
transition={{ duration: 0.2, delay: index * 0.05 }}
|
||||||
className={`overflow-hidden hover:shadow-md transition-shadow
|
className={cn(
|
||||||
${
|
"overflow-hidden hover:shadow-md transition-shadow",
|
||||||
selectionMode && selectedChats.includes(chat.id)
|
selectionMode && selectedChats.includes(chat.id)
|
||||||
? "ring-2 ring-primary ring-offset-2"
|
? "ring-2 ring-primary ring-offset-2"
|
||||||
: ""
|
: ""
|
||||||
}`}
|
)}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
if (!selectionMode) return;
|
if (!selectionMode) return;
|
||||||
// Ignore clicks coming from interactive elements
|
// Ignore clicks coming from interactive elements
|
||||||
|
@ -672,24 +657,21 @@ export default function ChatsPageClient({ searchSpaceId }: ChatsPageClientProps)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
|
||||||
<div className="text-sm text-muted-foreground line-clamp-3">
|
<CardFooter className="flex items-center justify-between gap-2 w-full">
|
||||||
{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>
|
|
||||||
<Badge variant="secondary" className="text-xs">
|
<Badge variant="secondary" className="text-xs">
|
||||||
<Tag className="mr-1 h-3 w-3" />
|
<Tag className="mr-1 h-3 w-3" />
|
||||||
{chat.type || "Unknown"}
|
{chat.type || "Unknown"}
|
||||||
</Badge>
|
</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>
|
</CardFooter>
|
||||||
</MotionCard>
|
</MotionCard>
|
||||||
))}
|
))}
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { DashboardBreadcrumb } from "@/components/dashboard-breadcrumb";
|
||||||
import { AppSidebarProvider } from "@/components/sidebar/AppSidebarProvider";
|
import { AppSidebarProvider } from "@/components/sidebar/AppSidebarProvider";
|
||||||
import { ThemeTogglerComponent } from "@/components/theme/theme-toggle";
|
import { ThemeTogglerComponent } from "@/components/theme/theme-toggle";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
@ -17,8 +19,18 @@ export function DashboardClientLayout({
|
||||||
navSecondary: any[];
|
navSecondary: any[];
|
||||||
navMain: 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 (
|
return (
|
||||||
<SidebarProvider>
|
<SidebarProvider open={open} onOpenChange={setOpen}>
|
||||||
{/* Use AppSidebarProvider which fetches user, search space, and recent chats */}
|
{/* Use AppSidebarProvider which fetches user, search space, and recent chats */}
|
||||||
<AppSidebarProvider
|
<AppSidebarProvider
|
||||||
searchSpaceId={searchSpaceId}
|
searchSpaceId={searchSpaceId}
|
||||||
|
@ -26,10 +38,13 @@ export function DashboardClientLayout({
|
||||||
navMain={navMain}
|
navMain={navMain}
|
||||||
/>
|
/>
|
||||||
<SidebarInset>
|
<SidebarInset>
|
||||||
<header className="flex h-16 shrink-0 items-center gap-2">
|
<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 gap-2 px-4">
|
<div className="flex items-center justify-between w-full gap-2 px-4">
|
||||||
<SidebarTrigger className="-ml-1" />
|
<div className="flex items-center gap-2">
|
||||||
<Separator orientation="vertical" className="h-6" />
|
<SidebarTrigger className="-ml-1" />
|
||||||
|
<Separator orientation="vertical" className="h-6" />
|
||||||
|
<DashboardBreadcrumb />
|
||||||
|
</div>
|
||||||
<ThemeTogglerComponent />
|
<ThemeTogglerComponent />
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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;
|
||||||
|
};
|
File diff suppressed because it is too large
Load diff
|
@ -1,12 +1,17 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { AnimatePresence, motion } from "framer-motion";
|
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 { useParams, useRouter } from "next/navigation";
|
||||||
import { useCallback, useRef, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { useDropzone } from "react-dropzone";
|
import { useDropzone } from "react-dropzone";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
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
|
// Grid pattern component inspired by Aceternity UI
|
||||||
function GridPattern() {
|
function GridPattern() {
|
||||||
|
@ -34,14 +39,13 @@ function GridPattern() {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function FileUploader() {
|
export default function FileUploader() {
|
||||||
// Use the useParams hook to get the params
|
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const search_space_id = params.search_space_id as string;
|
const search_space_id = params.search_space_id as string;
|
||||||
|
|
||||||
const [files, setFiles] = useState<File[]>([]);
|
const [files, setFiles] = useState<File[]>([]);
|
||||||
const [isUploading, setIsUploading] = useState(false);
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
|
const [uploadProgress, setUploadProgress] = useState(0);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
// Audio files are always supported (using whisper)
|
// Audio files are always supported (using whisper)
|
||||||
const audioFileTypes = {
|
const audioFileTypes = {
|
||||||
|
@ -204,7 +208,6 @@ export default function FileUploader() {
|
||||||
};
|
};
|
||||||
|
|
||||||
const acceptedFileTypes = getAcceptedFileTypes();
|
const acceptedFileTypes = getAcceptedFileTypes();
|
||||||
|
|
||||||
const supportedExtensions = Array.from(new Set(Object.values(acceptedFileTypes).flat())).sort();
|
const supportedExtensions = Array.from(new Set(Object.values(acceptedFileTypes).flat())).sort();
|
||||||
|
|
||||||
const onDrop = useCallback((acceptedFiles: File[]) => {
|
const onDrop = useCallback((acceptedFiles: File[]) => {
|
||||||
|
@ -215,12 +218,10 @@ export default function FileUploader() {
|
||||||
onDrop,
|
onDrop,
|
||||||
accept: acceptedFileTypes,
|
accept: acceptedFileTypes,
|
||||||
maxSize: 50 * 1024 * 1024, // 50MB
|
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) => {
|
const removeFile = (index: number) => {
|
||||||
setFiles((prevFiles) => prevFiles.filter((_, i) => i !== index));
|
setFiles((prevFiles) => prevFiles.filter((_, i) => i !== index));
|
||||||
};
|
};
|
||||||
|
@ -235,6 +236,7 @@ export default function FileUploader() {
|
||||||
|
|
||||||
const handleUpload = async () => {
|
const handleUpload = async () => {
|
||||||
setIsUploading(true);
|
setIsUploading(true);
|
||||||
|
setUploadProgress(0);
|
||||||
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
files.forEach((file) => {
|
files.forEach((file) => {
|
||||||
|
@ -244,12 +246,16 @@ export default function FileUploader() {
|
||||||
formData.append("search_space_id", search_space_id);
|
formData.append("search_space_id", search_space_id);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// toast("File Upload", {
|
// Simulate progress for better UX
|
||||||
// description: "Files Uploading Initiated",
|
const progressInterval = setInterval(() => {
|
||||||
// })
|
setUploadProgress((prev) => {
|
||||||
|
if (prev >= 90) return prev;
|
||||||
|
return prev + Math.random() * 10;
|
||||||
|
});
|
||||||
|
}, 200);
|
||||||
|
|
||||||
const response = await fetch(
|
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",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
|
@ -259,6 +265,9 @@ export default function FileUploader() {
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
clearInterval(progressInterval);
|
||||||
|
setUploadProgress(100);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error("Upload failed");
|
throw new Error("Upload failed");
|
||||||
}
|
}
|
||||||
|
@ -272,31 +281,15 @@ export default function FileUploader() {
|
||||||
router.push(`/dashboard/${search_space_id}/documents`);
|
router.push(`/dashboard/${search_space_id}/documents`);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
setIsUploading(false);
|
setIsUploading(false);
|
||||||
|
setUploadProgress(0);
|
||||||
toast("Upload Error", {
|
toast("Upload Error", {
|
||||||
description: `Error uploading files: ${error.message}`,
|
description: `Error uploading files: ${error.message}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const mainVariant = {
|
const getTotalFileSize = () => {
|
||||||
initial: {
|
return files.reduce((total, file) => total + file.size, 0);
|
||||||
x: 0,
|
|
||||||
y: 0,
|
|
||||||
},
|
|
||||||
animate: {
|
|
||||||
x: 20,
|
|
||||||
y: -20,
|
|
||||||
opacity: 0.9,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const secondaryVariant = {
|
|
||||||
initial: {
|
|
||||||
opacity: 0,
|
|
||||||
},
|
|
||||||
animate: {
|
|
||||||
opacity: 1,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const containerVariants = {
|
const containerVariants = {
|
||||||
|
@ -326,251 +319,252 @@ export default function FileUploader() {
|
||||||
return (
|
return (
|
||||||
<div className="grow flex items-center justify-center p-4 md:p-8">
|
<div className="grow flex items-center justify-center p-4 md:p-8">
|
||||||
<motion.div
|
<motion.div
|
||||||
className="w-full max-w-3xl mx-auto"
|
className="w-full max-w-4xl mx-auto space-y-6"
|
||||||
initial="hidden"
|
initial="hidden"
|
||||||
animate="visible"
|
animate="visible"
|
||||||
variants={containerVariants}
|
variants={containerVariants}
|
||||||
>
|
>
|
||||||
<motion.div
|
{/* Header Card */}
|
||||||
className="bg-background rounded-xl shadow-lg overflow-hidden border border-border"
|
<motion.div variants={itemVariants}>
|
||||||
variants={itemVariants}
|
<Card>
|
||||||
>
|
<CardHeader>
|
||||||
<motion.div
|
<CardTitle className="flex items-center gap-2">
|
||||||
className="p-10 group/file block rounded-lg cursor-pointer w-full relative overflow-hidden"
|
<Upload className="h-5 w-5" />
|
||||||
whileHover="animate"
|
Upload Documents
|
||||||
onClick={handleClick}
|
</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 */}
|
{/* 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 />
|
<GridPattern />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative z-10">
|
<CardContent className="p-10 relative z-10">
|
||||||
{/* Dropzone area */}
|
<div
|
||||||
<div {...getRootProps()} className="flex flex-col items-center justify-center">
|
{...getRootProps()}
|
||||||
<input {...getInputProps()} ref={fileInputRef} className="hidden" />
|
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"
|
||||||
|
|
||||||
<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 }}
|
|
||||||
>
|
>
|
||||||
<div className="mb-4 flex items-center justify-between">
|
<input {...getInputProps()} className="hidden" />
|
||||||
<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([]);
|
|
||||||
|
|
||||||
// Force a re-render after animation completes
|
{isDragActive ? (
|
||||||
setTimeout(() => {
|
<motion.div
|
||||||
const event = new Event("resize");
|
initial={{ opacity: 0, scale: 0.8 }}
|
||||||
window.dispatchEvent(event);
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
}, 350);
|
className="flex flex-col items-center gap-4"
|
||||||
}}
|
|
||||||
disabled={isUploading}
|
|
||||||
>
|
>
|
||||||
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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
<div className="space-y-4 max-h-[400px] overflow-y-auto pr-2 custom-scrollbar">
|
{/* File List Card */}
|
||||||
<AnimatePresence>
|
<AnimatePresence mode="wait">
|
||||||
{files.map((file, index) => (
|
{files.length > 0 && (
|
||||||
<motion.div
|
<motion.div
|
||||||
key={`${file.name}-${index}`}
|
variants={itemVariants}
|
||||||
layoutId={index === 0 ? "file-upload" : `file-upload-${index}`}
|
initial={{ opacity: 0, height: 0 }}
|
||||||
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"
|
animate={{ opacity: 1, height: "auto" }}
|
||||||
initial="hidden"
|
exit={{ opacity: 0, height: 0 }}
|
||||||
animate="visible"
|
transition={{ duration: 0.3 }}
|
||||||
exit="exit"
|
>
|
||||||
variants={fileItemVariants}
|
<Card>
|
||||||
>
|
<CardHeader>
|
||||||
<div className="flex justify-between w-full items-center gap-4">
|
<div className="flex items-center justify-between">
|
||||||
<motion.p
|
<div>
|
||||||
initial={{ opacity: 0 }}
|
<CardTitle>Selected Files ({files.length})</CardTitle>
|
||||||
animate={{ opacity: 1 }}
|
<CardDescription>
|
||||||
layout
|
Total size: {formatFileSize(getTotalFileSize())}
|
||||||
className="text-base text-neutral-700 dark:text-neutral-300 truncate max-w-xs font-medium"
|
</CardDescription>
|
||||||
>
|
</div>
|
||||||
{file.name}
|
<Button
|
||||||
</motion.p>
|
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">
|
<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
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => removeFile(index)}
|
onClick={() => removeFile(index)}
|
||||||
disabled={isUploading}
|
disabled={isUploading}
|
||||||
className="h-8 w-8"
|
className="h-8 w-8"
|
||||||
aria-label={`Remove ${file.name}`}
|
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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>
|
</motion.div>
|
||||||
<span>Uploading...</span>
|
))}
|
||||||
</motion.div>
|
</AnimatePresence>
|
||||||
) : (
|
</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>
|
|
||||||
|
|
||||||
{/* File type information */}
|
{isUploading && (
|
||||||
<motion.div className="px-8 pb-8" variants={itemVariants}>
|
<motion.div
|
||||||
<div className="p-4 bg-muted rounded-lg">
|
initial={{ opacity: 0, y: 10 }}
|
||||||
<div className="flex items-center gap-2 mb-3">
|
animate={{ opacity: 1, y: 0 }}
|
||||||
<Tag className="h-4 w-4 text-primary" />
|
className="mt-6 space-y-3"
|
||||||
<p className="text-sm font-medium">Supported file types:</p>
|
>
|
||||||
</div>
|
<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">
|
<div className="flex flex-wrap gap-2">
|
||||||
{supportedExtensions.map((ext) => (
|
{supportedExtensions.map((ext) => (
|
||||||
<motion.span
|
<Badge key={ext} variant="outline" className="text-xs">
|
||||||
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
|
|
||||||
>
|
|
||||||
{ext}
|
{ext}
|
||||||
</motion.span>
|
</Badge>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</CardContent>
|
||||||
</motion.div>
|
</Card>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
|
|
|
@ -31,7 +31,6 @@ export default function DashboardLayout({
|
||||||
title: "Researcher",
|
title: "Researcher",
|
||||||
url: `/dashboard/${search_space_id}/researcher`,
|
url: `/dashboard/${search_space_id}/researcher`,
|
||||||
icon: "SquareTerminal",
|
icon: "SquareTerminal",
|
||||||
isActive: true,
|
|
||||||
items: [],
|
items: [],
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
15
surfsense_web/app/dashboard/[search_space_id]/page.tsx
Normal file
15
surfsense_web/app/dashboard/[search_space_id]/page.tsx
Normal 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 <></>;
|
||||||
|
}
|
169
surfsense_web/components/dashboard-breadcrumb.tsx
Normal file
169
surfsense_web/components/dashboard-breadcrumb.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -1,7 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Trash2 } from "lucide-react";
|
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 { AppSidebar } from "@/components/sidebar/app-sidebar";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
|
@ -12,7 +12,7 @@ import {
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { apiClient } from "@/lib/api"; // Import the API client
|
import { apiClient } from "@/lib/api";
|
||||||
|
|
||||||
interface Chat {
|
interface Chat {
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
@ -80,66 +80,82 @@ export function AppSidebarProvider({
|
||||||
setIsClient(true);
|
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
|
// Fetch recent chats
|
||||||
useEffect(() => {
|
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();
|
fetchRecentChats();
|
||||||
|
|
||||||
// Set up a refresh interval (every 5 minutes)
|
// Set up a refresh interval (every 5 minutes)
|
||||||
|
@ -147,144 +163,144 @@ export function AppSidebarProvider({
|
||||||
|
|
||||||
// Clean up interval on component unmount
|
// Clean up interval on component unmount
|
||||||
return () => clearInterval(intervalId);
|
return () => clearInterval(intervalId);
|
||||||
}, [searchSpaceId]);
|
}, [fetchRecentChats]);
|
||||||
|
|
||||||
// Handle delete chat
|
// Fetch search space details
|
||||||
const handleDeleteChat = async () => {
|
useEffect(() => {
|
||||||
|
fetchSearchSpace();
|
||||||
|
}, [fetchSearchSpace]);
|
||||||
|
|
||||||
|
// Handle delete chat with better error handling
|
||||||
|
const handleDeleteChat = useCallback(async () => {
|
||||||
if (!chatToDelete) return;
|
if (!chatToDelete) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setIsDeleting(true);
|
setIsDeleting(true);
|
||||||
|
|
||||||
// Use the API client instead of direct fetch
|
|
||||||
await apiClient.delete(`api/v1/chats/${chatToDelete.id}`);
|
await apiClient.delete(`api/v1/chats/${chatToDelete.id}`);
|
||||||
|
|
||||||
// Close dialog and refresh chats
|
// Update local state
|
||||||
setRecentChats(recentChats.filter((chat) => chat.id !== chatToDelete.id));
|
setRecentChats((prev) => prev.filter((chat) => chat.id !== chatToDelete.id));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error deleting chat:", error);
|
console.error("Error deleting chat:", error);
|
||||||
|
// You could show a toast notification here
|
||||||
} finally {
|
} finally {
|
||||||
setIsDeleting(false);
|
setIsDeleting(false);
|
||||||
setShowDeleteDialog(false);
|
setShowDeleteDialog(false);
|
||||||
setChatToDelete(null);
|
setChatToDelete(null);
|
||||||
}
|
}
|
||||||
};
|
}, [chatToDelete]);
|
||||||
|
|
||||||
// Fetch search space details
|
// Memoized fallback chats
|
||||||
useEffect(() => {
|
const fallbackChats = useMemo(() => {
|
||||||
const fetchSearchSpace = async () => {
|
if (chatError) {
|
||||||
try {
|
return [
|
||||||
// Only run on client-side
|
{
|
||||||
if (typeof window === "undefined") return;
|
name: "Error loading chats",
|
||||||
|
url: "#",
|
||||||
|
icon: "AlertCircle",
|
||||||
|
id: 0,
|
||||||
|
search_space_id: Number(searchSpaceId),
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
name: "Retry",
|
||||||
|
icon: "RefreshCw",
|
||||||
|
onClick: retryFetch,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
if (!isLoadingChats && recentChats.length === 0) {
|
||||||
// Use the API client instead of direct fetch
|
return [
|
||||||
const data: SearchSpace = await apiClient.get<SearchSpace>(
|
{
|
||||||
`api/v1/searchspaces/${searchSpaceId}`
|
name: "No recent chats",
|
||||||
);
|
url: "#",
|
||||||
setSearchSpace(data);
|
icon: "MessageCircleMore",
|
||||||
setSearchSpaceError(null);
|
id: 0,
|
||||||
} catch (error) {
|
search_space_id: Number(searchSpaceId),
|
||||||
console.error("Error fetching search space:", error);
|
actions: [],
|
||||||
setSearchSpaceError(error instanceof Error ? error.message : "Unknown error occurred");
|
},
|
||||||
} finally {
|
];
|
||||||
setIsLoadingSearchSpace(false);
|
}
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error in fetchSearchSpace:", error);
|
|
||||||
setIsLoadingSearchSpace(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchSearchSpace();
|
return [];
|
||||||
}, [searchSpaceId]);
|
}, [chatError, isLoadingChats, recentChats.length, searchSpaceId, retryFetch]);
|
||||||
|
|
||||||
// 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: [],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: [];
|
|
||||||
|
|
||||||
// Use fallback chats if there's an error or no chats
|
// Use fallback chats if there's an error or no chats
|
||||||
const displayChats = recentChats.length > 0 ? recentChats : fallbackChats;
|
const displayChats = recentChats.length > 0 ? recentChats : fallbackChats;
|
||||||
|
|
||||||
// Update the first item in navSecondary to show the search space name
|
// Memoized updated navSecondary
|
||||||
const updatedNavSecondary = [...navSecondary];
|
const updatedNavSecondary = useMemo(() => {
|
||||||
if (updatedNavSecondary.length > 0 && isClient) {
|
const updated = [...navSecondary];
|
||||||
updatedNavSecondary[0] = {
|
if (updated.length > 0 && isClient) {
|
||||||
...updatedNavSecondary[0],
|
updated[0] = {
|
||||||
title:
|
...updated[0],
|
||||||
searchSpace?.name ||
|
title:
|
||||||
(isLoadingSearchSpace
|
searchSpace?.name ||
|
||||||
? "Loading..."
|
(isLoadingSearchSpace
|
||||||
: searchSpaceError
|
? "Loading..."
|
||||||
? "Error loading search space"
|
: searchSpaceError
|
||||||
: "Unknown Search Space"),
|
? "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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<AppSidebar
|
<AppSidebar navSecondary={updatedNavSecondary} navMain={navMain} RecentChats={displayChats} />
|
||||||
navSecondary={updatedNavSecondary}
|
|
||||||
navMain={navMain}
|
|
||||||
RecentChats={isClient ? displayChats : []}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Delete Confirmation Dialog - Only render on client */}
|
{/* Delete Confirmation Dialog */}
|
||||||
{isClient && (
|
<Dialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||||
<Dialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
<DialogContent className="sm:max-w-md">
|
||||||
<DialogContent className="sm:max-w-md">
|
<DialogHeader>
|
||||||
<DialogHeader>
|
<DialogTitle className="flex items-center gap-2">
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<Trash2 className="h-5 w-5 text-destructive" />
|
||||||
<Trash2 className="h-5 w-5 text-destructive" />
|
<span>Delete Chat</span>
|
||||||
<span>Delete Chat</span>
|
</DialogTitle>
|
||||||
</DialogTitle>
|
<DialogDescription>
|
||||||
<DialogDescription>
|
Are you sure you want to delete{" "}
|
||||||
Are you sure you want to delete{" "}
|
<span className="font-medium">{chatToDelete?.name}</span>? This action cannot be
|
||||||
<span className="font-medium">{chatToDelete?.name}</span>? This action cannot be
|
undone.
|
||||||
undone.
|
</DialogDescription>
|
||||||
</DialogDescription>
|
</DialogHeader>
|
||||||
</DialogHeader>
|
<DialogFooter className="flex gap-2 sm:justify-end">
|
||||||
<DialogFooter className="flex gap-2 sm:justify-end">
|
<Button
|
||||||
<Button
|
variant="outline"
|
||||||
variant="outline"
|
onClick={() => setShowDeleteDialog(false)}
|
||||||
onClick={() => setShowDeleteDialog(false)}
|
disabled={isDeleting}
|
||||||
disabled={isDeleting}
|
>
|
||||||
>
|
Cancel
|
||||||
Cancel
|
</Button>
|
||||||
</Button>
|
<Button
|
||||||
<Button
|
variant="destructive"
|
||||||
variant="destructive"
|
onClick={handleDeleteChat}
|
||||||
onClick={handleDeleteChat}
|
disabled={isDeleting}
|
||||||
disabled={isDeleting}
|
className="gap-2"
|
||||||
className="gap-2"
|
>
|
||||||
>
|
{isDeleting ? (
|
||||||
{isDeleting ? (
|
<>
|
||||||
<>
|
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
Deleting...
|
||||||
Deleting...
|
</>
|
||||||
</>
|
) : (
|
||||||
) : (
|
<>
|
||||||
<>
|
<Trash2 className="h-4 w-4" />
|
||||||
<Trash2 className="h-4 w-4" />
|
Delete
|
||||||
Delete
|
</>
|
||||||
</>
|
)}
|
||||||
)}
|
</Button>
|
||||||
</Button>
|
</DialogFooter>
|
||||||
</DialogFooter>
|
</DialogContent>
|
||||||
</DialogContent>
|
</Dialog>
|
||||||
</Dialog>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -17,15 +17,17 @@ import {
|
||||||
Trash2,
|
Trash2,
|
||||||
Undo2,
|
Undo2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useMemo } from "react";
|
import { memo, useMemo } from "react";
|
||||||
|
|
||||||
import { Logo } from "@/components/Logo";
|
import { Logo } from "@/components/Logo";
|
||||||
import { NavMain } from "@/components/sidebar/nav-main";
|
import { NavMain } from "@/components/sidebar/nav-main";
|
||||||
import { NavProjects } from "@/components/sidebar/nav-projects";
|
import { NavProjects } from "@/components/sidebar/nav-projects";
|
||||||
import { NavSecondary } from "@/components/sidebar/nav-secondary";
|
import { NavSecondary } from "@/components/sidebar/nav-secondary";
|
||||||
|
import { NavUser } from "@/components/sidebar/nav-user";
|
||||||
import {
|
import {
|
||||||
Sidebar,
|
Sidebar,
|
||||||
SidebarContent,
|
SidebarContent,
|
||||||
|
SidebarFooter,
|
||||||
SidebarHeader,
|
SidebarHeader,
|
||||||
SidebarMenu,
|
SidebarMenu,
|
||||||
SidebarMenuButton,
|
SidebarMenuButton,
|
||||||
|
@ -64,7 +66,6 @@ const defaultData = {
|
||||||
isActive: true,
|
isActive: true,
|
||||||
items: [],
|
items: [],
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
title: "Documents",
|
title: "Documents",
|
||||||
url: "#",
|
url: "#",
|
||||||
|
@ -154,12 +155,12 @@ interface AppSidebarProps extends React.ComponentProps<typeof Sidebar> {
|
||||||
navSecondary?: {
|
navSecondary?: {
|
||||||
title: string;
|
title: string;
|
||||||
url: string;
|
url: string;
|
||||||
icon: string; // Changed to string (icon name)
|
icon: string;
|
||||||
}[];
|
}[];
|
||||||
RecentChats?: {
|
RecentChats?: {
|
||||||
name: string;
|
name: string;
|
||||||
url: string;
|
url: string;
|
||||||
icon: string; // Changed to string (icon name)
|
icon: string;
|
||||||
id?: number;
|
id?: number;
|
||||||
search_space_id?: number;
|
search_space_id?: number;
|
||||||
actions?: {
|
actions?: {
|
||||||
|
@ -168,9 +169,15 @@ interface AppSidebarProps extends React.ComponentProps<typeof Sidebar> {
|
||||||
onClick: () => void;
|
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,
|
navMain = defaultData.navMain,
|
||||||
navSecondary = defaultData.navSecondary,
|
navSecondary = defaultData.navSecondary,
|
||||||
RecentChats = defaultData.RecentChats,
|
RecentChats = defaultData.RecentChats,
|
||||||
|
@ -180,7 +187,7 @@ export function AppSidebar({
|
||||||
const processedNavMain = useMemo(() => {
|
const processedNavMain = useMemo(() => {
|
||||||
return navMain.map((item) => ({
|
return navMain.map((item) => ({
|
||||||
...item,
|
...item,
|
||||||
icon: iconMap[item.icon] || SquareTerminal, // Fallback to SquareTerminal if icon not found
|
icon: iconMap[item.icon] || SquareTerminal,
|
||||||
}));
|
}));
|
||||||
}, [navMain]);
|
}, [navMain]);
|
||||||
|
|
||||||
|
@ -188,7 +195,7 @@ export function AppSidebar({
|
||||||
const processedNavSecondary = useMemo(() => {
|
const processedNavSecondary = useMemo(() => {
|
||||||
return navSecondary.map((item) => ({
|
return navSecondary.map((item) => ({
|
||||||
...item,
|
...item,
|
||||||
icon: iconMap[item.icon] || Undo2, // Fallback to Undo2 if icon not found
|
icon: iconMap[item.icon] || Undo2,
|
||||||
}));
|
}));
|
||||||
}, [navSecondary]);
|
}, [navSecondary]);
|
||||||
|
|
||||||
|
@ -197,17 +204,17 @@ export function AppSidebar({
|
||||||
return (
|
return (
|
||||||
RecentChats?.map((item) => ({
|
RecentChats?.map((item) => ({
|
||||||
...item,
|
...item,
|
||||||
icon: iconMap[item.icon] || MessageCircleMore, // Fallback to MessageCircleMore if icon not found
|
icon: iconMap[item.icon] || MessageCircleMore,
|
||||||
})) || []
|
})) || []
|
||||||
);
|
);
|
||||||
}, [RecentChats]);
|
}, [RecentChats]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sidebar variant="inset" {...props}>
|
<Sidebar variant="inset" collapsible="icon" aria-label="Main navigation" {...props}>
|
||||||
<SidebarHeader>
|
<SidebarHeader>
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
<SidebarMenuItem>
|
<SidebarMenuItem>
|
||||||
<SidebarMenuButton size="lg" asChild>
|
<SidebarMenuButton size="lg" asChild aria-label="Go to home page">
|
||||||
<div>
|
<div>
|
||||||
<div className="bg-sidebar-primary text-sidebar-primary-foreground flex aspect-square size-8 items-center justify-center rounded-lg">
|
<div className="bg-sidebar-primary text-sidebar-primary-foreground flex aspect-square size-8 items-center justify-center rounded-lg">
|
||||||
<Logo className="rounded-lg" />
|
<Logo className="rounded-lg" />
|
||||||
|
@ -221,11 +228,22 @@ export function AppSidebar({
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
</SidebarHeader>
|
</SidebarHeader>
|
||||||
<SidebarContent>
|
|
||||||
|
<SidebarContent className="space-y-6">
|
||||||
<NavMain items={processedNavMain} />
|
<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>
|
</SidebarContent>
|
||||||
|
<SidebarFooter>
|
||||||
|
<NavSecondary items={processedNavSecondary} className="mt-auto" />
|
||||||
|
|
||||||
|
{/* User Profile Section */}
|
||||||
|
<NavUser />
|
||||||
|
</SidebarFooter>
|
||||||
</Sidebar>
|
</Sidebar>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ChevronRight, type LucideIcon } from "lucide-react";
|
import { ChevronRight, type LucideIcon } from "lucide-react";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
|
||||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||||
import {
|
import {
|
||||||
|
@ -15,46 +16,56 @@ import {
|
||||||
SidebarMenuSubItem,
|
SidebarMenuSubItem,
|
||||||
} from "@/components/ui/sidebar";
|
} from "@/components/ui/sidebar";
|
||||||
|
|
||||||
export function NavMain({
|
interface NavItem {
|
||||||
items,
|
title: string;
|
||||||
}: {
|
url: string;
|
||||||
items: {
|
icon: LucideIcon;
|
||||||
|
isActive?: boolean;
|
||||||
|
items?: {
|
||||||
title: string;
|
title: string;
|
||||||
url: 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 (
|
return (
|
||||||
<SidebarGroup>
|
<SidebarGroup>
|
||||||
<SidebarGroupLabel>Platform</SidebarGroupLabel>
|
<SidebarGroupLabel>Platform</SidebarGroupLabel>
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
{items.map((item, index) => (
|
{memoizedItems.map((item, index) => (
|
||||||
<Collapsible key={`${item.title}-${index}`} asChild defaultOpen={item.isActive}>
|
<Collapsible key={`${item.title}-${index}`} asChild defaultOpen={item.isActive}>
|
||||||
<SidebarMenuItem>
|
<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}>
|
<a href={item.url}>
|
||||||
<item.icon />
|
<item.icon />
|
||||||
<span>{item.title}</span>
|
<span>{item.title}</span>
|
||||||
</a>
|
</a>
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
|
|
||||||
{item.items?.length ? (
|
{item.items?.length ? (
|
||||||
<>
|
<>
|
||||||
<CollapsibleTrigger asChild>
|
<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 />
|
<ChevronRight />
|
||||||
<span className="sr-only">Toggle</span>
|
<span className="sr-only">Toggle submenu</span>
|
||||||
</SidebarMenuAction>
|
</SidebarMenuAction>
|
||||||
</CollapsibleTrigger>
|
</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>
|
<SidebarMenuSub>
|
||||||
{item.items?.map((subItem, subIndex) => (
|
{item.items?.map((subItem, subIndex) => (
|
||||||
<SidebarMenuSubItem key={`${subItem.title}-${subIndex}`}>
|
<SidebarMenuSubItem key={`${subItem.title}-${subIndex}`}>
|
||||||
<SidebarMenuSubButton asChild>
|
<SidebarMenuSubButton asChild aria-label={subItem.title}>
|
||||||
<a href={subItem.url}>
|
<a href={subItem.url}>
|
||||||
<span>{subItem.title}</span>
|
<span>{subItem.title}</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
|
@ -1,17 +1,27 @@
|
||||||
"use client";
|
"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 { useRouter } from "next/navigation";
|
||||||
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import {
|
import {
|
||||||
SidebarGroup,
|
SidebarGroup,
|
||||||
SidebarGroupLabel,
|
SidebarGroupLabel,
|
||||||
|
SidebarInput,
|
||||||
SidebarMenu,
|
SidebarMenu,
|
||||||
SidebarMenuAction,
|
SidebarMenuAction,
|
||||||
SidebarMenuButton,
|
SidebarMenuButton,
|
||||||
|
@ -26,6 +36,8 @@ const actionIconMap: Record<string, LucideIcon> = {
|
||||||
Share,
|
Share,
|
||||||
Trash2,
|
Trash2,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
|
Search,
|
||||||
|
RefreshCw,
|
||||||
};
|
};
|
||||||
|
|
||||||
interface ChatAction {
|
interface ChatAction {
|
||||||
|
@ -34,33 +46,57 @@ interface ChatAction {
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NavProjects({
|
interface ChatItem {
|
||||||
chats,
|
name: string;
|
||||||
}: {
|
url: string;
|
||||||
chats: {
|
icon: LucideIcon;
|
||||||
name: string;
|
id?: number;
|
||||||
url: string;
|
search_space_id?: number;
|
||||||
icon: LucideIcon;
|
actions?: ChatAction[];
|
||||||
id?: number;
|
}
|
||||||
search_space_id?: number;
|
|
||||||
actions?: ChatAction[];
|
export function NavProjects({ chats }: { chats: ChatItem[] }) {
|
||||||
}[];
|
|
||||||
}) {
|
|
||||||
const { isMobile } = useSidebar();
|
const { isMobile } = useSidebar();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const [isDeleting, setIsDeleting] = useState<number | null>(null);
|
||||||
|
|
||||||
const searchSpaceId = chats[0]?.search_space_id || "";
|
const searchSpaceId = chats[0]?.search_space_id || "";
|
||||||
|
|
||||||
return (
|
// Memoized filtered chats
|
||||||
<SidebarGroup className="group-data-[collapsible=icon]:hidden">
|
const filteredChats = useMemo(() => {
|
||||||
<SidebarGroupLabel>Recent Chats</SidebarGroupLabel>
|
if (!searchQuery.trim()) return chats;
|
||||||
<SidebarMenu>
|
|
||||||
{chats.map((item, index) => (
|
return chats.filter((chat) => chat.name.toLowerCase().includes(searchQuery.toLowerCase()));
|
||||||
<SidebarMenuItem key={item.id ? `chat-${item.id}` : `chat-${item.name}-${index}`}>
|
}, [chats, searchQuery]);
|
||||||
<SidebarMenuButton>
|
|
||||||
<item.icon />
|
// Handle chat deletion with loading state
|
||||||
<span>{item.name}</span>
|
const handleDeleteChat = useCallback(async (chatId: number, deleteAction: () => void) => {
|
||||||
</SidebarMenuButton>
|
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>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<SidebarMenuAction showOnHover>
|
<SidebarMenuAction showOnHover>
|
||||||
|
@ -73,44 +109,79 @@ export function NavProjects({
|
||||||
side={isMobile ? "bottom" : "right"}
|
side={isMobile ? "bottom" : "right"}
|
||||||
align={isMobile ? "end" : "start"}
|
align={isMobile ? "end" : "start"}
|
||||||
>
|
>
|
||||||
{item.actions ? (
|
{chat.actions.map((action, actionIndex) => {
|
||||||
// Use the actions provided by the item
|
const ActionIcon = actionIconMap[action.icon] || Folder;
|
||||||
item.actions.map((action, actionIndex) => {
|
const isDeleteAction = action.name.toLowerCase().includes("delete");
|
||||||
const ActionIcon = actionIconMap[action.icon] || Folder;
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
key={`${action.name}-${actionIndex}`}
|
key={`${action.name}-${actionIndex}`}
|
||||||
onClick={action.onClick}
|
onClick={() => {
|
||||||
>
|
if (isDeleteAction) {
|
||||||
<ActionIcon className="text-muted-foreground" />
|
handleDeleteChat(chat.id || 0, action.onClick);
|
||||||
<span>{action.name}</span>
|
} else {
|
||||||
</DropdownMenuItem>
|
action.onClick();
|
||||||
);
|
}
|
||||||
})
|
}}
|
||||||
) : (
|
disabled={isDeletingChat}
|
||||||
// Default actions if none provided
|
className={isDeleteAction ? "text-destructive" : ""}
|
||||||
<>
|
>
|
||||||
<DropdownMenuItem>
|
<ActionIcon className="text-muted-foreground" />
|
||||||
<Folder className="text-muted-foreground" />
|
<span>{isDeletingChat && isDeleteAction ? "Deleting..." : action.name}</span>
|
||||||
<span>View Chat</span>
|
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
);
|
||||||
<DropdownMenuItem>
|
})}
|
||||||
<Trash2 className="text-muted-foreground" />
|
|
||||||
<span>Delete Chat</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</SidebarMenuItem>
|
)}
|
||||||
))}
|
|
||||||
<SidebarMenuItem>
|
|
||||||
<SidebarMenuButton onClick={() => router.push(`/dashboard/${searchSpaceId}/chats`)}>
|
|
||||||
<MoreHorizontal />
|
|
||||||
<span>View All Chats</span>
|
|
||||||
</SidebarMenuButton>
|
|
||||||
</SidebarMenuItem>
|
</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>
|
</SidebarMenu>
|
||||||
</SidebarGroup>
|
</SidebarGroup>
|
||||||
);
|
);
|
||||||
|
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
import type { LucideIcon } from "lucide-react";
|
import type { LucideIcon } from "lucide-react";
|
||||||
import type * as React from "react";
|
import type * as React from "react";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
SidebarGroup,
|
SidebarGroup,
|
||||||
|
@ -11,23 +12,28 @@ import {
|
||||||
SidebarMenuItem,
|
SidebarMenuItem,
|
||||||
} from "@/components/ui/sidebar";
|
} from "@/components/ui/sidebar";
|
||||||
|
|
||||||
|
interface NavSecondaryItem {
|
||||||
|
title: string;
|
||||||
|
url: string;
|
||||||
|
icon: LucideIcon;
|
||||||
|
}
|
||||||
|
|
||||||
export function NavSecondary({
|
export function NavSecondary({
|
||||||
items,
|
items,
|
||||||
...props
|
...props
|
||||||
}: {
|
}: {
|
||||||
items: {
|
items: NavSecondaryItem[];
|
||||||
title: string;
|
|
||||||
url: string;
|
|
||||||
icon: LucideIcon;
|
|
||||||
}[];
|
|
||||||
} & React.ComponentPropsWithoutRef<typeof SidebarGroup>) {
|
} & React.ComponentPropsWithoutRef<typeof SidebarGroup>) {
|
||||||
|
// Memoize items to prevent unnecessary re-renders
|
||||||
|
const memoizedItems = useMemo(() => items, [items]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarGroup {...props}>
|
<SidebarGroup {...props}>
|
||||||
<SidebarGroupLabel>SearchSpace</SidebarGroupLabel>
|
<SidebarGroupLabel>SearchSpace</SidebarGroupLabel>
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
{items.map((item, index) => (
|
{memoizedItems.map((item, index) => (
|
||||||
<SidebarMenuItem key={`${item.title}-${index}`}>
|
<SidebarMenuItem key={`${item.title}-${index}`}>
|
||||||
<SidebarMenuButton asChild size="sm">
|
<SidebarMenuButton asChild size="sm" aria-label={item.title}>
|
||||||
<a href={item.url}>
|
<a href={item.url}>
|
||||||
<item.icon />
|
<item.icon />
|
||||||
<span>{item.title}</span>
|
<span>{item.title}</span>
|
||||||
|
|
|
@ -1,7 +1,8 @@
|
||||||
"use client";
|
"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 { useParams, useRouter } from "next/navigation";
|
||||||
|
import { memo, useCallback, useEffect, useState } from "react";
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
|
@ -13,90 +14,163 @@ import {
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import {
|
import {
|
||||||
|
SidebarGroup,
|
||||||
SidebarMenu,
|
SidebarMenu,
|
||||||
SidebarMenuButton,
|
SidebarMenuButton,
|
||||||
SidebarMenuItem,
|
SidebarMenuItem,
|
||||||
useSidebar,
|
useSidebar,
|
||||||
} from "@/components/ui/sidebar";
|
} from "@/components/ui/sidebar";
|
||||||
|
import { apiClient } from "@/lib/api";
|
||||||
|
|
||||||
export function NavUser({
|
interface User {
|
||||||
user,
|
id: string;
|
||||||
}: {
|
email: string;
|
||||||
user: {
|
is_active: boolean;
|
||||||
name: string;
|
is_superuser: boolean;
|
||||||
email: string;
|
is_verified: boolean;
|
||||||
avatar: string;
|
}
|
||||||
};
|
|
||||||
}) {
|
interface UserData {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
avatar: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Memoized NavUser component for better performance
|
||||||
|
export const NavUser = memo(function NavUser() {
|
||||||
const { isMobile } = useSidebar();
|
const { isMobile } = useSidebar();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { search_space_id } = useParams();
|
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") {
|
if (typeof window !== "undefined") {
|
||||||
localStorage.removeItem("surfsense_bearer_token");
|
localStorage.removeItem("surfsense_bearer_token");
|
||||||
router.push("/");
|
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 (
|
return (
|
||||||
<SidebarMenu>
|
<SidebarGroup className="mt-auto">
|
||||||
<SidebarMenuItem>
|
<SidebarMenu>
|
||||||
<DropdownMenu>
|
<SidebarMenuItem>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenu>
|
||||||
<SidebarMenuButton
|
<DropdownMenuTrigger asChild>
|
||||||
size="lg"
|
<SidebarMenuButton
|
||||||
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
size="lg"
|
||||||
>
|
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
||||||
<Avatar className="h-8 w-8 rounded-lg">
|
aria-label="User menu"
|
||||||
<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">
|
|
||||||
<Avatar className="h-8 w-8 rounded-lg">
|
<Avatar className="h-8 w-8 rounded-lg">
|
||||||
<AvatarImage src={user.avatar} alt={user.name} />
|
<AvatarImage src={userData.avatar} alt={userData.name} />
|
||||||
<AvatarFallback className="rounded-lg">CN</AvatarFallback>
|
<AvatarFallback className="rounded-lg">
|
||||||
|
{userInitials || <UserIcon className="h-4 w-4" />}
|
||||||
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||||
<span className="truncate font-medium">{user.name}</span>
|
<span className="truncate font-medium">{userData.name}</span>
|
||||||
<span className="truncate text-xs">{user.email}</span>
|
<span className="truncate text-xs text-muted-foreground">{userData.email}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<ChevronsUpDown className="ml-auto size-4" />
|
||||||
</DropdownMenuLabel>
|
</SidebarMenuButton>
|
||||||
<DropdownMenuSeparator />
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuGroup>
|
<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
|
<DropdownMenuItem
|
||||||
onClick={() => router.push(`/dashboard/${search_space_id}/api-key`)}
|
onClick={() => router.push(`/settings`)}
|
||||||
|
aria-label="Go to settings"
|
||||||
>
|
>
|
||||||
<BadgeCheck />
|
<Settings className="h-4 w-4" />
|
||||||
API Key
|
Settings
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuGroup>
|
<DropdownMenuItem
|
||||||
<DropdownMenuSeparator />
|
onClick={handleLogout}
|
||||||
<DropdownMenuItem onClick={() => router.push(`/settings`)}>
|
aria-label="Sign out"
|
||||||
<Settings />
|
className="text-destructive focus:text-destructive"
|
||||||
Settings
|
>
|
||||||
</DropdownMenuItem>
|
<LogOut className="h-4 w-4" />
|
||||||
<DropdownMenuItem onClick={handleLogout}>
|
Sign out
|
||||||
<LogOut />
|
</DropdownMenuItem>
|
||||||
Log out
|
</DropdownMenuContent>
|
||||||
</DropdownMenuItem>
|
</DropdownMenu>
|
||||||
</DropdownMenuContent>
|
</SidebarMenuItem>
|
||||||
</DropdownMenu>
|
</SidebarMenu>
|
||||||
</SidebarMenuItem>
|
</SidebarGroup>
|
||||||
</SidebarMenu>
|
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
100
surfsense_web/components/ui/breadcrumb.tsx
Normal file
100
surfsense_web/components/ui/breadcrumb.tsx
Normal 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,
|
||||||
|
};
|
Loading…
Add table
Reference in a new issue