github-actions
Sync from GitHub Fri Dec 26 12:29:52 UTC 2025
aff341e
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>
);
}