Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 10 additions & 4 deletions app/(main)/academics/AcademicsClientView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import React, { useEffect, useMemo, useState } from "react";
import AcademicsSidebar from "./AcademicsSidebar";
import PdfViewer from "./PdfViewer";
import AcademicsFileSearch from "./AcademicsFileSearch";
import {
ResizableHandle,
ResizablePanel,
Expand Down Expand Up @@ -96,13 +97,13 @@ export default function AcademicsClientView({ initialData }: Props) {
const resolvedYearId = yearIsValid
? (storedYearId as string)
: defaultYear?._id ||
years.find((y) => y.branch?._id === resolvedBranchId)?._id ||
"";
years.find((y) => y.branch?._id === resolvedBranchId)?._id ||
"";
const resolvedSyllabusId = syllabusIsValid
? (storedSyllabusId as string)
: defaultSyllabus?._id ||
syllabi.find((s) => s.academicYear?._id === resolvedYearId)?._id ||
"";
syllabi.find((s) => s.academicYear?._id === resolvedYearId)?._id ||
"";

setSelectedBranchId(resolvedBranchId);
setSelectedYearId(resolvedYearId);
Expand Down Expand Up @@ -239,6 +240,11 @@ export default function AcademicsClientView({ initialData }: Props) {
))}
</SelectContent>
</Select>

{/* Search Files */}
<div className="ml-auto">
<AcademicsFileSearch onFileSelect={handleFileSelect} />
</div>
</div>

{/* Main Panel */}
Expand Down
178 changes: 178 additions & 0 deletions app/(main)/academics/AcademicsFileSearch.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
"use client";

import React, { useState, useEffect, useCallback } from "react";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Search, FileText, BookOpen, Loader2 } from "lucide-react";
import { ActiveFile } from "@/types/file";
import { searchFiles, FileSearchResult } from "@/lib/api/file";

interface AcademicsFileSearchProps {
onFileSelect: (file: ActiveFile) => void;
}

export default function AcademicsFileSearch({
onFileSelect,
}: AcademicsFileSearchProps) {
const [open, setOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [results, setResults] = useState<FileSearchResult[]>([]);
const [isLoading, setIsLoading] = useState(false);

// Debounced search
useEffect(() => {
if (!searchQuery.trim()) {
setResults([]);
return;
}

setIsLoading(true);
const timer = setTimeout(async () => {
try {
const data = await searchFiles({
query: searchQuery,
page: 1,
limit: 10,
});

setResults(data.docs);
} catch (error) {
console.error("Search error:", error);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Currently, search API errors are only logged to the console. For a better user experience, consider displaying a user-facing error message, for example, using a toast notification or showing an error message within the search dialog. This informs the user that something went wrong with their search.

setResults([]);
} finally {
setIsLoading(false);
}
}, 300); // 300ms debounce
Comment on lines +49 to +59

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The search result limit (10) on line 49 and the debounce delay (300) on line 59 are hardcoded. It's a good practice to extract these 'magic numbers' into named constants at the top of the file. This improves readability and makes them easier to modify in the future.

For example:

const SEARCH_RESULTS_LIMIT = 10;
const DEBOUNCE_DELAY_MS = 300;


return () => clearTimeout(timer);
}, [searchQuery]);
Comment on lines +37 to +62

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

This useEffect hook for debounced search has a potential race condition. If a user types quickly, multiple search requests can be initiated. If an earlier, slower request resolves after a later, faster one, the UI will display stale results. To fix this, you should ignore the results of stale requests. You can achieve this by using a flag that is checked before setting state, and toggled in the effect's cleanup function.

    useEffect(() => {
        if (!searchQuery.trim()) {
            setResults([]);
            return;
        }

        let isStale = false;
        setIsLoading(true);
        const timer = setTimeout(async () => {
            try {
                const data = await searchFiles({
                    query: searchQuery,
                    page: 1,
                    limit: 10,
                });

                if (!isStale) {
                    setResults(data.docs);
                }
            } catch (error) {
                if (!isStale) {
                    console.error("Search error:", error);
                    setResults([]);
                }
            } finally {
                if (!isStale) {
                    setIsLoading(false);
                }
            }
        }, 300); // 300ms debounce

        return () => {
            clearTimeout(timer);
            isStale = true;
        };
    }, [searchQuery]);


// Keyboard shortcut
useEffect(() => {
const down = (e: KeyboardEvent) => {
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
setOpen((open) => !open);
}
};

document.addEventListener("keydown", down);
return () => document.removeEventListener("keydown", down);
}, []);

const handleFileSelect = useCallback(
(result: FileSearchResult) => {
if (!result.driveFileId) {
console.error("File does not have a valid ID:", result);
return;
}

onFileSelect({
id: result._id,
driveId: result.driveFileId,
fileName: result.fileName || "Unknown File",
subject: result.subject.name,
});

setOpen(false);
setSearchQuery("");
setResults([]);
},
[onFileSelect]
);

const getCategoryLabel = (category: string, unitNumber?: number) => {
if (category === "unit" && unitNumber) {
return `Unit ${unitNumber}`;
}
if (category === "endsem") return "Previous Year - End-Sem";
if (category === "insem") return "Previous Year - In-Sem";
if (category === "decode") return "Decodes";
if (category === "book") return "Books";
return category;
};
Comment on lines +98 to +107

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The getCategoryLabel function is redefined on every render of the AcademicsFileSearch component. Since it's a pure function that doesn't rely on any component state or props, it can be defined outside the component scope. This prevents unnecessary re-creation and is a good practice for code organization and a slight performance improvement.


return (
<>
<Button
variant="outline"
className="relative w-[200px] justify-start text-sm text-muted-foreground"
onClick={() => setOpen(true)}
>
<Search className="mr-2 h-4 w-4" />
<span>Search files...</span>
<kbd className="pointer-events-none absolute right-1.5 top-1.5 hidden h-6 select-none items-center gap-1 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium opacity-100 sm:flex">
<span className="text-xs">⌘</span>K
</kbd>
</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="p-0 max-w-2xl">
<DialogHeader className="px-4 pt-4 pb-2">
<DialogTitle>Search Files</DialogTitle>
<DialogDescription>
Search across all files globally
</DialogDescription>
</DialogHeader>
<Command shouldFilter={false}>
<CommandInput
placeholder="Type to search files..."
value={searchQuery}
onValueChange={setSearchQuery}
/>
<CommandList className="max-h-[300px]">
{isLoading && (
<div className="flex items-center justify-center p-4">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
)}
{!isLoading && searchQuery && results.length === 0 && (
<CommandEmpty>No files found.</CommandEmpty>
)}
{!isLoading && results.length > 0 && (
<CommandGroup heading="Files">
{results.map((result) => (
<CommandItem
key={result._id}
onSelect={() => handleFileSelect(result)}
className="flex items-start gap-2 px-3 py-2"
>
<FileText className="h-4 w-4 mt-0.5 shrink-0 text-muted-foreground" />
<div className="flex flex-col gap-0.5 flex-1 min-w-0">
<div className="font-medium truncate">
{result.fileName}
</div>
<div className="text-xs text-muted-foreground flex items-center gap-2">
<span className="truncate">
<BookOpen className="h-3 w-3 inline mr-1" />
{result.subject.code} - {result.subject.name}
</span>
<span className="shrink-0 text-[10px] bg-muted px-1.5 py-0.5 rounded">
{getCategoryLabel(result.category, result.unitNumber)}
</span>
</div>
</div>
</CommandItem>
))}
</CommandGroup>
)}
</CommandList>
</Command>
</DialogContent>
</Dialog>
</>
);
}
153 changes: 153 additions & 0 deletions components/ui/command.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
"use client"

import * as React from "react"
import { type DialogProps } from "@radix-ui/react-dialog"
import { Command as CommandPrimitive } from "cmdk"
import { Search } from "lucide-react"

import { cn } from "@/lib/utils"
import { Dialog, DialogContent } from "@/components/ui/dialog"

const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
className
)}
{...props}
/>
))
Command.displayName = CommandPrimitive.displayName

const CommandDialog = ({ children, ...props }: DialogProps) => {
return (
<Dialog {...props}>
<DialogContent className="overflow-hidden p-0 shadow-lg">
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
)
}

const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
ref={ref}
className={cn(
"flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
</div>
))

CommandInput.displayName = CommandPrimitive.Input.displayName

const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
{...props}
/>
))

CommandList.displayName = CommandPrimitive.List.displayName

const CommandEmpty = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => (
<CommandPrimitive.Empty
ref={ref}
className="py-6 text-center text-sm"
{...props}
/>
))

CommandEmpty.displayName = CommandPrimitive.Empty.displayName

const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
className
)}
{...props}
/>
))

CommandGroup.displayName = CommandPrimitive.Group.displayName

const CommandSeparator = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator
ref={ref}
className={cn("-mx-1 h-px bg-border", className)}
{...props}
/>
))
CommandSeparator.displayName = CommandPrimitive.Separator.displayName

const CommandItem = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected='true']:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
className
)}
{...props}
/>
))

CommandItem.displayName = CommandPrimitive.Item.displayName

const CommandShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className
)}
{...props}
/>
)
}
CommandShortcut.displayName = "CommandShortcut"

export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}
Loading