diff --git a/website/blog/2025-08-29-building-a-rag-based-ai-recommender-2.mdx b/website/blog/2025-08-29-building-a-rag-based-ai-recommender-2.mdx index b3ace2773e0d3..031bc05b670ea 100644 --- a/website/blog/2025-08-29-building-a-rag-based-ai-recommender-2.mdx +++ b/website/blog/2025-08-29-building-a-rag-based-ai-recommender-2.mdx @@ -2,6 +2,7 @@ title: "Building a RAG-based AI Recommender (2/2)" author: Shiyan Xu category: blog +subCategory: indexing image: /assets/images/blog/2025-08-29-building-a-rag-based-ai-recommender-2.jpg tags: - blog diff --git a/website/blog/2025-09-17-hudi-auto-gen-keys.mdx b/website/blog/2025-09-17-hudi-auto-gen-keys.mdx index 9f0c24a89c6da..7f7b1b6db3ae6 100644 --- a/website/blog/2025-09-17-hudi-auto-gen-keys.mdx +++ b/website/blog/2025-09-17-hudi-auto-gen-keys.mdx @@ -3,6 +3,7 @@ title: "Automatic Record Key Generation in Apache Hudi" excerpt: "" author: Shiyan Xu category: blog +subCategory: security image: /assets/images/blog/2025-09-17-hudi-auto-gen-keys/2025-09-17-hudi-auto-gen-keys.fig2.jpg tags: - hudi @@ -19,8 +20,8 @@ By using a primary key that is stable across record movement, a system can effic Apache Hudi was the first lakehouse storage project to introduce the notion of record keys. For mutable workloads, this addressed a significant architectural challenge. In a typical data lake table, updating records usually required rewriting entire partitions—a process that is slow and expensive. By supporting the record key as the stable identifier for every record, Hudi offered unique and advanced capabilities among lakehouse frameworks: -* Hudi supports [record-level indexing](https://hudi.apache.org/blog/2023/11/01/record-level-index/) for directly locating records in [file groups](https://hudi.apache.org/docs/storage_layouts) for highly efficient upserts and queries, and [secondary indexes](https://hudi.apache.org/blog/2025/04/02/secondary-index/) that enable performant lookups for predicates on non-record key fields. -* Hudi implements [merge modes](https://hudi.apache.org/blog/2025/03/03/record-mergers-in-hudi/), standardizing record-merging semantics to handle requirements such as unordered events, duplicate records, and custom merge logic. +* Hudi supports [record-level indexing](https://hudi.apache.org/blog/2023/11/01/record-level-index/) for directly locating records in [file groups](https://hudi.apache.org/docs/storage_layouts) for highly efficient upserts and queries, and [secondary indexes](https://hudi.apache.org/blog/2025/04/02/secondary-index/) that enable performant lookups for predicates on non-record key fields. +* Hudi implements [merge modes](https://hudi.apache.org/blog/2025/03/03/record-mergers-in-hudi/), standardizing record-merging semantics to handle requirements such as unordered events, duplicate records, and custom merge logic. * By materializing record keys along with other [record-level meta-fields](https://www.onehouse.ai/blog/hudi-metafields-demystified), Hudi unlocks features such as efficient [change data capture (CDC)](https://hudi.apache.org/blog/2024/07/30/data-lake-cdc/) that serves record-level change streams, near-infinite history for time-travel queries, and the [clustering table service](https://hudi.apache.org/docs/clustering) that can significantly optimize file sizes.
@@ -61,10 +62,10 @@ In this example, you’re creating a Copy-on-Write table partitioned by `city`. Designing a key generation mechanism that operates efficiently at petabyte scale requires careful thought. We established five core requirements for the auto-generated keys: -1. **Global Uniqueness:** Keys must be unique across the entire table to maintain the integrity of a primary key. -2. **Low Storage Footprint:** The keys should be highly compressible to add minimal storage overhead. -3. **Computational Efficiency:** The encoding and decoding process must be lightweight so as not to slow down the write process. -4. **Idempotency:** The generation process must be resilient to task retries, producing the same key for the same record every time. +1. **Global Uniqueness:** Keys must be unique across the entire table to maintain the integrity of a primary key. +2. **Low Storage Footprint:** The keys should be highly compressible to add minimal storage overhead. +3. **Computational Efficiency:** The encoding and decoding process must be lightweight so as not to slow down the write process. +4. **Idempotency:** The generation process must be resilient to task retries, producing the same key for the same record every time. 5. **Engine Agnostic:** The logic must be reusable and implemented consistently across different execution engines like Spark and Flink. These principles guided the technical design. To align with primary key semantics, global uniqueness was non-negotiable. To minimize storage footprint, the generated keys needed to be compact and highly compressible, especially for tables with billions of records. The computational cost was also critical; any expensive operation would be amplified by the number of records, creating a significant performance overhead. Furthermore, in distributed systems where task failures and retries are common, the key generation process had to be idempotent—ensuring the same input record always produces the exact same key. Finally, the solution needed to be engine-agnostic to provide consistent behavior, whether data is written via Spark, Flink, or another supported engine. @@ -79,8 +80,8 @@ Based on the requirements mentioned previously, we eliminated several common ID Each component serves a specific purpose: -* **Write Action Start Time:** The timestamp from the Hudi timeline that marks the beginning of a write transaction. -* **Workload Partition ID:** An internal identifier that execution engines use to track the specific data split being processed by a given distributed write task. +* **Write Action Start Time:** The timestamp from the Hudi timeline that marks the beginning of a write transaction. +* **Workload Partition ID:** An internal identifier that execution engines use to track the specific data split being processed by a given distributed write task. * **Record Sequence ID:** A counter that uniquely identifies each record within that data split. Together, these three components—all readily accessible during the write process—form a record identifier that satisfies the requirements of global uniqueness, idempotency, and being engine-agnostic. diff --git a/website/blog/2025-10-02-Real-Time-Cloud-Security-Graphs-Hudi+PuppyGraph.mdx b/website/blog/2025-10-02-Real-Time-Cloud-Security-Graphs-Hudi+PuppyGraph.mdx index 37838b5474e15..562b3457bc7dc 100644 --- a/website/blog/2025-10-02-Real-Time-Cloud-Security-Graphs-Hudi+PuppyGraph.mdx +++ b/website/blog/2025-10-02-Real-Time-Cloud-Security-Graphs-Hudi+PuppyGraph.mdx @@ -3,6 +3,7 @@ title: "Real-Time Cloud Security Graphs with Apache Hudi and PuppyGraph" excerpt: "Hudi tables support fast upserts and incremental processing. PuppyGraph queries relationships in place using openCypher or Gremlin. In this blog, we explore how to get started with real-time security graph analytics at scale using the data already stored in your Hudi lakehouse tables." author: Jaz Samantha Ku, in collaboration with Shiyan Xu category: blog +subCategory: use case image: /assets/images/blog/2025-10-02-Real-Time-Cloud-Security-Graphs-Hudi+PuppyGraph/fig-4-Sample-Architecture-of-PuppyGraph-Hudi.png tags: - Apache Hudi @@ -16,8 +17,8 @@ Security tools such as SIEM, CSPM, and cloud workload protection need relationsh To keep up, the data pipeline must support: -* Continuous upserts with low lag so detections run on the latest state -* Incremental consumption so analytics read only “what changed since T” +* Continuous upserts with low lag so detections run on the latest state +* Incremental consumption so analytics read only “what changed since T” * A rewindable timeline so responders can review state during investigations With Apache Hudi and PuppyGraph, this becomes straightforward. Hudi tables support fast upserts and incremental processing. PuppyGraph queries relationships in place using openCypher or Gremlin. In this blog, we explore how to get started with real-time security graph analytics at scale using the data already stored in your Hudi lakehouse tables. @@ -81,19 +82,19 @@ Getting started is straightforward. You will deploy the stack, load security dat The components of this demo project include: -* Storage: MinIO/S3 – Object store for Hudi data -* Data Lakehouse: Apache Hudi – Brings database functionality to your data lakes -* Catalog: Hive Metastore – Backed by Postgres -* Compute engines: - * Spark – Initial table writes +* Storage: MinIO/S3 – Object store for Hudi data +* Data Lakehouse: Apache Hudi – Brings database functionality to your data lakes +* Catalog: Hive Metastore – Backed by Postgres +* Compute engines: + * Spark – Initial table writes * PuppyGraph – Graph query engine for complex, multi-hop graph queries ### Prerequisites This tutorial assumes that you have the following: -1. **Docker** and **Docker** **Compose** (for setting up the Docker container) -2. **Python 3** (for managing dependencies) +1. **Docker** and **Docker** **Compose** (for setting up the Docker container) +2. **Python 3** (for managing dependencies) 3. [PuppyGraph-Hudi Demo Repository](https://github.com/puppygraph/puppygraph-getting-started/tree/main/integration-demos/hudi-demo) #### Data Preparation @@ -137,7 +138,7 @@ docker compose exec spark /opt/spark/bin/spark-sql -f /init.sql #### Modeling the Graph -Now that our data is loaded in, we can log into the PuppyGraph Web UI at [http://localhost:8081](http://localhost:8081) with the default credentials (username: puppygraph, password: puppygraph123) +Now that our data is loaded in, we can log into the PuppyGraph Web UI at [http://localhost:8081](http://localhost:8081) with the default credentials (username: puppygraph, password: puppygraph123)
![](/assets/images/blog/2025-10-02-Real-Time-Cloud-Security-Graphs-Hudi+PuppyGraph/fig-5-PuppyGraph-Login-Page.png) @@ -162,9 +163,9 @@ Once you see your graph schema loaded in, you’re ready to start querying your By modeling the network infrastructure as a graph, users can identify potential security risks, such as: -* Public IP addresses exposed to the internet -* Network interfaces not protected by any security group -* Roles granted excessive access permissions +* Public IP addresses exposed to the internet +* Network interfaces not protected by any security group +* Roles granted excessive access permissions * Security groups with overly permissive ingress rules Listed below are some sample queries you can try running to explore the data: diff --git a/website/blog/2025-10-16-Modernizing-Upstox-Data-Platform-with-Apache-Hudi-DBT-and-EMR-Serverless.md b/website/blog/2025-10-16-Modernizing-Upstox-Data-Platform-with-Apache-Hudi-DBT-and-EMR-Serverless.md index 6e0d1fbfc6988..c17e86cb30058 100644 --- a/website/blog/2025-10-16-Modernizing-Upstox-Data-Platform-with-Apache-Hudi-DBT-and-EMR-Serverless.md +++ b/website/blog/2025-10-16-Modernizing-Upstox-Data-Platform-with-Apache-Hudi-DBT-and-EMR-Serverless.md @@ -3,6 +3,7 @@ title: "Modernizing Upstox's Data Platform with Apache Hudi, dbt, and EMR Server excerpt: "" author: The Hudi Community category: blog +subCategory: data lake image: /assets/images/blog/2025-10-16-Modernizing-Upstox-Data-Platform-with-Apache-Hudi-DBT-and-EMR-Serverless/fig1.png tags: - hudi diff --git a/website/blog/2025-10-22-Partition_Stats_Enhancing_Column_Stats_in_Hudi_1.0.md b/website/blog/2025-10-22-Partition_Stats_Enhancing_Column_Stats_in_Hudi_1.0.md index 3c922bfaaa60d..c719a25aa07ed 100644 --- a/website/blog/2025-10-22-Partition_Stats_Enhancing_Column_Stats_in_Hudi_1.0.md +++ b/website/blog/2025-10-22-Partition_Stats_Enhancing_Column_Stats_in_Hudi_1.0.md @@ -3,6 +3,7 @@ title: "Partition Stats: Enhancing Column Stats in Hudi 1.0" excerpt: "" author: Aditya Goenka and Shiyan Xu category: blog +subCategory: lakehouse image: /assets/images/blog/2025-10-22-Partition_Stats_Enhancing_Column_Stats_in_Hudi_1.0/fig1.jpg tags: - hudi diff --git a/website/blog/2025-10-29-deep-dive-into-hudis-indexing-subsystem-part-1-of-2.md b/website/blog/2025-10-29-deep-dive-into-hudis-indexing-subsystem-part-1-of-2.md index eac1c111e83a4..640fddbc64537 100644 --- a/website/blog/2025-10-29-deep-dive-into-hudis-indexing-subsystem-part-1-of-2.md +++ b/website/blog/2025-10-29-deep-dive-into-hudis-indexing-subsystem-part-1-of-2.md @@ -3,6 +3,7 @@ title: "Deep Dive Into Hudi’s Indexing Subsystem (Part 1 of 2)" excerpt: "" author: Shiyan Xu category: blog +subCategory: upserts image: /assets/images/blog/2025-10-29-deep-dive-into-hudis-indexing-subsystem-part-1-of-2/fig1.png tags: - hudi diff --git a/website/blog/2025-11-07-how-freewheel-uses-apache-hudi-to-power-its-data-lakehouse.mdx b/website/blog/2025-11-07-how-freewheel-uses-apache-hudi-to-power-its-data-lakehouse.mdx index 41914e4d002da..20cb316792c71 100644 --- a/website/blog/2025-11-07-how-freewheel-uses-apache-hudi-to-power-its-data-lakehouse.mdx +++ b/website/blog/2025-11-07-how-freewheel-uses-apache-hudi-to-power-its-data-lakehouse.mdx @@ -3,6 +3,7 @@ title: "How FreeWheel Uses Apache Hudi to Power Its Data Lakehouse" excerpt: "How FreeWheel unified batch and streaming with an Apache Hudi–powered lakehouse to improve freshness, simplify operations, and scale analytics." author: The Hudi Community category: blog +subCategory: security image: /assets/images/blog/2025-11-07-how-freewheel-uses-apache-hudi-to-power-its-data-lakehouse/image1.png tags: - hudi diff --git a/website/blog/2025-11-12-deep-dive-into-hudis-indexing-subsystem-part-2-of-2.md b/website/blog/2025-11-12-deep-dive-into-hudis-indexing-subsystem-part-2-of-2.md index 83170d4cfa252..136ea957f0cf3 100644 --- a/website/blog/2025-11-12-deep-dive-into-hudis-indexing-subsystem-part-2-of-2.md +++ b/website/blog/2025-11-12-deep-dive-into-hudis-indexing-subsystem-part-2-of-2.md @@ -3,6 +3,7 @@ title: Deep Dive Into Hudi's Indexing Subsystem (Part 2 of 2) excerpt: 'Explore advanced indexing in Apache Hudi: record and secondary indexes for fast point lookups, expression indexes for transformed predicates, and async indexing for building indexes without blocking writes.' author: Shiyan Xu category: blog +subCategory: data lake image: /assets/images/blog/2025-11-12-deep-dive-into-hudis-indexing-subsystem-part-2-of-2/fig1.png tags: - hudi diff --git a/website/learn/blog.md b/website/learn/blog.md index 7bd0edc50e8c5..4471286481a79 100644 --- a/website/learn/blog.md +++ b/website/learn/blog.md @@ -1,11 +1,8 @@ --- title: "Blogs" +hide_title: true --- import BlogList from '@site/src/components/BlogList'; -# Blogs - -Welcome to Apache Hudi blogs! Here you'll find the latest articles, tutorials, and updates from the Hudi community. - diff --git a/website/src/components/BlogList/Icon/search.svg b/website/src/components/BlogList/Icon/search.svg new file mode 100644 index 0000000000000..229e4b8f2b5dc --- /dev/null +++ b/website/src/components/BlogList/Icon/search.svg @@ -0,0 +1,3 @@ + + + diff --git a/website/src/components/BlogList/index.js b/website/src/components/BlogList/index.js index e91e96c86da51..5ef58f49aa82c 100644 --- a/website/src/components/BlogList/index.js +++ b/website/src/components/BlogList/index.js @@ -1,8 +1,14 @@ -import React, { useState } from 'react'; +import {useDateTimeFormat} from "@docusaurus/theme-common/internal"; +import React, {useState, useMemo, useEffect, useRef} from 'react'; import Link from '@docusaurus/Link'; import { useBaseUrlUtils } from '@docusaurus/core/lib/client/exports/useBaseUrl'; import AuthorName from '@site/src/components/AuthorName'; import styles from '../ContentList/styles.module.css'; +import {useHistory} from '@docusaurus/router'; +import SearchIcon from './Icon/search.svg'; +import Title from "@site/src/components/Title"; +import { useLocation } from 'react-router-dom'; + const allBlogPosts = ((ctx) => { const blogpostNames = ctx.keys(); @@ -29,20 +35,82 @@ const allBlogPosts = ((ctx) => { ); })(require.context('../../../blog', true, /\.mdx?$/)); -const sortedBlogPosts = allBlogPosts - .filter(post => post.metadata && post.metadata.title && post.metadata.permalink) - .sort((a,b) => { - const dateA = a.metadata?.date ? new Date(a.metadata.date).getTime() : 0; - const dateB = b.metadata?.date ? new Date(b.metadata.date).getTime() : 0; - return dateB - dateA; - }); - const POSTS_PER_PAGE = 12; export default function BlogList() { + const history = useHistory(); + const location = useLocation(); + const urlParams = new URLSearchParams(location.search); + const defaultCategory = urlParams.get("category"); + const defaultPage = +urlParams.get("page"); + const defaultSearch = urlParams.get("search") || ""; + const { withBaseUrl } = useBaseUrlUtils(); - const [currentPage, setCurrentPage] = useState(1); - + const [currentPage, setCurrentPage] = useState(defaultPage || 1); + const [category, setCategory] = useState(defaultCategory || 'all'); + const [searchInput, setSearchInput] = useState(defaultSearch); + const [searchQuery, setSearchQuery] = useState(defaultSearch); + const debounceTimerRef = useRef(null); + + const buildUrl = (category, pageNum, search) => { + const parts = []; + parts.push(`category=${encodeURIComponent(category)}`); + parts.push(`page=${pageNum}`); + if(search && search.trim()) parts.push(`search=${encodeURIComponent(search.trim())}`); + return `/learn/blog?${parts.join('&')}`; + }; + + useEffect(() => { + debounceTimerRef.current = setTimeout(() => { + const isNewSearch = searchInput !== searchQuery; + if (isNewSearch) { + setCurrentPage(1); + } + setSearchQuery(searchInput); + history.replace( + buildUrl( + category, + isNewSearch ? 1 : currentPage, + searchInput + ) + ); + + }, 800); + + return () => { + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + } + }; + }, [searchInput]); + + const filteretdBlogPosts = useMemo(() => { + let filtered = allBlogPosts; + // Filter by subCategory + if(category !== 'all') { + filtered = filtered.filter((elem) => elem.frontMatter.subCategory === category); + } + + // Filter by search query - only search by title (case-insensitive) + if(searchQuery.trim()) { + const query = searchQuery.toLowerCase().trim(); + filtered = filtered.filter((post) => { + const title = post.metadata?.title?.toLowerCase() || ''; + return title.includes(query); + }); + } + + return filtered; + },[category, searchQuery]) + + const sortedBlogPosts = filteretdBlogPosts + .filter(post => post.metadata && post.metadata.title && post.metadata.permalink) + .sort((a,b) => { + const dateA = a.metadata?.date ? new Date(a.metadata.date).getTime() : 0; + const dateB = b.metadata?.date ? new Date(b.metadata.date).getTime() : 0; + return dateB - dateA; + }); + const totalPages = Math.ceil(sortedBlogPosts.length / POSTS_PER_PAGE); const startIndex = (currentPage - 1) * POSTS_PER_PAGE; const endIndex = startIndex + POSTS_PER_PAGE; @@ -51,6 +119,11 @@ export default function BlogList() { const handlePageChange = (page) => { setCurrentPage(page); window.scrollTo({ top: 0, behavior: 'smooth' }); + history.push(buildUrl(category, page, searchQuery)); + }; + + const handleSearchChange = (e) => { + setSearchInput(e.target.value); }; const getPageNumbers = () => { @@ -58,39 +131,90 @@ export default function BlogList() { const maxVisible = 5; let startPage = Math.max(1, currentPage - Math.floor(maxVisible / 2)); let endPage = Math.min(totalPages, startPage + maxVisible - 1); - + if (endPage - startPage < maxVisible - 1) { startPage = Math.max(1, endPage - maxVisible + 1); } - + for (let i = startPage; i <= endPage; i++) { pages.push(i); } return pages; }; + const categoryData = [ + {label:"All", value:"all"}, + {label:"Indexing", value:"indexing"}, + {label:"Security", value:"security"}, + {label:"Use Case", value:"use case"}, + {label:"Data Lake", value:"data lake"}, + {label:"Lakehouse", value:"lakehouse"}, + {label:"Upserts", value:"upserts"}, + ] + + return (
-

All Blog Posts

-
+
+ + </div> + <div className={styles.blogFilterSection}> + <div className={styles.blogFilters}> + <div className={styles.categoryBar}> + {categoryData.map(elem => ( + <button + key={elem.label} + className={elem.value === (category ?? 'all') ? styles.categoryActive : styles.category} + onClick={() => { + setCategory(elem.value) + setCurrentPage(1) + setSearchInput('') + setSearchQuery('') + history.push(buildUrl(elem.value, 1, '')); + }} + type="button" + > + {elem.label} + </button> + ))} + </div> + <div className={styles.searchContainer}> + <SearchIcon className={styles.searchIcon} width={20} height={20} /> + <input + type="text" + className={styles.searchInput} + placeholder="Search" + value={searchInput} + onChange={handleSearchChange} + /> + </div> + </div> + </div> + <div key={`${category}-${searchQuery}-${currentPage}`} className={styles.gridWrapper}> + <div className={styles.grid}> {currentPosts.map((blog, index) => { const { frontMatter, assets, metadata } = blog; const { date, title, authors, permalink, description } = metadata || {}; const image = assets?.image ?? frontMatter?.image ?? "/assets/images/hudi.png"; - - if (!title || !permalink) { - return null; - } - - const dateObj = date ? new Date(date) : null; - const formattedDate = dateObj ? dateObj.toLocaleDateString('en-US', { - year: 'numeric', - month: 'long', - day: 'numeric' - }) : ''; + + if (!title || !permalink) return null; + + const dateTimeFormat = useDateTimeFormat({ + day: 'numeric', + month: 'long', + year: 'numeric', + timeZone: 'UTC', + }); + + const formatDate = (blogDate) => dateTimeFormat.format(new Date(blogDate)); + const formattedDate = date ? formatDate(date) : ''; return ( - <article key={index} className={styles.card}> + <article + key={index} + className={styles.card} + style={{ animationDelay: `${index * 0.1}s` }} + > <Link to={permalink} className={styles.link} target="_blank" rel="noopener noreferrer"> <div className={styles.imageWrapper}> <img @@ -100,6 +224,7 @@ export default function BlogList() { /> </div> <div className={styles.content}> + <h3 className={styles.title}>{title}</h3> <div className={styles.meta}> <AuthorName authors={authors} @@ -108,14 +233,14 @@ export default function BlogList() { /> <span className={styles.date}>{formattedDate}</span> </div> - <h3 className={styles.title}>{title}</h3> </div> </Link> </article> ); })} + </div> </div> - + {totalPages > 1 && ( <nav className={styles.pagination} aria-label="Blog pagination"> <button @@ -126,7 +251,7 @@ export default function BlogList() { > Previous </button> - + <div className={styles.paginationNumbers}> {getPageNumbers().map((pageNum) => ( <button @@ -140,7 +265,7 @@ export default function BlogList() { </button> ))} </div> - + <button className={styles.paginationButton} onClick={() => handlePageChange(currentPage + 1)} @@ -151,9 +276,13 @@ export default function BlogList() { </button> </nav> )} - + <div className={styles.paginationInfo}> - Showing {startIndex + 1}-{Math.min(endIndex, sortedBlogPosts.length)} of {sortedBlogPosts.length} posts + {currentPosts.length > 0 ? ( + <>Showing {startIndex + 1}-{Math.min(endIndex, sortedBlogPosts.length)} of {sortedBlogPosts.length} posts</> + ) : ( + <>No blog posts available</> + )} </div> </div> ); diff --git a/website/src/components/ContentList/styles.module.css b/website/src/components/ContentList/styles.module.css index fa1caa1210d6e..48a0ae2ff2eaf 100644 --- a/website/src/components/ContentList/styles.module.css +++ b/website/src/components/ContentList/styles.module.css @@ -9,22 +9,90 @@ font-size: 2rem; } +.gridWrapper { + animation: wrapperFadeIn 0.4s ease-out; + will-change: opacity, transform; + transition: opacity 0.4s ease-out, transform 0.4s ease-out; +} + +@keyframes wrapperFadeIn { + 0% { + opacity: 0; + transform: scale(0.8); + } + 100% { + opacity: 1; + transform: scale(1); + } +} + .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 2rem; + animation: gridFadeIn 0.5s ease-out; + will-change: opacity, transform, scale; + position: relative; + transition: opacity 0.3s ease-out, transform 0.3s ease-out; +} + +@keyframes gridFadeIn { + 0% { + opacity: 0; + transform: translateX(20px); + } + 100% { + opacity: 1; + transform: translateX(0); + } +} + +.grid.fadeOut { + animation: gridFadeOut 0.3s ease-in forwards; +} + +@keyframes gridFadeOut { + 0% { + opacity: 1; + transform: scale(1); + } + 100% { + opacity: 0; + transform: scale(0.95); + } } .card { - border: 1px solid var(--ifm-color-emphasis-200); - border-radius: 8px; + border: 1px solid var(--ifm-gray-200); + border-radius: 12px; overflow: hidden; - transition: transform 0.2s, box-shadow 0.2s; + transition: transform 0.4s cubic-bezier(0.4, 0, 0.2, 1), + box-shadow 0.2s, + opacity 0.4s cubic-bezier(0.4, 0, 0.2, 1), + scale 0.4s cubic-bezier(0.4, 0, 0.2, 1), + height 0.4s cubic-bezier(0.4, 0, 0.2, 1), + margin 0.4s cubic-bezier(0.4, 0, 0.2, 1); + animation: fadeInUp 0.6s ease-out; + opacity: 0; + animation-fill-mode: forwards; + will-change: opacity, transform, scale, height; + transform-origin: center; +} + +@keyframes fadeInUp { + 0% { + opacity: 0; + transform: translateY(25px) scale(0.95); + } + 100% { + opacity: 1; + transform: translateY(0) scale(1); + } } .card:hover { transform: translateY(-4px); - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + box-shadow: 0px 5px 12px 0px #06376D1A; } .link { @@ -52,8 +120,8 @@ .meta { display: flex; - align-items: center; - gap: 1rem; + flex-direction: column; + gap: 4px; margin-bottom: 0.75rem; font-size: 0.875rem; color: var(--ifm-color-emphasis-600); @@ -61,13 +129,16 @@ .date { font-size: 0.875rem; + color: var(--ifm-gray-800); + font-family: var(--ifm-heading-font-family) !important; } .title { font-size: 1.25rem; font-weight: 600; margin: 0.5rem 0; - color: var(--ifm-heading-color); + color: var(--ifm-blue-900); + font-family: var(--ifm-heading-font-family) !important; } .description { @@ -80,6 +151,7 @@ line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; + font-family: var(--ifm-heading-font-family); } /* Pagination styles */ @@ -157,6 +229,8 @@ /* Author name (used by BlogList and VideoList) */ .authorName { font-size: 0.875rem; + color: var(--ifm-blue-900); + font-family: var(--ifm-heading-font-family) !important; } /* FAQ-specific styles */ @@ -193,7 +267,187 @@ font-weight: 500; } +.link:hover { + text-decoration: none !important; +} + .faqCard:hover .faqLinkText { text-decoration: underline; } +.blogTitle { + display: flex; + justify-content: center; + margin-bottom: 40px; +} + +.blogFilterSection { + display: flex; + flex-direction: column; + gap: 20px; + align-items: center; + margin-bottom: 60px; + width: 100%; +} + +.blogFilterSection > * { + width: fit-content; + min-width: 300px; + max-width: 100%; +} + +.categoryBar { + display: inline-flex; + flex-direction: row; + gap: 12px; + padding: 10px 18px; + border-radius: 30px; + border: 1px solid rgba(10, 37, 64, 0.08); + box-shadow: 0 1px 0 rgba(16, 24, 40, 0.04) inset; + + overflow-x: auto; + overflow-y: hidden; + flex-wrap: nowrap; + white-space: nowrap; + scrollbar-width: thin; + scrollbar-color: rgba(0, 0, 0, 0.2) transparent; + -webkit-overflow-scrolling: touch; +} + +.categoryBar::-webkit-scrollbar { + height: 1px !important; + display: block; +} + +.categoryBar::-webkit-scrollbar-track { + background: transparent; + border-radius: 0; +} + +.categoryBar::-webkit-scrollbar-thumb { + background-color: rgba(0, 0, 0, 0.15) !important; + border-radius: 0; +} + +.categoryBar::-webkit-scrollbar-thumb:hover { + background-color: rgba(0, 0, 0, 0.3) !important; +} + +.category, +.categoryActive { + appearance: none; + background: transparent; + border: 0; + color: var(--ifm-blue-900); + font-weight: 400; + font-size: 16px; + padding: 10px 14px; + border-radius: 20px; + cursor: pointer; + transition: background 150ms ease, color 150ms ease, box-shadow 150ms ease; + white-space: nowrap; + flex-shrink: 0; +} + +.category:hover { + background: var(--ifm-gray-100); +} + +.categoryActive { + background: var(--ifm-gray-100); + color: #0f2e4f; + box-shadow: 0 1px 2px rgba(16, 24, 40, 0.06), + inset 0 0 0 1px rgba(10, 37, 64, 0.10); +} + +.category:focus-visible, +.categoryActive:focus-visible { + outline: 0; + box-shadow: 0 0 0 2px #fff, 0 0 0 4px rgba(13, 177, 249, 0.55); +} + +.searchContainer { + display: inline-flex; + align-items: center; + gap: 12px; + padding: 10px 18px; + border-radius: 30px; + border: 1px solid rgba(10, 37, 64, 0.08); + box-shadow: 0 1px 0 rgba(16, 24, 40, 0.04) inset; + background-color: var(--ifm-background-color); + width: 100%; + height: 50px !important; +} + +.searchIcon { + flex-shrink: 0; + color: #082C54; + width: 20px; + height: 20px; + display: block; +} + +.searchIcon svg { + width: 100%; + height: 100%; + color: inherit; +} + +.searchInput { + flex: 1; + border: 0; + background: transparent; + outline: none; + font-size: 16px; + color: var(--ifm-blue-900); + width: 100%; + min-width: 250px; +} + +.searchInput::placeholder { + color: var(--ifm-blue-900); + opacity: 0.6; + font-size: 14px; +} + +.searchInput:focus { + outline: none; +} + +@media (max-width: 996px) { + .blogFilterSection > * { + min-width: 250px; + } + + .categoryBar { + gap: 14px; + padding: 8px 12px; + } + + .category, .categoryActive { + padding: 8px 12px; + font-size: 15px; + } + + .searchContainer { + padding: 8px 16px; + gap: 10px; + } + + .searchIcon { + width: 18px; + height: 18px; + } + + .searchInput { + font-size: 15px; + } +} + +.blogFilters { + display: flex; + flex-direction: column; + gap: 20px; +} + + diff --git a/website/src/components/VideoList/index.js b/website/src/components/VideoList/index.js index a0dc9d9a84002..3b4834f1f05f1 100644 --- a/website/src/components/VideoList/index.js +++ b/website/src/components/VideoList/index.js @@ -42,7 +42,7 @@ const POSTS_PER_PAGE = 12; export default function VideoList() { const { withBaseUrl } = useBaseUrlUtils(); const [currentPage, setCurrentPage] = useState(1); - + const totalPages = Math.ceil(sortedVideos.length / POSTS_PER_PAGE); const startIndex = (currentPage - 1) * POSTS_PER_PAGE; const endIndex = startIndex + POSTS_PER_PAGE; @@ -58,11 +58,11 @@ export default function VideoList() { const maxVisible = 5; let startPage = Math.max(1, currentPage - Math.floor(maxVisible / 2)); let endPage = Math.min(totalPages, startPage + maxVisible - 1); - + if (endPage - startPage < maxVisible - 1) { startPage = Math.max(1, endPage - maxVisible + 1); } - + for (let i = startPage; i <= endPage; i++) { pages.push(i); } @@ -78,21 +78,21 @@ export default function VideoList() { const { date, title, authors, permalink, description } = metadata || {}; const image = assets?.image ?? frontMatter?.image ?? "/assets/images/hudi.png"; const videoUrl = frontMatter?.navigate || permalink; - + if (!title || !videoUrl) { return null; } - + const dateObj = date ? new Date(date) : null; - const formattedDate = dateObj ? dateObj.toLocaleDateString('en-US', { - year: 'numeric', - month: 'long', - day: 'numeric' + const formattedDate = dateObj ? dateObj.toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric' }) : ''; const isExternalUrl = videoUrl && (videoUrl.startsWith('http://') || videoUrl.startsWith('https://')); const LinkComponent = isExternalUrl ? 'a' : Link; - const linkProps = isExternalUrl + const linkProps = isExternalUrl ? { href: videoUrl, target: '_blank', rel: 'noopener noreferrer' } : { to: videoUrl, target: '_blank', rel: 'noopener noreferrer' }; @@ -127,7 +127,7 @@ export default function VideoList() { ); })} </div> - + {totalPages > 1 && ( <nav className={styles.pagination} aria-label="Video pagination"> <button @@ -138,7 +138,7 @@ export default function VideoList() { > Previous </button> - + <div className={styles.paginationNumbers}> {getPageNumbers().map((pageNum) => ( <button @@ -152,7 +152,7 @@ export default function VideoList() { </button> ))} </div> - + <button className={styles.paginationButton} onClick={() => handlePageChange(currentPage + 1)} @@ -163,7 +163,7 @@ export default function VideoList() { </button> </nav> )} - + <div className={styles.paginationInfo}> Showing {startIndex + 1}-{Math.min(endIndex, sortedVideos.length)} of {sortedVideos.length} videos </div> diff --git a/website/src/css/custom.css b/website/src/css/custom.css index b70f290cbab52..e4d248eb1e66b 100644 --- a/website/src/css/custom.css +++ b/website/src/css/custom.css @@ -38,6 +38,8 @@ --ifm-blue-800: #15467D; --ifm-blue-900: #082C54; --ifmyellow-500: #FFAA00; + --ifm-gray-100: #EDF1F4; + --ifm-gray-200: #E4EBEF; --ifm-gray-500: #C0CED5; --ifm-gray-600: #ABBDC7; --ifm-gray-800: #69818E; diff --git a/website/src/theme/BlogPostItem/Header/Authors/index.js b/website/src/theme/BlogPostItem/Header/Authors/index.js index dd243caffe3c3..c2acdc57c0405 100644 --- a/website/src/theme/BlogPostItem/Header/Authors/index.js +++ b/website/src/theme/BlogPostItem/Header/Authors/index.js @@ -19,6 +19,7 @@ export default function BlogPostItemHeaderAuthors({className}) { <div className={clsx( imageOnly ? styles.imageOnlyAuthorRow : 'row', + styles.wrapper, className, )}> {authors.map((author, idx) => ( diff --git a/website/src/theme/BlogPostItem/Header/Authors/styles.module.css b/website/src/theme/BlogPostItem/Header/Authors/styles.module.css index 01a73306960b2..4814cc3c1c7f4 100644 --- a/website/src/theme/BlogPostItem/Header/Authors/styles.module.css +++ b/website/src/theme/BlogPostItem/Header/Authors/styles.module.css @@ -1,3 +1,6 @@ +.wrapper { + margin: 0; +} .authorCol { max-width: inherit !important; } @@ -13,7 +16,9 @@ } .authorWrapper { - margin-left: 10px; + display: flex; + gap: 10px; + padding: 0; .avatar__name { span { font-weight: 600 !important; diff --git a/website/src/theme/BlogPostItem/Header/Info/index.js b/website/src/theme/BlogPostItem/Header/Info/index.js index 42553fb792481..bb1813a4134a8 100644 --- a/website/src/theme/BlogPostItem/Header/Info/index.js +++ b/website/src/theme/BlogPostItem/Header/Info/index.js @@ -47,8 +47,8 @@ export default function BlogPostItemHeaderInfo({className}) { const formatDate = (blogDate) => dateTimeFormat.format(new Date(blogDate)); return ( <div className={clsx(styles.container, 'margin-vert--sm', className)}> - <DateTime date={date} formattedDate={formatDate(date)} /> <BlogPostItemHeaderAuthors /> + <DateTime date={date} formattedDate={formatDate(date)} /> {typeof readingTime !== 'undefined' && ( <> <ReadingTime readingTime={readingTime} /> diff --git a/website/src/theme/BlogPostItem/Header/Info/styles.module.css b/website/src/theme/BlogPostItem/Header/Info/styles.module.css index 46e2febfcba2d..56ea0c74d3a04 100644 --- a/website/src/theme/BlogPostItem/Header/Info/styles.module.css +++ b/website/src/theme/BlogPostItem/Header/Info/styles.module.css @@ -1,6 +1,7 @@ .container { color: #1c1e21; display: flex; + gap:10px; flex-direction: row; font-size: 1.1rem; margin-left: 2px;