Spaces:
Running
Running
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> | |
); | |
}; | |