diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..13566b8 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..03d9549 --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..0a75641 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..91010ff --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/polymath.iml b/.idea/polymath.iml new file mode 100644 index 0000000..24643cc --- /dev/null +++ b/.idea/polymath.iml @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/bun.lockb b/bun.lockb new file mode 100755 index 0000000..80bfa68 Binary files /dev/null and b/bun.lockb differ diff --git a/components.json b/components.json new file mode 100644 index 0000000..421c026 --- /dev/null +++ b/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/app/globals.css", + "baseColor": "gray", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "iconLibrary": "lucide" +} \ No newline at end of file diff --git a/src/components/theme-provider.tsx b/src/components/theme-provider.tsx new file mode 100644 index 0000000..1466952 --- /dev/null +++ b/src/components/theme-provider.tsx @@ -0,0 +1,11 @@ +"use client" + +import * as React from "react" +import { ThemeProvider as NextThemesProvider } from "next-themes" + +export function ThemeProvider({ + children, + ...props + }: React.ComponentProps) { + return {children} +} \ No newline at end of file diff --git a/src/components/ui/accordion.tsx b/src/components/ui/accordion.tsx new file mode 100644 index 0000000..4a8cca4 --- /dev/null +++ b/src/components/ui/accordion.tsx @@ -0,0 +1,66 @@ +"use client" + +import * as React from "react" +import * as AccordionPrimitive from "@radix-ui/react-accordion" +import { ChevronDownIcon } from "lucide-react" + +import { cn } from "@/lib/utils" + +function Accordion({ + ...props +}: React.ComponentProps) { + return +} + +function AccordionItem({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AccordionTrigger({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + svg]:rotate-180", + className + )} + {...props} + > + {children} + + + + ) +} + +function AccordionContent({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + +
{children}
+
+ ) +} + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } diff --git a/src/components/ui/alert-dialog.tsx b/src/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..0863e40 --- /dev/null +++ b/src/components/ui/alert-dialog.tsx @@ -0,0 +1,157 @@ +"use client" + +import * as React from "react" +import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" + +import { cn } from "@/lib/utils" +import { buttonVariants } from "@/components/ui/button" + +function AlertDialog({ + ...props +}: React.ComponentProps) { + return +} + +function AlertDialogTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogPortal({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + + ) +} + +function AlertDialogHeader({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDialogFooter({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogAction({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogCancel({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +} diff --git a/src/components/ui/alert.tsx b/src/components/ui/alert.tsx new file mode 100644 index 0000000..1421354 --- /dev/null +++ b/src/components/ui/alert.tsx @@ -0,0 +1,66 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const alertVariants = cva( + "relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current", + { + variants: { + variant: { + default: "bg-card text-card-foreground", + destructive: + "text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function Alert({ + className, + variant, + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
+ ) +} + +function AlertTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDescription({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +export { Alert, AlertTitle, AlertDescription } diff --git a/src/components/ui/articles.tsx b/src/components/ui/articles.tsx new file mode 100644 index 0000000..871d999 --- /dev/null +++ b/src/components/ui/articles.tsx @@ -0,0 +1,532 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { useSearchParams, useRouter } from "next/navigation"; +import { getArticlesPage } from "@/lib/CrossRefAPI"; // Update this import path +import { + Pagination, + PaginationContent, + PaginationEllipsis, + PaginationItem, + PaginationLink, + PaginationNext, + PaginationPrevious, +} from "@/components/ui/pagination"; +import { X } from "lucide-react"; + +type CrossrefWork = { + DOI: string; + title?: string[]; + author?: { + given?: string; + family?: string; + name?: string; // Sometimes CrossRef uses 'name' instead + }[]; + published?: { "date-parts": number[][] }; + type?: string; + "container-title"?: string[]; + publisher?: string; + tags?: string[]; +}; + +type AdvancedFields = { + title?: string; + author?: string; + abstract?: string; +}; + +// Article Element Component +function ArticleElement({ article }: { article: CrossrefWork }) { + // Console log the article object for debugging + console.log("Article Object:", article); + console.log("Author data:", article.author); + + const formatAuthors = (authors?: { given?: string; family?: string; name?: string }[]) => { + // Debug logging + console.log("Formatting authors:", authors); + console.log("Authors type:", typeof authors); + console.log("Authors array?", Array.isArray(authors)); + + if (!authors || authors.length === 0) { + console.log("No authors found for article:", article.title?.[0]); + // For book chapters or other content without individual authors, + // we might want to show the container title or publisher instead + if (article["container-title"]?.[0]) { + return `From: ${article["container-title"][0]}`; + } + if (article.publisher) { + return `Publisher: ${article.publisher}`; + } + return "Unknown Author"; + } + + const authorList = authors.map(author => { + // Check if there's a 'name' field first (sometimes CrossRef uses this) + if (author.name) { + return author.name.trim(); + } + + // Handle cases where given or family might be missing + const given = author.given?.trim() || ""; + const family = author.family?.trim() || ""; + + // If both are empty, skip this author + if (!given && !family) return null; + + // Format based on what's available + if (given && family) return `${given} ${family}`; + if (family) return family; + if (given) return given; + + return null; + }).filter(Boolean); // Remove null entries + + console.log("Processed author list:", authorList); + + if (authorList.length === 0) return "Unknown Author"; + + // Limit to first 3 authors for smaller display + if (authorList.length > 3) { + return authorList.slice(0, 3).join(", ") + " et al."; + } + return authorList.join(", "); + }; + + const formatDate = (published?: { "date-parts": number[][] }) => { + if (!published || !published["date-parts"] || !published["date-parts"][0]) { + return "Date unknown"; + } + const [year] = published["date-parts"][0]; + return year; + }; + + return ( +
+ {/* Title */} +

+ {article.title?.[0] || "Untitled Article"} +

+ + {/* Authors and Meta Info */} +
+

{formatAuthors(article.author)} • {formatDate(article.published)}

+ {article["container-title"]?.[0] && ( +

{article["container-title"][0]}

+ )} +
+ + {/* Tags - Show only first 4 */} + {article.tags && article.tags.length > 0 && ( +
+
+ {article.tags.slice(0, 4).map((tag, index) => ( + + {tag} + + ))} + {article.tags.length > 4 && ( + + +{article.tags.length - 4} more + + )} +
+
+ )} + + {/* DOI */} +
+ DOI: {article.DOI} + + View Article + +
+
+ ); +} + +// Main Articles Container Component +export default function ArticlesContainer({ + searchQuery, + page = 1, + pageSize = 10, + advancedFields}: { + searchQuery?: string; + page?: number; + pageSize?: number; + advancedFields?: AdvancedFields; +}) { + const [articles, setArticles] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [hasNextPage, setHasNextPage] = useState(true); + + const searchParams = useSearchParams(); + const router = useRouter(); + + const currentPage = parseInt(searchParams.get("p") || "1"); + const currentSearchQuery = searchParams.get("q") || ""; + const currentPageSize = searchParams.get("pageSize") || "10"; + + // Get advanced fields from URL params + const currentTitleQuery = searchParams.get("title") || ""; + const currentAuthorQuery = searchParams.get("author") || ""; + const currentAbstractQuery = searchParams.get("abstract") || ""; + + // Generate URL for pagination + const generatePageUrl = (targetPage: number) => { + const params = new URLSearchParams(); + if (currentSearchQuery) params.set("q", currentSearchQuery); + if (currentTitleQuery) params.set("title", currentTitleQuery); + if (currentAuthorQuery) params.set("author", currentAuthorQuery); + if (currentAbstractQuery) params.set("abstract", currentAbstractQuery); + params.set("p", targetPage.toString()); + params.set("pageSize", currentPageSize); + return `/search?${params.toString()}`; + }; + + // Remove individual filter + const removeFilter = (filterType: 'q' | 'title' | 'author' | 'abstract') => { + const params = new URLSearchParams(); + + // Keep all current params except the one being removed + if (filterType !== 'q' && currentSearchQuery) params.set("q", currentSearchQuery); + if (filterType !== 'title' && currentTitleQuery) params.set("title", currentTitleQuery); + if (filterType !== 'author' && currentAuthorQuery) params.set("author", currentAuthorQuery); + if (filterType !== 'abstract' && currentAbstractQuery) params.set("abstract", currentAbstractQuery); + + // Reset to page 1 when filters change + params.set("p", "1"); + params.set("pageSize", currentPageSize); + + router.push(`/search?${params.toString()}`); + }; + + // Clear all filters + const clearAllFilters = () => { + router.push(`/search?p=1&pageSize=${currentPageSize}`); + }; + + // Handle page navigation + const navigateToPage = (targetPage: number) => { + if (targetPage < 1) return; + router.push(generatePageUrl(targetPage)); + }; + + useEffect(() => { + const fetchArticles = async () => { + setLoading(true); + setError(null); + + try { + // Create advanced fields object from current URL params or props + const currentAdvancedFields: AdvancedFields | undefined = + (currentTitleQuery || currentAuthorQuery || currentAbstractQuery || advancedFields) ? { + title: currentTitleQuery || advancedFields?.title, + author: currentAuthorQuery || advancedFields?.author, + abstract: currentAbstractQuery || advancedFields?.abstract + } : undefined; + + console.log("Fetching with params:", { + page: currentPage, + pageSize: parseInt(currentPageSize), + searchQuery: currentSearchQuery, + advancedFields: currentAdvancedFields + }); + + const results = await getArticlesPage( + currentPage, + parseInt(currentPageSize), + currentSearchQuery, + currentAdvancedFields + ); + + // If we're searching by author, filter results to only include articles with authors + let filteredResults = results; + if (currentAdvancedFields?.author && currentAdvancedFields.author.trim()) { + console.log("Filtering results for author search:", currentAdvancedFields.author); + console.log("Results before filtering:", results.length); + + const searchTerms = currentAdvancedFields.author.toLowerCase().split(' ').filter(term => term.length > 0); + console.log("Search terms:", searchTerms); + + filteredResults = results.filter(article => { + // Only include articles that have author data + if (!article.author || article.author.length === 0) { + return false; + } + + // Check if the searched author name appears in any of the authors + return article.author.some(author => { + const given = (author.given || '').toLowerCase(); + const family = (author.family || '').toLowerCase(); + const fullName = `${given} ${family}`.trim(); + + console.log(`Checking author: "${fullName}" against search terms:`, searchTerms); + + // Check if all search terms appear somewhere in the author's name + const matchesAllTerms = searchTerms.every(term => + given.includes(term) || family.includes(term) || fullName.includes(term) + ); + + // Also check for partial matches (at least 2 terms if searching for more than 2 words) + const matchesPartially = searchTerms.length > 2 ? + searchTerms.filter(term => + given.includes(term) || family.includes(term) || fullName.includes(term) + ).length >= 2 : + matchesAllTerms; + + console.log(`Author "${fullName}" matches: ${matchesPartially}`); + return matchesPartially; + }); + }); + + console.log("Results after filtering:", filteredResults.length); + console.log("Filtered articles:", filteredResults.map(a => ({ + title: a.title?.[0], + authors: a.author?.map(auth => `${auth.given} ${auth.family}`) + }))); + } + + console.log("Setting articles:", filteredResults.length); + setArticles(filteredResults); + + // Determine if there's a next page based on results + setHasNextPage(filteredResults.length === parseInt(currentPageSize)); + } catch (err) { + console.error("Error fetching articles:", err); + setError(err instanceof Error ? err.message : "Failed to fetch articles"); + setArticles([]); + setHasNextPage(false); + } finally { + setLoading(false); + } + }; + + fetchArticles(); + }, [currentSearchQuery, currentPage, currentPageSize, currentTitleQuery, currentAuthorQuery, currentAbstractQuery, advancedFields?.title, advancedFields?.author, advancedFields?.abstract]); + + if (loading) { + return ( +
+
+
+
+
+ ); + } + + if (error) { + return ( +
+
+

Error loading articles:

+

{error}

+
+
+ ); + } + + if (articles.length === 0) { + return ( +
+
+

No articles found

+

Try adjusting your search query

+
+
+ ); + } + + return ( +
+ {/* Active Filters Display */} + {(currentSearchQuery || currentTitleQuery || currentAuthorQuery || currentAbstractQuery) && ( +
+
+
+

Active Filters:

+ +
+
+ {currentSearchQuery && ( +
+ General: {currentSearchQuery} + +
+ )} + {currentTitleQuery && ( +
+ Title: {currentTitleQuery} + +
+ )} + {currentAuthorQuery && ( +
+ Author: {currentAuthorQuery} + +
+ )} + {currentAbstractQuery && ( +
+ Abstract: {currentAbstractQuery} + +
+ )} +
+
+
+ )} + + {/* Articles List */} + {articles.map((article) => ( + + ))} + + {/* Fixed Pagination at Bottom */} +
+
+ + + {/* Previous Button */} + + 1 ? generatePageUrl(currentPage - 1) : "#"} + onClick={(e) => { + e.preventDefault(); + if (currentPage > 1) navigateToPage(currentPage - 1); + }} + className={`text-white hover:bg-gray-700 border-gray-600 ${currentPage <= 1 ? "pointer-events-none opacity-50" : ""}`} + /> + + + {/* Show a few pages around current */} + {currentPage > 2 && ( + + { + e.preventDefault(); + navigateToPage(currentPage - 2); + }} + className="text-white hover:bg-gray-700 border-gray-600" + > + {currentPage - 2} + + + )} + + {currentPage > 1 && ( + + { + e.preventDefault(); + navigateToPage(currentPage - 1); + }} + className="text-white hover:bg-gray-700 border-gray-600" + > + {currentPage - 1} + + + )} + + {/* Current Page */} + + + {currentPage} + + + + {/* Next pages */} + {hasNextPage && ( + + { + e.preventDefault(); + navigateToPage(currentPage + 1); + }} + className="text-white hover:bg-gray-700 border-gray-600" + > + {currentPage + 1} + + + )} + + {hasNextPage && ( + + { + e.preventDefault(); + navigateToPage(currentPage + 2); + }} + className="text-white hover:bg-gray-700 border-gray-600" + > + {currentPage + 2} + + + )} + + {/* Ellipsis if there are likely more pages */} + {hasNextPage && currentPage > 1 && ( + + + + )} + + {/* Next Button */} + + { + e.preventDefault(); + if (hasNextPage) navigateToPage(currentPage + 1); + }} + className={`text-white hover:bg-gray-700 border-gray-600 ${!hasNextPage ? "pointer-events-none opacity-50" : ""}`} + /> + + + +
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/ui/aspect-ratio.tsx b/src/components/ui/aspect-ratio.tsx new file mode 100644 index 0000000..3df3fd0 --- /dev/null +++ b/src/components/ui/aspect-ratio.tsx @@ -0,0 +1,11 @@ +"use client" + +import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio" + +function AspectRatio({ + ...props +}: React.ComponentProps) { + return +} + +export { AspectRatio } diff --git a/src/components/ui/avatar.tsx b/src/components/ui/avatar.tsx new file mode 100644 index 0000000..71e428b --- /dev/null +++ b/src/components/ui/avatar.tsx @@ -0,0 +1,53 @@ +"use client" + +import * as React from "react" +import * as AvatarPrimitive from "@radix-ui/react-avatar" + +import { cn } from "@/lib/utils" + +function Avatar({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AvatarImage({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AvatarFallback({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { Avatar, AvatarImage, AvatarFallback } diff --git a/src/components/ui/background-gradient.tsx b/src/components/ui/background-gradient.tsx new file mode 100644 index 0000000..4a9a5db --- /dev/null +++ b/src/components/ui/background-gradient.tsx @@ -0,0 +1,73 @@ +import { cn } from "@/lib/utils"; +import React from "react"; +import { motion } from "motion/react"; + +export const BackgroundGradient = ({ + children, + className, + containerClassName, + animate = true, +}: { + children?: React.ReactNode; + className?: string; + containerClassName?: string; + animate?: boolean; +}) => { + const variants = { + initial: { + backgroundPosition: "0 50%", + }, + animate: { + backgroundPosition: ["0, 50%", "100% 50%", "0 50%"], + }, + }; + return ( +
+ + + + +
{children}
+
+ ); +}; diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx new file mode 100644 index 0000000..0205413 --- /dev/null +++ b/src/components/ui/badge.tsx @@ -0,0 +1,46 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const badgeVariants = cva( + "inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden", + { + variants: { + variant: { + default: + "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90", + secondary: + "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90", + destructive: + "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", + outline: + "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function Badge({ + className, + variant, + asChild = false, + ...props +}: React.ComponentProps<"span"> & + VariantProps & { asChild?: boolean }) { + const Comp = asChild ? Slot : "span" + + return ( + + ) +} + +export { Badge, badgeVariants } diff --git a/src/components/ui/breadcrumb.tsx b/src/components/ui/breadcrumb.tsx new file mode 100644 index 0000000..eb88f32 --- /dev/null +++ b/src/components/ui/breadcrumb.tsx @@ -0,0 +1,109 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { ChevronRight, MoreHorizontal } from "lucide-react" + +import { cn } from "@/lib/utils" + +function Breadcrumb({ ...props }: React.ComponentProps<"nav">) { + return