Skip to content

Commit 840ff22

Browse files
committed
basic flow
1 parent a0e8700 commit 840ff22

7 files changed

Lines changed: 603 additions & 4 deletions

File tree

app/(main)/academics/AcademicsClientView.tsx

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import React, { useEffect, useMemo, useState } from "react";
55
import AcademicsSidebar from "./AcademicsSidebar";
66
import PdfViewer from "./PdfViewer";
7+
import AcademicsFileSearch from "./AcademicsFileSearch";
78
import {
89
ResizableHandle,
910
ResizablePanel,
@@ -96,13 +97,13 @@ export default function AcademicsClientView({ initialData }: Props) {
9697
const resolvedYearId = yearIsValid
9798
? (storedYearId as string)
9899
: defaultYear?._id ||
99-
years.find((y) => y.branch?._id === resolvedBranchId)?._id ||
100-
"";
100+
years.find((y) => y.branch?._id === resolvedBranchId)?._id ||
101+
"";
101102
const resolvedSyllabusId = syllabusIsValid
102103
? (storedSyllabusId as string)
103104
: defaultSyllabus?._id ||
104-
syllabi.find((s) => s.academicYear?._id === resolvedYearId)?._id ||
105-
"";
105+
syllabi.find((s) => s.academicYear?._id === resolvedYearId)?._id ||
106+
"";
106107

107108
setSelectedBranchId(resolvedBranchId);
108109
setSelectedYearId(resolvedYearId);
@@ -239,6 +240,11 @@ export default function AcademicsClientView({ initialData }: Props) {
239240
))}
240241
</SelectContent>
241242
</Select>
243+
244+
{/* Search Files */}
245+
<div className="ml-auto">
246+
<AcademicsFileSearch onFileSelect={handleFileSelect} />
247+
</div>
242248
</div>
243249

244250
{/* Main Panel */}
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
"use client";
2+
3+
import React, { useState, useEffect, useCallback } from "react";
4+
import {
5+
Command,
6+
CommandEmpty,
7+
CommandGroup,
8+
CommandInput,
9+
CommandItem,
10+
CommandList,
11+
} from "@/components/ui/command";
12+
import {
13+
Dialog,
14+
DialogContent,
15+
DialogDescription,
16+
DialogHeader,
17+
DialogTitle,
18+
} from "@/components/ui/dialog";
19+
import { Button } from "@/components/ui/button";
20+
import { Search, FileText, BookOpen, Loader2 } from "lucide-react";
21+
import { ActiveFile } from "@/types/file";
22+
import { searchFiles, FileSearchResult } from "@/lib/api/file";
23+
24+
interface AcademicsFileSearchProps {
25+
onFileSelect: (file: ActiveFile) => void;
26+
}
27+
28+
export default function AcademicsFileSearch({
29+
onFileSelect,
30+
}: AcademicsFileSearchProps) {
31+
const [open, setOpen] = useState(false);
32+
const [searchQuery, setSearchQuery] = useState("");
33+
const [results, setResults] = useState<FileSearchResult[]>([]);
34+
const [isLoading, setIsLoading] = useState(false);
35+
36+
// Debounced search
37+
useEffect(() => {
38+
if (!searchQuery.trim()) {
39+
setResults([]);
40+
return;
41+
}
42+
43+
setIsLoading(true);
44+
const timer = setTimeout(async () => {
45+
try {
46+
const data = await searchFiles({
47+
query: searchQuery,
48+
page: 1,
49+
limit: 10,
50+
});
51+
52+
setResults(data.docs);
53+
} catch (error) {
54+
console.error("Search error:", error);
55+
setResults([]);
56+
} finally {
57+
setIsLoading(false);
58+
}
59+
}, 300); // 300ms debounce
60+
61+
return () => clearTimeout(timer);
62+
}, [searchQuery]);
63+
64+
// Keyboard shortcut
65+
useEffect(() => {
66+
const down = (e: KeyboardEvent) => {
67+
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
68+
e.preventDefault();
69+
setOpen((open) => !open);
70+
}
71+
};
72+
73+
document.addEventListener("keydown", down);
74+
return () => document.removeEventListener("keydown", down);
75+
}, []);
76+
77+
const handleFileSelect = useCallback(
78+
(result: FileSearchResult) => {
79+
if (!result.driveFileId) {
80+
console.error("File does not have a valid ID:", result);
81+
return;
82+
}
83+
84+
onFileSelect({
85+
id: result._id,
86+
driveId: result.driveFileId,
87+
fileName: result.fileName || "Unknown File",
88+
subject: result.subject.name,
89+
});
90+
91+
setOpen(false);
92+
setSearchQuery("");
93+
setResults([]);
94+
},
95+
[onFileSelect]
96+
);
97+
98+
const getCategoryLabel = (category: string, unitNumber?: number) => {
99+
if (category === "unit" && unitNumber) {
100+
return `Unit ${unitNumber}`;
101+
}
102+
if (category === "endsem") return "Previous Year - End-Sem";
103+
if (category === "insem") return "Previous Year - In-Sem";
104+
if (category === "decode") return "Decodes";
105+
if (category === "book") return "Books";
106+
return category;
107+
};
108+
109+
return (
110+
<>
111+
<Button
112+
variant="outline"
113+
className="relative w-[200px] justify-start text-sm text-muted-foreground"
114+
onClick={() => setOpen(true)}
115+
>
116+
<Search className="mr-2 h-4 w-4" />
117+
<span>Search files...</span>
118+
<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">
119+
<span className="text-xs"></span>K
120+
</kbd>
121+
</Button>
122+
<Dialog open={open} onOpenChange={setOpen}>
123+
<DialogContent className="p-0 max-w-2xl">
124+
<DialogHeader className="px-4 pt-4 pb-2">
125+
<DialogTitle>Search Files</DialogTitle>
126+
<DialogDescription>
127+
Search across all files globally
128+
</DialogDescription>
129+
</DialogHeader>
130+
<Command shouldFilter={false}>
131+
<CommandInput
132+
placeholder="Type to search files..."
133+
value={searchQuery}
134+
onValueChange={setSearchQuery}
135+
/>
136+
<CommandList className="max-h-[300px]">
137+
{isLoading && (
138+
<div className="flex items-center justify-center p-4">
139+
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
140+
</div>
141+
)}
142+
{!isLoading && searchQuery && results.length === 0 && (
143+
<CommandEmpty>No files found.</CommandEmpty>
144+
)}
145+
{!isLoading && results.length > 0 && (
146+
<CommandGroup heading="Files">
147+
{results.map((result) => (
148+
<CommandItem
149+
key={result._id}
150+
onSelect={() => handleFileSelect(result)}
151+
className="flex items-start gap-2 px-3 py-2"
152+
>
153+
<FileText className="h-4 w-4 mt-0.5 shrink-0 text-muted-foreground" />
154+
<div className="flex flex-col gap-0.5 flex-1 min-w-0">
155+
<div className="font-medium truncate">
156+
{result.fileName}
157+
</div>
158+
<div className="text-xs text-muted-foreground flex items-center gap-2">
159+
<span className="truncate">
160+
<BookOpen className="h-3 w-3 inline mr-1" />
161+
{result.subject.code} - {result.subject.name}
162+
</span>
163+
<span className="shrink-0 text-[10px] bg-muted px-1.5 py-0.5 rounded">
164+
{getCategoryLabel(result.category, result.unitNumber)}
165+
</span>
166+
</div>
167+
</div>
168+
</CommandItem>
169+
))}
170+
</CommandGroup>
171+
)}
172+
</CommandList>
173+
</Command>
174+
</DialogContent>
175+
</Dialog>
176+
</>
177+
);
178+
}

components/ui/command.tsx

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
"use client"
2+
3+
import * as React from "react"
4+
import { type DialogProps } from "@radix-ui/react-dialog"
5+
import { Command as CommandPrimitive } from "cmdk"
6+
import { Search } from "lucide-react"
7+
8+
import { cn } from "@/lib/utils"
9+
import { Dialog, DialogContent } from "@/components/ui/dialog"
10+
11+
const Command = React.forwardRef<
12+
React.ElementRef<typeof CommandPrimitive>,
13+
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
14+
>(({ className, ...props }, ref) => (
15+
<CommandPrimitive
16+
ref={ref}
17+
className={cn(
18+
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
19+
className
20+
)}
21+
{...props}
22+
/>
23+
))
24+
Command.displayName = CommandPrimitive.displayName
25+
26+
const CommandDialog = ({ children, ...props }: DialogProps) => {
27+
return (
28+
<Dialog {...props}>
29+
<DialogContent className="overflow-hidden p-0 shadow-lg">
30+
<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">
31+
{children}
32+
</Command>
33+
</DialogContent>
34+
</Dialog>
35+
)
36+
}
37+
38+
const CommandInput = React.forwardRef<
39+
React.ElementRef<typeof CommandPrimitive.Input>,
40+
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
41+
>(({ className, ...props }, ref) => (
42+
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
43+
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
44+
<CommandPrimitive.Input
45+
ref={ref}
46+
className={cn(
47+
"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",
48+
className
49+
)}
50+
{...props}
51+
/>
52+
</div>
53+
))
54+
55+
CommandInput.displayName = CommandPrimitive.Input.displayName
56+
57+
const CommandList = React.forwardRef<
58+
React.ElementRef<typeof CommandPrimitive.List>,
59+
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
60+
>(({ className, ...props }, ref) => (
61+
<CommandPrimitive.List
62+
ref={ref}
63+
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
64+
{...props}
65+
/>
66+
))
67+
68+
CommandList.displayName = CommandPrimitive.List.displayName
69+
70+
const CommandEmpty = React.forwardRef<
71+
React.ElementRef<typeof CommandPrimitive.Empty>,
72+
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
73+
>((props, ref) => (
74+
<CommandPrimitive.Empty
75+
ref={ref}
76+
className="py-6 text-center text-sm"
77+
{...props}
78+
/>
79+
))
80+
81+
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
82+
83+
const CommandGroup = React.forwardRef<
84+
React.ElementRef<typeof CommandPrimitive.Group>,
85+
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
86+
>(({ className, ...props }, ref) => (
87+
<CommandPrimitive.Group
88+
ref={ref}
89+
className={cn(
90+
"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",
91+
className
92+
)}
93+
{...props}
94+
/>
95+
))
96+
97+
CommandGroup.displayName = CommandPrimitive.Group.displayName
98+
99+
const CommandSeparator = React.forwardRef<
100+
React.ElementRef<typeof CommandPrimitive.Separator>,
101+
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
102+
>(({ className, ...props }, ref) => (
103+
<CommandPrimitive.Separator
104+
ref={ref}
105+
className={cn("-mx-1 h-px bg-border", className)}
106+
{...props}
107+
/>
108+
))
109+
CommandSeparator.displayName = CommandPrimitive.Separator.displayName
110+
111+
const CommandItem = React.forwardRef<
112+
React.ElementRef<typeof CommandPrimitive.Item>,
113+
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
114+
>(({ className, ...props }, ref) => (
115+
<CommandPrimitive.Item
116+
ref={ref}
117+
className={cn(
118+
"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",
119+
className
120+
)}
121+
{...props}
122+
/>
123+
))
124+
125+
CommandItem.displayName = CommandPrimitive.Item.displayName
126+
127+
const CommandShortcut = ({
128+
className,
129+
...props
130+
}: React.HTMLAttributes<HTMLSpanElement>) => {
131+
return (
132+
<span
133+
className={cn(
134+
"ml-auto text-xs tracking-widest text-muted-foreground",
135+
className
136+
)}
137+
{...props}
138+
/>
139+
)
140+
}
141+
CommandShortcut.displayName = "CommandShortcut"
142+
143+
export {
144+
Command,
145+
CommandDialog,
146+
CommandInput,
147+
CommandList,
148+
CommandEmpty,
149+
CommandGroup,
150+
CommandItem,
151+
CommandShortcut,
152+
CommandSeparator,
153+
}

0 commit comments

Comments
 (0)