Spaces:
Sleeping
Sleeping
| import { useState, useEffect } from "react"; | |
| import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; | |
| import { Button } from "@/components/ui/button"; | |
| import { Input } from "@/components/ui/input"; | |
| import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; | |
| import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; | |
| import { Calendar as CalendarComponent } from "@/components/ui/calendar"; | |
| import { cn } from "@/lib/utils"; | |
| import { format } from "date-fns"; | |
| import { DateRange } from "@/types"; | |
| import { RetrievedSource } from "@/services/apiService"; | |
| import { storage, STORAGE_KEYS } from "@/lib/storage"; | |
| import { | |
| Search, | |
| ArrowUpDown, | |
| Calendar, | |
| FileText, | |
| ChevronDown, | |
| ChevronUp, | |
| ExternalLink | |
| } from "lucide-react"; | |
| interface SourcesModalProps { | |
| open: boolean; | |
| onOpenChange: (open: boolean) => void; | |
| } | |
| export const SourcesModal = ({ open, onOpenChange }: SourcesModalProps) => { | |
| const [searchTerm, setSearchTerm] = useState(""); | |
| const [selectedSource, setSelectedSource] = useState<string>(""); | |
| const [selectedCategory, setSelectedCategory] = useState<string>(""); | |
| const [date, setDate] = useState<DateRange>({ | |
| from: undefined, | |
| to: undefined, | |
| }); | |
| const [sortBy, setSortBy] = useState<string>("date-desc"); | |
| const [sources, setSources] = useState<RetrievedSource[]>([]); | |
| const [sourceTypes, setSourceTypes] = useState<string[]>([]); | |
| const [categories, setCategories] = useState<string[]>([]); | |
| // Load sources from storage | |
| useEffect(() => { | |
| if (open) { | |
| const storedSources = storage.get<RetrievedSource[]>(STORAGE_KEYS.SOURCES) || []; | |
| setSources(storedSources); | |
| // Extract unique source types and categories | |
| const types = Array.from(new Set(storedSources.map(s => s.metadata?.source).filter(Boolean))); | |
| setSourceTypes(types as string[]); | |
| const cats = Array.from(new Set(storedSources.map(s => { | |
| // For this example, we'll use the first word of the content as a mock category | |
| const firstWord = s.content_snippet.split(' ')[0]; | |
| return firstWord.length > 3 ? firstWord : "General"; | |
| }))); | |
| setCategories(cats); | |
| } | |
| }, [open]); | |
| // Filter sources based on search term, source, category, and date | |
| const filteredSources = sources.filter(source => { | |
| const matchesSearch = !searchTerm || | |
| source.content_snippet.toLowerCase().includes(searchTerm.toLowerCase()) || | |
| (source.metadata?.source || "").toLowerCase().includes(searchTerm.toLowerCase()); | |
| const matchesSource = !selectedSource || selectedSource === "All" || source.metadata?.source === selectedSource; | |
| // Mock category matching based on first word of content | |
| const sourceCategory = source.content_snippet.split(' ')[0].length > 3 ? | |
| source.content_snippet.split(' ')[0] : "General"; | |
| const matchesCategory = !selectedCategory || selectedCategory === "All" || sourceCategory === selectedCategory; | |
| let matchesDate = true; | |
| if (date.from && source.metadata?.ruling_date) { | |
| matchesDate = matchesDate && new Date(source.metadata.ruling_date) >= date.from; | |
| } | |
| if (date.to && source.metadata?.ruling_date) { | |
| matchesDate = matchesDate && new Date(source.metadata.ruling_date) <= date.to; | |
| } | |
| return matchesSearch && matchesSource && matchesCategory && matchesDate; | |
| }); | |
| // Sort sources | |
| const sortedSources = [...filteredSources].sort((a, b) => { | |
| if (sortBy === "date-desc") { | |
| return new Date(b.metadata?.ruling_date || "").getTime() - | |
| new Date(a.metadata?.ruling_date || "").getTime(); | |
| } else if (sortBy === "date-asc") { | |
| return new Date(a.metadata?.ruling_date || "").getTime() - | |
| new Date(b.metadata?.ruling_date || "").getTime(); | |
| } else if (sortBy === "relevance-desc") { | |
| return b.content_snippet.length - a.content_snippet.length; | |
| } | |
| return 0; | |
| }); | |
| const resetFilters = () => { | |
| setSearchTerm(""); | |
| setSelectedSource(""); | |
| setSelectedCategory(""); | |
| setDate({ from: undefined, to: undefined }); | |
| }; | |
| const [expandedSources, setExpandedSources] = useState<Record<number, boolean>>({}); | |
| const toggleSource = (index: number) => { | |
| setExpandedSources(prev => ({ | |
| ...prev, | |
| [index]: !prev[index] | |
| })); | |
| }; | |
| return ( | |
| <Dialog open={open} onOpenChange={onOpenChange}> | |
| <DialogContent className="w-full max-w-4xl"> | |
| <DialogHeader> | |
| <DialogTitle className="flex items-center text-xl"> | |
| <FileText className="mr-2 h-5 w-5" /> | |
| Knowledge Sources | |
| </DialogTitle> | |
| </DialogHeader> | |
| <div> | |
| <div className="flex flex-col md:flex-row md:items-center justify-between mb-2"> | |
| <div className="flex flex-col md:flex-row space-y-2 md:space-y-0 md:space-x-2"> | |
| <Select value={sortBy} onValueChange={setSortBy}> | |
| <SelectTrigger className="w-[180px]"> | |
| <div className="flex items-center"> | |
| <ArrowUpDown className="mr-2 h-3.5 w-3.5 text-muted-foreground" /> | |
| <span>Sort By</span> | |
| </div> | |
| </SelectTrigger> | |
| <SelectContent> | |
| <SelectItem value="date-desc">Date (Newest)</SelectItem> | |
| <SelectItem value="date-asc">Date (Oldest)</SelectItem> | |
| <SelectItem value="relevance-desc">Relevance</SelectItem> | |
| </SelectContent> | |
| </Select> | |
| </div> | |
| </div> | |
| <div className="bg-accent/30 rounded-lg p-1 mb-2"> | |
| <div className="flex flex-col md:flex-row space-y-1 md:space-y-0 md:space-x-2"> | |
| <div className="relative flex-1"> | |
| <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground h-4 w-4" /> | |
| <Input | |
| type="text" | |
| placeholder="Search sources..." | |
| value={searchTerm} | |
| onChange={(e) => setSearchTerm(e.target.value)} | |
| className="pl-10" | |
| /> | |
| </div> | |
| <Select value={selectedSource} onValueChange={setSelectedSource}> | |
| <SelectTrigger className="w-full md:w-[180px]"> | |
| <span className="truncate"> | |
| {selectedSource || "All Sources"} | |
| </span> | |
| </SelectTrigger> | |
| <SelectContent> | |
| <SelectItem value="All">All Sources</SelectItem> | |
| {sourceTypes.map(type => ( | |
| <SelectItem key={type} value={type}>{type}</SelectItem> | |
| ))} | |
| </SelectContent> | |
| </Select> | |
| <Select value={selectedCategory} onValueChange={setSelectedCategory}> | |
| <SelectTrigger className="w-full md:w-[180px]"> | |
| <span className="truncate"> | |
| {selectedCategory || "All Categories"} | |
| </span> | |
| </SelectTrigger> | |
| <SelectContent> | |
| <SelectItem value="All">All Categories</SelectItem> | |
| {categories.map(category => ( | |
| <SelectItem key={category} value={category}>{category}</SelectItem> | |
| ))} | |
| </SelectContent> | |
| </Select> | |
| <Popover> | |
| <PopoverTrigger asChild> | |
| <Button variant="outline" className="w-full md:w-[180px] justify-start text-left"> | |
| <Calendar className="mr-2 h-4 w-4" /> | |
| <span> | |
| {date.from || date.to ? ( | |
| <> | |
| {date.from ? format(date.from, "LLL dd, y") : "From"} - {" "} | |
| {date.to ? format(date.to, "LLL dd, y") : "To"} | |
| </> | |
| ) : ( | |
| "Date Range" | |
| )} | |
| </span> | |
| </Button> | |
| </PopoverTrigger> | |
| <PopoverContent className="w-auto p-0"> | |
| <CalendarComponent | |
| mode="range" | |
| selected={date} | |
| onSelect={(value: DateRange | undefined) => { | |
| if (value) setDate(value); | |
| }} | |
| className="p-3" | |
| /> | |
| </PopoverContent> | |
| </Popover> | |
| <Button | |
| variant="outline" | |
| className="md:w-auto" | |
| onClick={resetFilters} | |
| > | |
| Reset | |
| </Button> | |
| </div> | |
| </div> | |
| <div className="max-h-[45dvh] md:max-h-[60vh] overflow-y-auto space-y-4"> | |
| {sortedSources.length > 0 ? ( | |
| sortedSources.map((source, index) => ( | |
| <div key={index} className="border rounded-lg overflow-hidden transition-all duration-300"> | |
| <div className="p-4 bg-card"> | |
| <div className="flex items-start justify-between"> | |
| <div> | |
| <h3 className="font-medium"> | |
| {source.metadata?.source || "Source"} | |
| </h3> | |
| <div className="flex items-center mt-1 text-sm text-muted-foreground"> | |
| <FileText className="h-3.5 w-3.5 mr-1.5" /> | |
| <span>{source.metadata?.source || "Unknown Source"}</span> | |
| {source.metadata?.ruling_date && ( | |
| <> | |
| <span className="mx-1.5">•</span> | |
| <Calendar className="h-3.5 w-3.5 mr-1.5" /> | |
| <span>{new Date(source.metadata.ruling_date).toLocaleDateString()}</span> | |
| </> | |
| )} | |
| </div> | |
| </div> | |
| <Button | |
| variant="ghost" | |
| size="sm" | |
| onClick={() => toggleSource(index)} | |
| className="p-0 h-8 w-8 hover:bg-accent/10" | |
| > | |
| {expandedSources[index] ? ( | |
| <ChevronUp className="h-4 w-4" /> | |
| ) : ( | |
| <ChevronDown className="h-4 w-4" /> | |
| )} | |
| </Button> | |
| </div> | |
| <div className={expandedSources[index] ? "" : "max-h-10 md:max-h-16 overflow-hidden relative"}> | |
| <p className="text-sm text-muted-foreground mt-2"> | |
| {source.content_snippet} | |
| </p> | |
| {!expandedSources[index] && ( | |
| <div className="absolute bottom-0 left-0 right-0 h-12 bg-gradient-to-t from-card to-transparent"></div> | |
| )} | |
| </div> | |
| {expandedSources[index] && ( | |
| <div className="mt-4 text-sm flex justify-end"> | |
| <Button variant="link" size="sm" className="h-8 p-0 text-primary"> | |
| <ExternalLink className="h-3 w-3 mr-1" /> | |
| View source | |
| </Button> | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| )) | |
| ) : ( | |
| <div className="text-center p-8"> | |
| <div className="w-16 h-16 mx-auto mb-4 rounded-full bg-muted/30 flex items-center justify-center"> | |
| <FileText className="h-8 w-8 text-muted-foreground" /> | |
| </div> | |
| <h3 className="text-lg font-medium mb-2">No sources found</h3> | |
| <p className="text-muted-foreground"> | |
| {sources.length > 0 | |
| ? "No sources match your current search criteria. Try adjusting your filters." | |
| : "Chat with Insight AI to get information with source citations."} | |
| </p> | |
| <Button | |
| variant="outline" | |
| className="mt-4" | |
| onClick={resetFilters} | |
| > | |
| Reset Filters | |
| </Button> | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| </DialogContent> | |
| </Dialog> | |
| ); | |
| }; | |