diff --git a/frontend/components/BlogCard.tsx b/frontend/components/BlogCard.tsx new file mode 100644 index 0000000..e422435 --- /dev/null +++ b/frontend/components/BlogCard.tsx @@ -0,0 +1,268 @@ +//blog cards +"use client"; + +import { useState } from "react"; +import { + Center, + VStack, + Box, + Button, + Icon, + Text, + Heading, + HStack, + useColorModeValue, + AspectRatio, +} from "@chakra-ui/react"; +import Link from "next/link"; +import Image from "next/image"; +import { motion, useReducedMotion } from "framer-motion"; +import { FaBookOpen, FaCalendarAlt, FaClock, FaArrowRight } from "react-icons/fa"; + +const MotionBox = motion(Box); + +interface Blog { + id: number; + title: string; + description: string; + published_on: string; + image: string | null; + related_link: string | null; + markdown_content: string; + page_url: string; + cover_image?: { src: string; alt: string; caption?: string } | null; + authors?: Array<{ name: string; affiliationId?: string }>; + affiliations?: Array<{ id: string; name: string }>; + publication_links?: Array<{ text: string; url: string; icon?: string }> | null; + sections?: Array<{ + type: string; + heading?: string; + content?: string; + headers?: string[]; + rows?: string[][]; + items?: Array<{ id: string; prompt: string; response: string }>; + image?: { src: string; alt?: string; caption?: string }; + }>; + team?: { + students?: Array<{ name: string }>; + advisors?: Array<{ name: string }>; + contacts?: Array<{ name: string; email?: string }>; + }; + bibtex?: string; +} + +// Helper functions for blog content processing +function getSectionsArray(sections: any): any[] { + if (Array.isArray(sections)) { + return sections; + } + if (typeof sections === 'string') { + try { + const parsed = JSON.parse(sections); + return Array.isArray(parsed) ? parsed : []; + } catch (e) { + return []; + } + } + return []; +} + +function getReadingTime(content: string, sections?: any[]): string { + const WORDS_PER_MINUTE = 225; + let totalText = content || ''; + + if (sections && Array.isArray(sections)) { + sections.forEach(section => { + if (section.heading) totalText += ' ' + section.heading; + if (section.content) totalText += ' ' + section.content; + if (section.type === 'table') { + if (section.headers && Array.isArray(section.headers)) { + totalText += ' ' + section.headers.join(' '); + } + if (section.rows && Array.isArray(section.rows)) { + section.rows.forEach((row: string[]) => { + if (Array.isArray(row)) totalText += ' ' + row.join(' '); + }); + } + } + if (section.type === 'examples' && section.items && Array.isArray(section.items)) { + section.items.forEach((item: { id: string; prompt: string; response: string }) => { + if (item.prompt) totalText += ' ' + item.prompt; + if (item.response) totalText += ' ' + item.response; + }); + } + if (section.image && section.image.caption) totalText += ' ' + section.image.caption; + }); + } + + if (!totalText || totalText.trim().length === 0) return "1 min read"; + + const cleanText = totalText + .replace(/[#*_`~\[\]()]/g, ' ') + .replace(/<[^>]*>/g, ' ') + .replace(/\s+/g, ' ') + .trim(); + + const words = cleanText.split(/\s+/).filter(word => word.length > 0); + const wordCount = words.length; + const minutes = Math.max(1, Math.ceil(wordCount / WORDS_PER_MINUTE)); + return `${minutes} min read`; +} + +function formatDate(dateString: string): string { + return new Date(dateString).toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + }); +} + +// BlogCard Component with Framer Motion animations +export default function BlogCard({ blog, index }: { blog: Blog; index: number }) { + const shouldReduceMotion = useReducedMotion(); + const [imageError, setImageError] = useState(false); + + const cardBg = useColorModeValue("white", "gray.700"); + const borderColor = useColorModeValue("orange.100", "orange.700"); + const textColor = useColorModeValue("gray.600", "gray.300"); + const headingColor = useColorModeValue("gray.800", "white"); + const hoverBorderColor = useColorModeValue("orange.300", "orange.500"); + + const readingTime = getReadingTime(blog.markdown_content, getSectionsArray(blog.sections)); + const authorNames = blog.authors?.map(author => author.name).join(", ") || "AI4Bharat Team"; + const coverImageSrc = blog.image || blog.cover_image?.src; + + return ( + + + + {coverImageSrc && !imageError ? ( + {blog.cover_image?.alt setImageError(true)} + quality={75} + /> + ) : ( +
+ + + + Article + + +
+ )} +
+ + + + + + {formatDate(blog.published_on)} + + + + {readingTime} + + + + + {blog.title} + + + + {blog.description} + + + + By {authorNames.length > 30 ? `${authorNames.substring(0, 30)}...` : authorNames} + + + + +
+
+ ); +} diff --git a/frontend/components/ClientBlogsRenderer.tsx b/frontend/components/ClientBlogsRenderer.tsx new file mode 100644 index 0000000..d1dfcb3 --- /dev/null +++ b/frontend/components/ClientBlogsRenderer.tsx @@ -0,0 +1,178 @@ +//blogs rendering +"use client"; + +import { useState, useMemo } from "react"; +import { + Center, + VStack, + Box, + Button, + Icon, + Input, + InputGroup, + InputLeftElement, + SimpleGrid, + useBreakpointValue, + Text, + Heading, + HStack, + useColorModeValue, +} from "@chakra-ui/react"; +import { AnimatePresence } from "framer-motion"; +import { FaSearch, FaBookOpen } from "react-icons/fa"; +import BlogCard from "./BlogCard"; // Import BlogCard component + +// Blog interface (same as in Server Component) +interface Blog { + id: number; + title: string; + description: string; + published_on: string; + image: string | null; + related_link: string | null; + markdown_content: string; + page_url: string; + cover_image?: { src: string; alt: string; caption?: string } | null; + authors?: Array<{ name: string; affiliationId?: string }>; + affiliations?: Array<{ id: string; name: string }>; + publication_links?: Array<{ text: string; url: string; icon?: string }> | null; + sections?: Array<{ + type: string; + heading?: string; + content?: string; + headers?: string[]; + rows?: string[][]; + items?: Array<{ id: string; prompt: string; response: string }>; + image?: { src: string; alt?: string; caption?: string }; + }>; + team?: { + students?: Array<{ name: string }>; + advisors?: Array<{ name: string }>; + contacts?: Array<{ name: string; email?: string }>; + }; + bibtex?: string; +} + +// Main Client Component for rendering blogs with interactivity and animations +export default function ClientBlogsRenderer({ + initialBlogs, + headingColor, + textColor +}: { + initialBlogs: Blog[]; + headingColor: string; + textColor: string; +}) { + const [searchTerm, setSearchTerm] = useState(""); + const gridColumns = useBreakpointValue({ + base: 1, + md: 2, + lg: 3, + xl: 3 + }); + const inputBg = useColorModeValue("white", "gray.700"); + const borderColor = useColorModeValue("orange.200", "orange.600"); + + const filteredBlogs = useMemo(() => { + if (!searchTerm.trim()) return initialBlogs; + const searchLower = searchTerm.toLowerCase(); + return initialBlogs.filter(blog => + blog.title.toLowerCase().includes(searchLower) || + blog.description.toLowerCase().includes(searchLower) || + blog.authors?.some(author => + author.name.toLowerCase().includes(searchLower) + ) + ); + }, [initialBlogs, searchTerm]); + + return ( + <> + {initialBlogs.length > 0 ? ( + <> + {/* Search and Filter UI */} + + + + + + + setSearchTerm(e.target.value)} + bg={inputBg} + borderColor={borderColor} + borderWidth="1px" + borderRadius="md" + fontSize="md" + _hover={{ borderColor: "orange.300" }} + _focus={{ + borderColor: "orange.400", + boxShadow: "0 0 0 1px orange.400" + }} + _placeholder={{ color: textColor }} + /> + + + + + + + {filteredBlogs.length} {filteredBlogs.length === 1 ? 'article' : 'articles'} + + + + + + + {filteredBlogs.length > 0 ? ( + + + {filteredBlogs.map((blog, index) => ( + + ))} + + + ) : ( +
+ + + + + No articles found + + + Try adjusting your search terms or browse all articles. + + + + +
+ )} + + ) : ( +
+ + + + + Coming Soon + + + We're working on bringing you amazing research content. + Check back soon for the latest insights and innovations. + + + +
+ )} + + ); +} diff --git a/frontend/src/app/blog/[slug]/BlogContentDisplay.tsx b/frontend/src/app/blog/[slug]/BlogContentDisplay.tsx index 442a07a..12943a1 100644 --- a/frontend/src/app/blog/[slug]/BlogContentDisplay.tsx +++ b/frontend/src/app/blog/[slug]/BlogContentDisplay.tsx @@ -1,3 +1,4 @@ +//blog layout "use client"; import React, { useState, useEffect, useCallback } from 'react'; diff --git a/frontend/src/app/blog/[slug]/page.tsx b/frontend/src/app/blog/[slug]/page.tsx index 5590783..ddb0789 100644 --- a/frontend/src/app/blog/[slug]/page.tsx +++ b/frontend/src/app/blog/[slug]/page.tsx @@ -1,12 +1,10 @@ +//main pages import React from 'react'; import { API_URL } from '../../config'; import { notFound } from 'next/navigation'; import { Metadata } from 'next'; import BlogContentDisplay from './BlogContentDisplay'; -export const dynamicParams = true; -export const revalidate = 3600; - interface Blog { id: number; title: string; @@ -37,105 +35,44 @@ interface Blog { bibtex?: string; } -let cachedBlogs: Blog[] | null = null; -let cacheTimestamp: number = 0; -const CACHE_DURATION = 10 * 60 * 1000; - -async function getAllBlogPostsCached(): Promise { - const now = Date.now(); - - if (cachedBlogs && (now - cacheTimestamp) < CACHE_DURATION) { - return cachedBlogs; - } - - const endpoint = `${API_URL}/news/`; - - try { - const res = await fetch(endpoint, { - headers: { - 'Content-Type': 'application/json', - }, - next: { - revalidate: 3600, - tags: ['blog-list'] - } - }); - - if (!res.ok) { - return cachedBlogs || []; - } - - const data = await res.json(); - const blogs = Array.isArray(data) ? data : []; - - cachedBlogs = blogs; - cacheTimestamp = now; - - return blogs; - } catch (error) { - return cachedBlogs || []; - } -} - async function getIdFromPageUrl(pageUrl: string): Promise { - const blogs = await getAllBlogPostsCached(); - const blog = blogs.find(blog => blog.page_url === pageUrl); - + const res = await fetch(`${API_URL}/news/`, { + headers: { + 'Content-Type': 'application/json', + }, + cache: 'no-store', + }); + if (!res.ok) { + notFound(); + } + const blogs = await res.json(); + const blog = blogs.find((b: Blog) => b.page_url === pageUrl); if (!blog) { notFound(); } - return blog.id; } async function getBlogPostById(id: number): Promise { - const endpoint = `${API_URL}/news/${id}`; - - try { - const res = await fetch(endpoint, { - headers: { - 'Content-Type': 'application/json', - }, - next: { - revalidate: 3600, - tags: [`blog-${id}`] - } - }); - - if (!res.ok) { - notFound(); - } - - const blog = await res.json(); - return blog; - } catch (error) { + const res = await fetch(`${API_URL}/news/${id}`, { + headers: { + 'Content-Type': 'application/json', + }, + cache: 'no-store', + }); + if (!res.ok) { notFound(); } -} - -export async function generateStaticParams() { - try { - const blogs = await getAllBlogPostsCached(); - - const params = blogs - .filter(blog => blog.page_url && blog.page_url.trim() !== '') - .map((blog) => ({ slug: blog.page_url })); - - return params; - } catch (error) { - return []; - } + return await res.json(); } export async function generateMetadata({ params }: { params: { slug: string } }): Promise { try { const blogId = await getIdFromPageUrl(params.slug); const blog = await getBlogPostById(blogId); - const imageUrl = blog.image || blog.cover_image?.src; const images = imageUrl ? [imageUrl] : []; - - const metadata: Metadata = { + return { title: `${blog.title} | AI4Bharat Blog`, description: blog.description, keywords: [ @@ -183,8 +120,6 @@ export async function generateMetadata({ params }: { params: { slug: string } }) }, }, }; - - return metadata; } catch (error) { return { title: 'Blog Post | AI4Bharat', @@ -197,7 +132,6 @@ export default async function BlogDetailPage({ params }: { params: { slug: strin try { const blogId = await getIdFromPageUrl(params.slug); const blog = await getBlogPostById(blogId); - return ; } catch (error) { notFound(); diff --git a/frontend/src/app/blog/page.tsx b/frontend/src/app/blog/page.tsx index a55def0..7de883a 100644 --- a/frontend/src/app/blog/page.tsx +++ b/frontend/src/app/blog/page.tsx @@ -1,51 +1,23 @@ -"use client"; - -import { useQuery } from "react-query"; +//blogs sections page import { Container, Heading, Text, - Spinner, Center, VStack, Box, Button, - useColorModeValue, - Skeleton, - Badge, - Flex, - HStack, Icon, - Input, - InputGroup, - InputLeftElement, - SimpleGrid, - useBreakpointValue, - Divider, Alert, AlertIcon, AlertTitle, AlertDescription, - useToast, - IconButton, - Tooltip, - AspectRatio, } from "@chakra-ui/react"; -import Link from "next/link"; -import Image from "next/image"; -import { motion, useReducedMotion, AnimatePresence } from "framer-motion"; -import { useState, useMemo, useCallback } from "react"; -import { - FaSearch, - FaCalendarAlt, - FaUser, - FaArrowRight, - FaBookOpen, - FaTags, - FaHome, - FaClock -} from "react-icons/fa"; -import { API_URL } from "../config"; +import { FaHome } from "react-icons/fa"; +import { API_URL } from "../config"; +import ClientBlogsRenderer from "../../../components/ClientBlogsRenderer"; + +export const revalidate = 60; interface Blog { id: number; @@ -77,437 +49,39 @@ interface Blog { bibtex?: string; } -const MotionBox = motion(Box); -const MotionContainer = motion(Container); - -function getSectionsArray(sections: any): any[] { - if (Array.isArray(sections)) { - return sections; - } - if (typeof sections === 'string') { - try { - const parsed = JSON.parse(sections); - return Array.isArray(parsed) ? parsed : []; - } catch (e) { - return []; +async function fetchBlogList(): Promise { + const endpoint = `${API_URL}/news/`; + try { + const response = await fetch(endpoint, { + headers: { + 'Content-Type': 'application/json', + }, + next: { revalidate: 60 } + }); + if (!response.ok) { + throw new Error(`Failed to fetch blog list: ${response.status} ${response.statusText}`); } + const data = await response.json(); + return data; + } catch (error) { + console.error("Error fetching blogs:", error); + throw error; } - return []; } -function getReadingTime(content: string, sections?: any[]): string { - const WORDS_PER_MINUTE = 225; - - let totalText = content || ''; - - if (sections && Array.isArray(sections)) { - sections.forEach(section => { - if (section.heading) { - totalText += ' ' + section.heading; - } - - if (section.content) { - totalText += ' ' + section.content; - } - - if (section.type === 'table') { - if (section.headers && Array.isArray(section.headers)) { - totalText += ' ' + section.headers.join(' '); - } - if (section.rows && Array.isArray(section.rows)) { - section.rows.forEach((row: string[]) => { - if (Array.isArray(row)) { - totalText += ' ' + row.join(' '); - } - }); -} +export default async function BlogsPage() { + let blogList: Blog[] = []; + let error: Error | null = null; - } - - if (section.type === 'examples' && section.items && Array.isArray(section.items)) { - section.items.forEach((item: { id: string; prompt: string; response: string }) => { - if (item.prompt) totalText += ' ' + item.prompt; - if (item.response) totalText += ' ' + item.response; - }); -} - - if (section.image && section.image.caption) { - totalText += ' ' + section.image.caption; - } - }); + try { + blogList = await fetchBlogList(); + } catch (err) { + error = err instanceof Error ? err : new Error('Unknown error'); } - - if (!totalText || totalText.trim().length === 0) { - return "1 min read"; - } - - const cleanText = totalText - .replace(/[#*_`~\[\]()]/g, ' ') - .replace(/<[^>]*>/g, ' ') - .replace(/\s+/g, ' ') - .trim(); - - const words = cleanText.split(/\s+/).filter(word => word.length > 0); - const wordCount = words.length; - - const minutes = Math.max(1, Math.ceil(wordCount / WORDS_PER_MINUTE)); - - return `${minutes} min read`; -} - -function formatDate(dateString: string): string { - return new Date(dateString).toLocaleDateString('en-US', { - year: 'numeric', - month: 'short', - day: 'numeric', - }); -} - -function BlogCardSkeleton() { - const cardBg = useColorModeValue("white", "gray.700"); - const borderColor = useColorModeValue("orange.100", "orange.800"); - - return ( - - - - - - - - - - - - - - - - - - - - ); -} - -function SearchAndFilter({ - searchTerm, - onSearchChange, - totalCount -}: { - searchTerm: string; - onSearchChange: (value: string) => void; - totalCount: number; -}) { - const inputBg = useColorModeValue("white", "gray.700"); - const borderColor = useColorModeValue("orange.200", "orange.600"); - const textColor = useColorModeValue("gray.600", "gray.300"); - - return ( - - - - - - - onSearchChange(e.target.value)} - bg={inputBg} - borderColor={borderColor} - borderWidth="1px" - borderRadius="md" - fontSize="md" - _hover={{ borderColor: "orange.300" }} - _focus={{ - borderColor: "orange.400", - boxShadow: "0 0 0 1px orange.400" - }} - _placeholder={{ color: textColor }} - /> - - - - - - - {totalCount} {totalCount === 1 ? 'article' : 'articles'} - - - {searchTerm && ( - <> - - - Results for: "{searchTerm}" - - - )} - - - - ); -} - -function BlogCard({ blog, index }: { blog: Blog; index: number }) { - const shouldReduceMotion = useReducedMotion(); - const [imageError, setImageError] = useState(false); - - const cardBg = useColorModeValue("white", "gray.700"); - const borderColor = useColorModeValue("orange.100", "orange.700"); - const textColor = useColorModeValue("gray.600", "gray.300"); - const headingColor = useColorModeValue("gray.800", "white"); - const hoverBorderColor = useColorModeValue("orange.300", "orange.500"); - - const readingTime = getReadingTime(blog.markdown_content, getSectionsArray(blog.sections)); - const authorNames = blog.authors?.map(author => author.name).join(", ") || "AI4Bharat Team"; - const coverImageSrc = blog.image || blog.cover_image?.src; - return ( - - - - {coverImageSrc && !imageError ? ( - {blog.cover_image?.alt setImageError(true)} - quality={75} - /> - ) : ( -
- - - - Article - - -
- )} -
- - - - - - {formatDate(blog.published_on)} - - - - {readingTime} - - - - - {blog.title} - - - - {blog.description} - - - - By {authorNames.length > 30 ? `${authorNames.substring(0, 30)}...` : authorNames} - - - - -
-
- ); -} - -const fetchBlogList = async (): Promise => { - const endpoint = `${API_URL}/news/`; - - const response = await fetch(endpoint, { - next: { revalidate: 3600 }, - headers: { - 'Content-Type': 'application/json', - } - }); - - if (!response.ok) { - throw new Error(`Failed to fetch blog list: ${response.status} ${response.statusText}`); - } - - const data = await response.json(); - return data; -}; - -export default function BlogsPage() { - const [searchTerm, setSearchTerm] = useState(""); - const shouldReduceMotion = useReducedMotion(); - const toast = useToast(); - - const { data: blogList, isLoading, error, refetch } = useQuery( - ["fetchBlogList"], - fetchBlogList, - { - staleTime: 5 * 60 * 1000, - cacheTime: 10 * 60 * 1000, - onError: () => { - toast({ - title: "Failed to load articles", - description: "Please check your connection and try again.", - status: "error", - duration: 5000, - isClosable: true, - }); - } - } - ); - - const filteredBlogs = useMemo(() => { - if (!blogList) return []; - if (!searchTerm.trim()) return blogList; - - const searchLower = searchTerm.toLowerCase(); - return blogList.filter(blog => - blog.title.toLowerCase().includes(searchLower) || - blog.description.toLowerCase().includes(searchLower) || - blog.authors?.some(author => - author.name.toLowerCase().includes(searchLower) - ) - ); - }, [blogList, searchTerm]); - - const bgColor = useColorModeValue("orange.50", "gray.900"); - const textColor = useColorModeValue("gray.700", "gray.300"); - const headingColor = useColorModeValue("gray.900", "white"); - - const gridColumns = useBreakpointValue({ - base: 1, - md: 2, - lg: 3, - xl: 3 - }); - - if (isLoading) { - return ( - - - - - AI4Bharat Blog - - - Discover cutting-edge research and insights from the AI4Bharat community - - - - - {Array.from({ length: 6 }).map((_, index) => ( - - ))} - - - - ); - } + const bgColor = "orange.50"; + const textColor = "gray.700"; + const headingColor = "gray.900"; if (error) { return ( @@ -536,12 +110,13 @@ export default function BlogsPage() { @@ -552,114 +127,37 @@ export default function BlogsPage() { return ( - + - - - AI4Bharat Blog - - - - + - - Discover cutting-edge research and insights from the AI4Bharat community. - Explore our latest work in AI for Indian languages and beyond. - - + Discover cutting-edge research and insights from the AI4Bharat community. + Explore our latest work in AI for Indian languages and beyond. + - {blogList && blogList.length > 0 && ( - - - - )} - - {blogList && blogList.length > 0 ? ( - filteredBlogs.length > 0 ? ( - - - {filteredBlogs.map((blog, index) => ( - - ))} - - - ) : ( -
- - - - - No articles found - - - Try adjusting your search terms or browse all articles. - - - - -
- ) - ) : ( -
- - - - - Coming Soon - - - We're working on bringing you amazing research content. - Check back soon for the latest insights and innovations. - - - -
- )} -
+ +
); }