Spaces:
Sleeping
Sleeping
| import { useState, useEffect, type FormEvent } from 'react'; | |
| interface BlogPost { | |
| id: number; | |
| title: string; | |
| content: string; | |
| author: string; | |
| created_at: string; | |
| published: boolean; | |
| tags: string[]; | |
| featured_image?: { | |
| url: string; | |
| alt_text: string; | |
| caption: string; | |
| }; | |
| post_images: Array<{ | |
| id: number; | |
| url: string; | |
| alt_text: string; | |
| caption: string; | |
| order: number; | |
| position?: number; | |
| }>; | |
| } | |
| interface BlogSummary { | |
| id: number; | |
| title: string; | |
| author: string; | |
| created_at: string; | |
| tags: string[]; | |
| excerpt: string; | |
| has_featured_image: boolean; | |
| featured_image_url?: string; | |
| post_image_count: number; | |
| } | |
| interface BlogResponse { | |
| posts: BlogSummary[]; | |
| total: number; | |
| limit: number; | |
| offset: number; | |
| has_more: boolean; | |
| } | |
| export default function App() { | |
| const [blogData, setBlogData] = useState<BlogResponse | null>(null); | |
| const [selectedPost, setSelectedPost] = useState<BlogPost | null>(null); | |
| const [viewMode, setViewMode] = useState<'home' | 'blog'>('home'); | |
| const [currentPage, setCurrentPage] = useState(1); | |
| const [isDragging, setIsDragging] = useState(false); | |
| const [headerCollapsed, setHeaderCollapsed] = useState(false); | |
| const [lastScrollY, setLastScrollY] = useState(0); | |
| const [searchQuery, setSearchQuery] = useState(''); | |
| const [selectedCategory, setSelectedCategory] = useState('All'); | |
| const [isLoading, setIsLoading] = useState(false); | |
| const [searchResults, setSearchResults] = useState<BlogResponse | null>(null); | |
| const [isSearching, setIsSearching] = useState(false); | |
| const [searchTimer, setSearchTimer] = useState<number | null>(null); | |
| const PAGE_SIZE = 6; | |
| const handlePageChange = (newPage: number) => { | |
| if (newPage >= 1 && (!blogData || newPage <= Math.ceil(blogData.total / PAGE_SIZE))) { | |
| setCurrentPage(newPage); | |
| fetchBlogPosts(newPage); | |
| } | |
| }; | |
| const [sliderVisible, setSliderVisible] = useState(false); | |
| const categories = ['All', 'Artificial Intelligence','Developers','AI Agents','Social','Movies']; | |
| // Fetch blog posts on component mount and page change (disabled when searching) | |
| useEffect(() => { | |
| if (!searchQuery) fetchBlogPosts(currentPage); | |
| }, [currentPage, searchQuery, selectedCategory]); | |
| // When category changes, reset page & clear search results (if any) | |
| useEffect(() => { | |
| setCurrentPage(1); | |
| if (!searchQuery) { | |
| fetchBlogPosts(1); | |
| } | |
| }, [selectedCategory]); | |
| // Handle scroll effect for blog header | |
| useEffect(() => { | |
| if (viewMode !== 'blog') return; | |
| const handleScroll = () => { | |
| const currentScrollY = window.scrollY; | |
| const scrollThreshold = 100; // Minimum scroll distance to trigger effect | |
| if (currentScrollY > scrollThreshold) { | |
| // Scrolling down - collapse header | |
| if (currentScrollY > lastScrollY && !headerCollapsed) { | |
| setHeaderCollapsed(true); | |
| } | |
| // Scrolling up - expand header | |
| else if (currentScrollY < lastScrollY && headerCollapsed) { | |
| setHeaderCollapsed(false); | |
| } | |
| } else { | |
| // Near top - always show full header | |
| setHeaderCollapsed(false); | |
| } | |
| setLastScrollY(currentScrollY); | |
| }; | |
| window.addEventListener('scroll', handleScroll, { passive: true }); | |
| return () => window.removeEventListener('scroll', handleScroll); | |
| }, [viewMode, lastScrollY, headerCollapsed]); | |
| async function fetchBlogPosts(page: number = 1) { | |
| setIsLoading(true); | |
| try { | |
| const params = new URLSearchParams({ page: String(page), limit: String(PAGE_SIZE) }); | |
| if (selectedCategory && selectedCategory !== 'All') params.append('category', selectedCategory); | |
| const res = await fetch(`/api/blog/posts?${params.toString()}`); | |
| if (res.ok) { | |
| const data = await res.json(); | |
| // Small delay to make loading visible | |
| await new Promise(resolve => setTimeout(resolve, 300)); | |
| setBlogData(data); | |
| } | |
| } catch (err) { | |
| console.error('Failed to fetch blog posts:', err); | |
| } finally { | |
| setIsLoading(false); | |
| } | |
| } | |
| async function runSearch(query: string) { | |
| const q = query.trim(); | |
| if (!q) { | |
| setSearchResults(null); | |
| return; | |
| } | |
| setIsSearching(true); | |
| try { | |
| const params = new URLSearchParams({ q }); | |
| if (selectedCategory && selectedCategory !== 'All') params.append('category', selectedCategory); | |
| const res = await fetch(`/api/blog/search?${params.toString()}`); | |
| if (res.ok) { | |
| const data = await res.json(); | |
| setSearchResults({ | |
| posts: data.posts, | |
| total: data.total, | |
| limit: data.posts.length, | |
| offset: 0, | |
| has_more: false | |
| } as BlogResponse); | |
| } | |
| } catch (e) { | |
| console.error('Search failed', e); | |
| } finally { | |
| setIsSearching(false); | |
| } | |
| } | |
| // Debounce search input | |
| useEffect(() => { | |
| if (searchTimer) window.clearTimeout(searchTimer); | |
| const handle = window.setTimeout(() => { | |
| runSearch(searchQuery); | |
| }, 300); | |
| setSearchTimer(handle); | |
| return () => window.clearTimeout(handle); | |
| }, [searchQuery]); | |
| async function fetchBlogPost(id: number) { | |
| setIsLoading(true); | |
| try { | |
| const res = await fetch(`/api/blog/posts/${id}`); | |
| if (res.ok) { | |
| const post = await res.json(); | |
| // Add artificial delay to make loading visible | |
| await new Promise(resolve => setTimeout(resolve, 300)); | |
| setSelectedPost(post); | |
| setViewMode('blog'); | |
| } | |
| } catch (err) { | |
| console.error('Failed to fetch blog post:', err); | |
| } finally { | |
| setIsLoading(false); | |
| } | |
| } | |
| function formatDate(dateString: string) { | |
| return new Date(dateString).toLocaleDateString('en-US', { | |
| year: 'numeric', | |
| month: 'long', | |
| day: 'numeric' | |
| }); | |
| } | |
| function renderBlogContent(content: string, images: BlogPost['post_images']) { | |
| const paragraphs = content.split('\n\n').filter(p => p.trim()); | |
| const elements: JSX.Element[] = []; | |
| paragraphs.forEach((paragraph, index) => { | |
| const paragraphNumber = index + 1; | |
| elements.push( | |
| <p key={`para-${paragraphNumber}`} className="blog-paragraph"> | |
| {paragraph} | |
| </p> | |
| ); | |
| // Insert images that should appear after this paragraph | |
| const imagesForPosition = images.filter(img => img.position === paragraphNumber); | |
| imagesForPosition.forEach(image => { | |
| elements.push( | |
| <figure key={`img-${image.id}`} className="blog-image"> | |
| <img src={image.url} alt={image.alt_text} /> | |
| {image.caption && <figcaption>{image.caption}</figcaption>} | |
| </figure> | |
| ); | |
| }); | |
| }); | |
| return elements; | |
| } | |
| // Toggle subtle separator only if content is scrollable or user scrolled | |
| useEffect(() => { | |
| function evaluate() { | |
| const header = document.querySelector('.compact-header'); | |
| if (!header) return; | |
| const scrollable = document.documentElement.scrollHeight > window.innerHeight + 4; | |
| const scrolled = window.scrollY > 4; | |
| if (scrollable || scrolled) header.classList.add('with-sep'); | |
| else header.classList.remove('with-sep'); | |
| } | |
| evaluate(); | |
| window.addEventListener('resize', evaluate); | |
| window.addEventListener('scroll', evaluate, { passive: true }); | |
| return () => { | |
| window.removeEventListener('resize', evaluate); | |
| window.removeEventListener('scroll', evaluate); | |
| }; | |
| }, []); | |
| if (viewMode === 'blog' && selectedPost) { | |
| return ( | |
| <div className="app-root blog-view"> | |
| <div className="bg-layers" aria-hidden="true" /> | |
| {/* Smart header that transforms based on scroll */} | |
| <header className={`blog-header smart-header ${headerCollapsed ? 'collapsed' : 'expanded'}`}> | |
| <div className="blog-header-inner"> | |
| <button | |
| onClick={() => setViewMode('home')} | |
| className="back-button" | |
| aria-label="Back to home" | |
| > | |
| β Back to Home | |
| </button> | |
| <div className="blog-title-section"> | |
| <h1 className="blog-title">{selectedPost.title}</h1> | |
| <div className="blog-meta"> | |
| <span className="blog-author">βοΈ {selectedPost.author}</span> | |
| <span className="blog-date">π {formatDate(selectedPost.created_at)}</span> | |
| </div> | |
| </div> | |
| </div> | |
| </header> | |
| <main className="blog-content-area"> | |
| <article className="blog-article"> | |
| {selectedPost.featured_image && ( | |
| <figure className="featured-image"> | |
| <img | |
| src={selectedPost.featured_image.url} | |
| alt={selectedPost.featured_image.alt_text} | |
| /> | |
| {selectedPost.featured_image.caption && ( | |
| <figcaption>{selectedPost.featured_image.caption}</figcaption> | |
| )} | |
| </figure> | |
| )} | |
| <div className="blog-body"> | |
| {renderBlogContent(selectedPost.content, selectedPost.post_images)} | |
| </div> | |
| {/* Tags section below content */} | |
| <div className="blog-tags-section"> | |
| {selectedPost.tags && selectedPost.tags.length > 0 && ( | |
| <> | |
| <h3 className="blog-tags-title">Tags</h3> | |
| <div className="blog-tags-container"> | |
| {selectedPost.tags.map(tag => ( | |
| <span key={tag} className="blog-tag">{tag}</span> | |
| ))} | |
| </div> | |
| </> | |
| )} | |
| </div> | |
| <div style={{ display: 'flex', justifyContent: 'center', margin: '2rem 0' }}> | |
| <button | |
| className="back-button" | |
| onClick={() => setViewMode('home')} | |
| aria-label="Back to home" | |
| > | |
| β Back to Home | |
| </button> | |
| </div> | |
| </article> | |
| </main> | |
| </div> | |
| ); | |
| } | |
| return ( | |
| <div className="app-root homepage-layout"> | |
| <div className="bg-layers" aria-hidden="true" /> | |
| {/* Enhanced Site Header with integrated controls */} | |
| <header className="site-header"> | |
| <div className="header-inner"> | |
| <div className="brand-block"> | |
| <h1 className="site-title">Amplify<span className="pulse-dot" /></h1> | |
| <p className="site-tagline">Stories that made an impact & what is happening in the world</p> | |
| </div> | |
| <div className="header-controls" role="search"> | |
| <div className="search-wrapper"> | |
| <input | |
| type="text" | |
| aria-label="Search blog posts" | |
| placeholder="Search posts..." | |
| value={searchQuery} | |
| onChange={(e) => setSearchQuery(e.target.value)} | |
| className="search-input header-search" | |
| /> | |
| <div className="search-icon">π</div> | |
| </div> | |
| <div className="category-wrapper"> | |
| <select | |
| aria-label="Filter by category" | |
| value={selectedCategory} | |
| onChange={(e) => setSelectedCategory(e.target.value)} | |
| className="category-select header-category" | |
| > | |
| {categories.map(category => ( | |
| <option key={category} value={category}>{category}</option> | |
| ))} | |
| </select> | |
| <div className="dropdown-arrow">βΌ</div> | |
| </div> | |
| </div> | |
| </div> | |
| </header> | |
| <main className="main-content"> | |
| {/* Blog Grid Section */} | |
| <section className="blog-content-section"> | |
| <div className="blog-container"> | |
| <div className="blog-grid-new"> | |
| {(searchResults ? searchResults.posts : blogData?.posts)?.map((post: any) => ( | |
| <article | |
| key={post.id} | |
| className={`blog-card-new ${isLoading ? 'loading' : ''}`} | |
| onClick={() => !isLoading && fetchBlogPost(post.id)} | |
| style={{ position: 'relative' }} | |
| > | |
| {isLoading && ( | |
| <div className="loading-overlay"> | |
| <div className="loading-spinner"></div> | |
| </div> | |
| )} | |
| {post.featured_image_url && ( | |
| <div className="blog-card-image-new"> | |
| <img src={post.featured_image_url} alt={post.title} /> | |
| <div className="image-overlay"></div> | |
| </div> | |
| )} | |
| <div className="blog-card-content-new"> | |
| <div className="blog-card-tags-new"> | |
| {post.tags.slice(0, 2).map((tag: string) => ( | |
| <span key={tag} className="blog-card-tag-new">{tag}</span> | |
| ))} | |
| {post.percent_match !== undefined && ( | |
| <span className="match-badge" title="Tag match score">{post.percent_match}%</span> | |
| )} | |
| </div> | |
| <h3 className="blog-card-title-new">{post.title}</h3> | |
| <p className="blog-card-excerpt-new">{post.excerpt}</p> | |
| <div className="blog-card-meta-new"> | |
| <span className="blog-card-author-new">βοΈ {post.author}</span> | |
| <span className="blog-card-date-new">π {formatDate(post.created_at)}</span> | |
| </div> | |
| {post.post_image_count > 0 && ( | |
| <div className="blog-card-stats-new"> | |
| <span className="blog-card-stat-new">π· {post.post_image_count} images</span> | |
| </div> | |
| )} | |
| </div> | |
| </article> | |
| ))} | |
| </div> | |
| {isLoading && !searchResults && ( | |
| <div className="grid-loading"> | |
| <div className="loading-spinner"></div> | |
| <p>Loading posts...</p> | |
| </div> | |
| )} | |
| {isSearching && ( | |
| <div className="grid-loading" style={{marginTop:'1rem'}}> | |
| <div className="loading-spinner"></div> | |
| <p>Searching...</p> | |
| </div> | |
| )} | |
| {searchResults && !isSearching && searchResults.total === 0 && ( | |
| <div style={{textAlign:'center', marginTop:'1rem', fontSize:'0.85rem', color:'#555'}}>No matches found.</div> | |
| )} | |
| {/* Pagination */} | |
| {!searchResults && blogData && blogData.total > PAGE_SIZE && ( | |
| <div className="pagination-new"> | |
| <button | |
| className="pagination-btn-new prev" | |
| onClick={() => handlePageChange(currentPage - 1)} | |
| disabled={currentPage === 1} | |
| > | |
| β Previous | |
| </button> | |
| <div className="page-indicators"> | |
| {Array.from({ length: Math.ceil(blogData.total / PAGE_SIZE) }, (_, i) => i + 1).map(pageNum => ( | |
| <button | |
| key={pageNum} | |
| className={`page-indicator ${pageNum === currentPage ? 'active' : ''}`} | |
| onClick={() => handlePageChange(pageNum)} | |
| > | |
| {pageNum} | |
| </button> | |
| ))} | |
| </div> | |
| <button | |
| className="pagination-btn-new next" | |
| onClick={() => handlePageChange(currentPage + 1)} | |
| disabled={!blogData.has_more} | |
| > | |
| Next β | |
| </button> | |
| </div> | |
| )} | |
| </div> | |
| </section> | |
| </main> | |
| </div> | |
| ); | |
| } | |