-
Notifications
You must be signed in to change notification settings - Fork 0
feat: search files #1
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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); | ||
| setResults([]); | ||
| } finally { | ||
| setIsLoading(false); | ||
| } | ||
| }, 300); // 300ms debounce | ||
|
Comment on lines
+49
to
+59
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The search result limit ( For example: const SEARCH_RESULTS_LIMIT = 10;
const DEBOUNCE_DELAY_MS = 300; |
||
|
|
||
| return () => clearTimeout(timer); | ||
| }, [searchQuery]); | ||
|
Comment on lines
+37
to
+62
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This |
||
|
|
||
| // 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The |
||
|
|
||
| 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> | ||
| </> | ||
| ); | ||
| } | ||
| 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, | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.