diff --git a/client/pages/Blog.tsx b/client/pages/Blog.tsx index 0a008d4e..ca34aeb5 100644 --- a/client/pages/Blog.tsx +++ b/client/pages/Blog.tsx @@ -1,72 +1,34 @@ -import { useState, useEffect, useRef, useMemo } from "react"; -import Layout from "@/components/Layout"; -import { Button } from "@/components/ui/button"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card"; -import { Badge } from "@/components/ui/badge"; -import LoadingScreen from "@/components/LoadingScreen"; -import { aethexToast } from "@/lib/aethex-toast"; +import { useEffect, useMemo, useState } from "react"; import { Link } from "react-router-dom"; -import { - PenTool, - Calendar, - User, - ArrowRight, - Search, - Filter, - Bookmark, - Share, - ThumbsUp, - MessageCircle, - TrendingUp, -} from "lucide-react"; +import Layout from "@/components/Layout"; +import LoadingScreen from "@/components/LoadingScreen"; +import { useAethexToast } from "@/hooks/use-aethex-toast"; +import BlogHero from "@/components/blog/BlogHero"; +import BlogTrendingRail from "@/components/blog/BlogTrendingRail"; +import BlogCategoryChips from "@/components/blog/BlogCategoryChips"; +import BlogPostGrid from "@/components/blog/BlogPostGrid"; +import BlogNewsletterSection from "@/components/blog/BlogNewsletterSection"; +import { Card, CardContent } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { ArrowRight, Compass, Layers, ListFilter, Newspaper } from "lucide-react"; +import type { BlogCategory, BlogPost } from "@/components/blog/types"; -type BlogPost = { - id?: string | number; - slug?: string; - title: string; - excerpt: string; - author: string; - date: string; - readTime?: string; - category?: string; - image?: string | null; - likes?: number; - comments?: number; - trending?: boolean; -}; +const buildSlug = (post: BlogPost): string => post.slug || post.id?.toString() || "article"; -const normalizeCategory = (value?: string) => - value - ? value - .toString() - .toLowerCase() - .trim() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-+|-+$/g, "") - : "general"; - -const buildSlug = (post: BlogPost): string => { - const base = (post.slug || post.id || post.title || "").toString(); - const sanitized = base +const normalizeCategory = (value?: string | null) => + (value || "general") + .toString() .trim() .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-+|-+$/g, ""); - return sanitized || "article"; -}; + .replace(/[^a-z0-9]+/g, "-"); -export default function Blog() { +const Blog = () => { + const toast = useAethexToast(); const [isLoading, setIsLoading] = useState(true); - const [selectedCategory, setSelectedCategory] = useState("all"); - const toastShownRef = useRef(false); const [posts, setPosts] = useState([]); - const [featuredPost, setFeaturedPost] = useState(null); + const [searchQuery, setSearchQuery] = useState(""); + const [selectedCategory, setSelectedCategory] = useState("all"); const staticPosts = useMemo( () => [ @@ -238,442 +200,258 @@ export default function Blog() { let cancelled = false; (async () => { try { - let res = await fetch("/api/blog?limit=50"); + const res = await fetch("/api/blog?limit=50"); let data: any = []; try { - if (res.ok) data = await res.json(); - } catch (err) { - // fallback to Supabase REST if server API not available (dev) + if (res.ok) { + data = await res.json(); + } + } catch (error) { + console.warn("Failed to parse blog API response, falling back to Supabase", error); + } + + if ((!Array.isArray(data) || !data.length) && import.meta.env.VITE_SUPABASE_URL && import.meta.env.VITE_SUPABASE_ANON_KEY) { try { - const sbUrl = import.meta.env.VITE_SUPABASE_URL; - const sbKey = import.meta.env.VITE_SUPABASE_ANON_KEY; - if (sbUrl && sbKey) { - const url = `${sbUrl.replace(/\/$/, "")}/rest/v1/blog_posts?select=id,slug,title,excerpt,author,date,read_time,category,image,likes,comments,published_at&order=published_at.desc&limit=50`; - const sbRes = await fetch(url, { - headers: { - apikey: sbKey as string, - Authorization: `Bearer ${sbKey}`, - }, - }); - if (sbRes.ok) data = await sbRes.json(); + const sbUrl = import.meta.env.VITE_SUPABASE_URL.replace(/\/$/, ""); + const url = `${sbUrl}/rest/v1/blog_posts?select=slug,title,excerpt,author,date,read_time,category,image,likes,comments,published_at&order=published_at.desc&limit=50`; + const fallbackRes = await fetch(url, { + headers: { + apikey: import.meta.env.VITE_SUPABASE_ANON_KEY, + Authorization: `Bearer ${import.meta.env.VITE_SUPABASE_ANON_KEY}`, + }, + }); + if (fallbackRes.ok) { + data = await fallbackRes.json(); } - } catch (e) { - console.warn("Supabase fallback failed:", e); - data = []; + } catch (error) { + console.warn("Supabase fallback failed", error); } } if (!cancelled && Array.isArray(data)) { - const mapped: BlogPost[] = data.map((r: any) => ({ - id: r.id, - slug: r.slug ?? r.id ?? undefined, - title: r.title, - excerpt: r.excerpt, - author: r.author, - date: r.date, - readTime: r.read_time ?? r.readTime, - category: r.category ?? "General", - image: r.image ?? null, - likes: typeof r.likes === "number" ? r.likes : 0, - comments: typeof r.comments === "number" ? r.comments : 0, - trending: Boolean(r.trending), + const mapped: BlogPost[] = data.map((record: any) => ({ + id: record.id ?? record.slug, + slug: record.slug, + title: record.title, + excerpt: record.excerpt ?? record.summary ?? null, + author: record.author ?? "AeThex Team", + date: record.date ?? record.published_at, + readTime: record.read_time ?? record.readTime ?? null, + category: record.category ?? "General", + image: record.image ?? null, + likes: typeof record.likes === "number" ? record.likes : null, + comments: typeof record.comments === "number" ? record.comments : null, + trending: Boolean(record.trending) || (typeof record.likes === "number" && record.likes > 250), + body: record.body_html ?? record.body ?? null, })); setPosts(mapped); - const highlighted = mapped.find((post) => post.trending) ?? mapped[0] ?? null; - setFeaturedPost(highlighted); } - } catch (e) { - console.warn("Blog fetch failed:", e); + } catch (error) { + console.warn("Blog fetch failed", error); + toast.system("Loaded curated AeThex articles"); } finally { if (!cancelled) { setIsLoading(false); - if (!toastShownRef.current) { - aethexToast.system("AeThex Blog loaded successfully"); - toastShownRef.current = true; - } } } })(); + return () => { cancelled = true; }; - }, []); + }, [toast]); - useEffect(() => { - if (selectedCategory === "all") { - return; - } - const dataset = posts.length ? posts : staticPosts; - const hasCategory = dataset.some( - (post) => normalizeCategory(post.category) === selectedCategory, - ); - if (!hasCategory) { - setSelectedCategory("all"); - } - }, [posts, staticPosts, selectedCategory]); - - const categories = useMemo(() => { - const dataset = posts.length ? posts : staticPosts; - const counts = new Map(); - dataset.forEach((post) => { - const name = post.category || "General"; - const id = normalizeCategory(name); - const existing = counts.get(id); - counts.set(id, { - name, - count: (existing?.count ?? 0) + 1, - }); - }); - const preferredOrder = [ - "technology", - "tutorials", - "research", - "company-news", - "general", - ]; - const ordered: { id: string; name: string; count: number }[] = []; - preferredOrder.forEach((id) => { - const entry = counts.get(id); - if (entry) { - ordered.push({ id, name: entry.name, count: entry.count }); - counts.delete(id); - } - }); - counts.forEach((value, id) => { - ordered.push({ id, name: value.name, count: value.count }); - }); - return [ - { id: "all", name: "All Posts", count: dataset.length }, - ...ordered, - ]; - }, [posts, staticPosts]); + const dataset = posts.length ? posts : staticPosts; const filteredPosts = useMemo(() => { - const dataset = posts.length ? posts : staticPosts; - if (selectedCategory === "all") { - return dataset; - } - return dataset.filter( - (post) => normalizeCategory(post.category) === selectedCategory, - ); - }, [posts, staticPosts, selectedCategory]); + const query = searchQuery.trim().toLowerCase(); + return dataset.filter((post) => { + const matchesCategory = + selectedCategory === "all" || normalizeCategory(post.category) === selectedCategory; + if (!matchesCategory) return false; - const activeFeaturedPost = useMemo(() => { - const dataset = posts.length ? posts : staticPosts; - const scoped = selectedCategory === "all" ? dataset : filteredPosts; - const scopedPosts = scoped.length ? scoped : dataset; - const featuredSlug = featuredPost ? buildSlug(featuredPost) : null; - if (featuredSlug) { - const matchingFeatured = scopedPosts.find( - (post) => buildSlug(post) === featuredSlug, - ); - if (matchingFeatured) { - return matchingFeatured; - } + if (!query) return true; + const haystack = [post.title, post.excerpt, post.author] + .filter(Boolean) + .map((value) => value!.toLowerCase()) + .join(" "); + return haystack.includes(query); + }); + }, [dataset, selectedCategory, searchQuery]); + + const featuredPost = useMemo(() => { + if (!filteredPosts.length) { + return dataset.find((post) => post.trending) ?? dataset[0] ?? null; } - return ( - scopedPosts.find((post) => post.trending) ?? - (featuredSlug - ? dataset.find((post) => buildSlug(post) === featuredSlug) - : null) ?? - dataset.find((post) => post.trending) ?? - scopedPosts[0] ?? - dataset[0] ?? - null - ); - }, [featuredPost, filteredPosts, posts, staticPosts, selectedCategory]); + return filteredPosts.find((post) => post.trending) ?? filteredPosts[0] ?? null; + }, [dataset, filteredPosts]); const displayedPosts = useMemo(() => { - if (!activeFeaturedPost) { - return filteredPosts; - } - const featuredSlug = buildSlug(activeFeaturedPost); - return filteredPosts.filter((post) => buildSlug(post) !== featuredSlug); - }, [filteredPosts, activeFeaturedPost]); + if (!featuredPost) return filteredPosts; + return filteredPosts.filter((post) => buildSlug(post) !== buildSlug(featuredPost)); + }, [filteredPosts, featuredPost]); - const placeholderImage = "/placeholder.svg"; + const trendingPosts = useMemo(() => { + const sorted = [...dataset] + .filter((post) => post.trending || (post.likes ?? 0) >= 200) + .sort((a, b) => (b.likes ?? 0) - (a.likes ?? 0)); + return sorted.slice(0, 3); + }, [dataset]); + + const categories: BlogCategory[] = useMemo(() => { + const counts = new Map(); + dataset.forEach((post) => { + const id = normalizeCategory(post.category); + const name = post.category || "General"; + counts.set(id, { + id, + name, + count: (counts.get(id)?.count ?? 0) + 1, + }); + }); + + const ordered = [ + { id: "all", name: "All posts", count: dataset.length }, + ...Array.from(counts.values()).sort((a, b) => b.count - a.count), + ]; + + return ordered; + }, [dataset]); + + const insights = useMemo( + () => [ + { + label: "Teams publishing", + value: new Set(dataset.map((post) => (post.author || "AeThex Team").split(" ")[0])).size, + helper: "Active contributors this month", + icon: , + }, + { + label: "Focus areas", + value: new Set(dataset.map((post) => post.category || "General")).size, + helper: "Distinct categories covered", + icon: , + }, + { + label: "Stories published", + value: dataset.length, + helper: "All-time AeThex blog posts", + icon: , + }, + ], + [dataset], + ); if (isLoading) { - return ( - - ); + return ; } + const handleResetFilters = () => { + setSelectedCategory("all"); + setSearchQuery(""); + }; + return ( -
- {/* Hero Section */} -
-
-
- + + +
+
+
+
+

+ Filter by track +

+

Navigate the AeThex knowledge graph

+
+ +
+
+ +
+
+
+ + + +
+
+ {insights.map((insight) => ( + - - AeThex Blog - - -

- - Insights & Innovation - -

- -

- Stay updated with the latest developments in game technology, AI - research, and industry insights from the AeThex team. -

- - {/* Search and Filter */} -
-
- - -
- -
-
-
-
- - {/* Categories */} -
-
-
- {categories.map((category, index) => ( - - ))} -
-
-
- - {/* Featured Post */} - {activeFeaturedPost && ( -
-
- -
-
- {activeFeaturedPost.title} + + + {insight.icon} + +
+

{insight.label}

+

{insight.value}

+

{insight.helper}

-
- - Featured - - - {activeFeaturedPost.title} - - - {activeFeaturedPost.excerpt} - - -
-
-
- - {activeFeaturedPost.author || "AeThex Team"} -
-
- - {activeFeaturedPost.date} -
-
- {activeFeaturedPost.readTime && ( - {activeFeaturedPost.readTime} - )} -
- -
- -
-
- - {activeFeaturedPost.likes ?? 0} -
-
- - {activeFeaturedPost.comments ?? 0} -
-
-
-
-
+ -
-
- )} - - {/* Recent Posts */} -
-
-
-

- Recent Articles -

-

- Latest insights and tutorials from our team -

-
- - {displayedPosts.length > 0 ? ( -
- {displayedPosts.map((post, index) => ( - - -
- - {post.category || "General"} - - {post.trending && ( - - - Trending - - )} -
- - {post.title} - - - {post.excerpt} - -
- - -
-
- - {post.author || "AeThex Team"} -
-
- - {post.date || "Coming soon"} -
-
- -
- {post.readTime ? ( - - {post.readTime} - - ) : ( - - Quick read - - )} -
- - -
-
- -
-
-
- - {post.likes ?? 0} -
-
- - {post.comments ?? 0} -
-
- -
-
-
- ))} -
- ) : ( -
- No articles available in this category yet. Please check back soon. -
- )} + ))}
- {/* Newsletter CTA */} -
-
-
-

- Stay in the Loop -

-

- Subscribe to our newsletter for the latest articles, tutorials, - and technology insights delivered directly to your inbox. -

+
+
+
+
+

Latest updates

+

Fresh from the AeThex ship room

+
+ +
-
- -
+
+ + + +
+
+
+
+
+

Explore more

+

Dive into AeThex documentation

+

+ Looking for implementation guides, deployment recipes, or program onboarding materials? + Visit our documentation hub for developer tutorials, platform references, and community playbooks. +

+
+
- -

- Join 10,000+ developers getting weekly insights. Unsubscribe - anytime. -

); -} +}; + +export default Blog;