diff --git a/public/images/not-found.svg b/public/images/not-found.svg
new file mode 100644
index 00000000000..cb212359571
--- /dev/null
+++ b/public/images/not-found.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/components/ChangelogFilters/ChangelogFilters.tsx b/src/components/ChangelogFilters/ChangelogFilters.tsx
new file mode 100644
index 00000000000..2e0647230ba
--- /dev/null
+++ b/src/components/ChangelogFilters/ChangelogFilters.tsx
@@ -0,0 +1,35 @@
+import styles from "./styles.module.css"
+import type { ChangelogItem } from "~/components/ChangelogSnippet/types.ts"
+import { DesktopFilters } from "./DesktopFilters.tsx"
+import { MobileFilters } from "./MobileFilters.tsx"
+import { useState } from "react"
+import { clsx } from "~/lib/clsx/clsx.ts"
+
+export interface ChangelogFiltersProps {
+ products: string[]
+ networks: string[]
+ types: string[]
+ items: ChangelogItem[]
+}
+
+export const ChangelogFilters = ({ products, networks, types, items }: ChangelogFiltersProps) => {
+ const [searchExpanded, setSearchExpanded] = useState(false)
+
+ return (
+
+ )
+}
diff --git a/src/components/ChangelogFilters/DesktopFilters.tsx b/src/components/ChangelogFilters/DesktopFilters.tsx
new file mode 100644
index 00000000000..c54ce85b814
--- /dev/null
+++ b/src/components/ChangelogFilters/DesktopFilters.tsx
@@ -0,0 +1,245 @@
+import { SvgSearch, SvgTaillessArrowDownSmall, SvgX } from "@chainlink/blocks"
+import styles from "./styles.module.css"
+import { useState, useEffect, useRef } from "react"
+import { clsx } from "~/lib/clsx/clsx.ts"
+import type { ChangelogItem } from "~/components/ChangelogSnippet/types.ts"
+import { useChangelogFilters } from "./useChangelogFilters.ts"
+
+type FilterType = "product" | "network" | "type" | null
+
+interface SearchInputProps {
+ isExpanded: boolean
+ onClick: (value: boolean) => void
+ value: string
+ onChange: (value: string) => void
+}
+
+const SearchInput = ({ isExpanded, onClick, value, onChange }: SearchInputProps) => {
+ return (
+ onClick(true)}>
+
+ onChange(e.target.value)}
+ />
+ {isExpanded && (
+ {
+ e.stopPropagation()
+ onClick(false)
+ onChange("")
+ }}
+ style={{
+ marginRight: "var(--space-4x)",
+ }}
+ />
+ )}
+
+ )
+}
+
+interface TriggerProps {
+ label: string
+ count: number
+ isActive: boolean
+ onClick: () => void
+ onClose: () => void
+ onClearAll: () => void
+}
+
+const Trigger = ({ label, count, isActive, onClick, onClose, onClearAll }: TriggerProps) => {
+ return (
+
+ )
+}
+
+interface FilterPillProps {
+ label: string
+ isSelected: boolean
+ onClick: () => void
+}
+
+const FilterPill = ({ label, isSelected, onClick }: FilterPillProps) => {
+ return (
+
+ )
+}
+
+interface DesktopFiltersProps {
+ products: string[]
+ networks: string[]
+ types: string[]
+ items: ChangelogItem[]
+}
+
+export const DesktopFilters = ({ products, networks, types, items }: DesktopFiltersProps) => {
+ const [activeFilter, setActiveFilter] = useState(null)
+ const wrapperRef = useRef(null)
+
+ const {
+ searchExpanded,
+ searchTerm,
+ selectedProducts,
+ selectedNetworks,
+ selectedTypes,
+ handleSearchChange,
+ handleSearchToggle,
+ toggleSelection,
+ clearProductFilters,
+ clearNetworkFilters,
+ clearTypeFilters,
+ } = useChangelogFilters({ items })
+
+ const toggleFilter = (filterType: FilterType) => {
+ setActiveFilter(filterType)
+ }
+
+ const closeFilter = () => {
+ setActiveFilter(null)
+ }
+
+ // Close filter when clicking outside
+ useEffect(() => {
+ if (!activeFilter) return
+
+ const handleClickOutside = (event: MouseEvent) => {
+ if (wrapperRef.current && !wrapperRef.current.contains(event.target as Node)) {
+ closeFilter()
+ }
+ }
+
+ document.addEventListener("mousedown", handleClickOutside)
+ return () => {
+ document.removeEventListener("mousedown", handleClickOutside)
+ }
+ }, [activeFilter])
+
+ const getFilterOptions = () => {
+ switch (activeFilter) {
+ case "product":
+ return products
+ case "network":
+ return networks
+ case "type":
+ return types
+ default:
+ return []
+ }
+ }
+
+ const getSelectedValues = () => {
+ switch (activeFilter) {
+ case "product":
+ return selectedProducts
+ case "network":
+ return selectedNetworks
+ case "type":
+ return selectedTypes
+ default:
+ return []
+ }
+ }
+
+ return (
+
+ {activeFilter && (
+
+ {getFilterOptions().map((option) => (
+ {
+ const type = activeFilter as "product" | "network" | "type"
+ toggleSelection(type, option)
+ }}
+ />
+ ))}
+
+ )}
+
+ {!searchExpanded && (
+ <>
+ toggleFilter("product")}
+ onClose={closeFilter}
+ onClearAll={clearProductFilters}
+ />
+ toggleFilter("network")}
+ onClose={closeFilter}
+ onClearAll={clearNetworkFilters}
+ />
+ toggleFilter("type")}
+ onClose={closeFilter}
+ onClearAll={clearTypeFilters}
+ />
+ >
+ )}
+
+
+
+ )
+}
diff --git a/src/components/ChangelogFilters/MobileFilters.tsx b/src/components/ChangelogFilters/MobileFilters.tsx
new file mode 100644
index 00000000000..9240d6b7fe8
--- /dev/null
+++ b/src/components/ChangelogFilters/MobileFilters.tsx
@@ -0,0 +1,337 @@
+import { SvgSearch, SvgTaillessArrowDownSmall, SvgX } from "@chainlink/blocks"
+import styles from "./styles.module.css"
+import { useState, useEffect } from "react"
+import { createPortal } from "react-dom"
+import { clsx } from "~/lib/clsx/clsx.ts"
+import type { ChangelogItem } from "~/components/ChangelogSnippet/types.ts"
+import { useChangelogFilters } from "./useChangelogFilters.ts"
+
+type FilterType = "product" | "network" | "type" | null
+
+interface SearchInputProps {
+ isExpanded: boolean
+ onClick: (value: boolean) => void
+ value: string
+ onChange: (value: string) => void
+}
+
+const SearchInput = ({ isExpanded, onClick, value, onChange }: SearchInputProps) => {
+ return (
+ onClick(true)}>
+
+ onChange(e.target.value)}
+ />
+ {isExpanded && (
+ {
+ e.stopPropagation()
+ onClick(false)
+ onChange("")
+ }}
+ style={{
+ marginRight: "var(--space-4x)",
+ }}
+ />
+ )}
+
+ )
+}
+
+interface FilterPillProps {
+ label: string
+ isSelected: boolean
+ onClick: () => void
+}
+
+const FilterPill = ({ label, isSelected, onClick }: FilterPillProps) => {
+ return (
+
+ )
+}
+
+interface MobileFiltersButtonProps {
+ totalCount: number
+ onClick: () => void
+}
+
+const MobileFiltersButton = ({ totalCount, onClick }: MobileFiltersButtonProps) => {
+ return (
+
+ )
+}
+
+interface FilterSectionProps {
+ title: string
+ count: number
+ isExpanded: boolean
+ options: string[]
+ selectedValues: string[]
+ onToggle: () => void
+ onSelect: (value: string) => void
+ onClearAll: () => void
+}
+
+const FilterSection = ({
+ title,
+ count,
+ isExpanded,
+ options,
+ selectedValues,
+ onToggle,
+ onSelect,
+ onClearAll,
+}: FilterSectionProps) => {
+ return (
+
+
+ {isExpanded && (
+
+ {options.map((option) => (
+ onSelect(option)}
+ />
+ ))}
+
+ )}
+
+ )
+}
+
+interface MobileFiltersModalProps {
+ isOpen: boolean
+ onClose: () => void
+ products: string[]
+ networks: string[]
+ types: string[]
+ selectedProducts: string[]
+ selectedNetworks: string[]
+ selectedTypes: string[]
+ onSelectProduct: (value: string) => void
+ onSelectNetwork: (value: string) => void
+ onSelectType: (value: string) => void
+ onClearAll: () => void
+ expandedSection: FilterType
+ onToggleSection: (section: FilterType) => void
+ onClearProducts: () => void
+ onClearNetworks: () => void
+ onClearTypes: () => void
+}
+
+const MobileFiltersModal = ({
+ isOpen,
+ onClose,
+ products,
+ networks,
+ types,
+ selectedProducts,
+ selectedNetworks,
+ selectedTypes,
+ onSelectProduct,
+ onSelectNetwork,
+ onSelectType,
+ onClearAll,
+ expandedSection,
+ onToggleSection,
+ onClearProducts,
+ onClearNetworks,
+ onClearTypes,
+}: MobileFiltersModalProps) => {
+ if (!isOpen) return null
+
+ return (
+ <>
+
+
+
+
Filters
+
+
+
+ onToggleSection(expandedSection === "product" ? null : "product")}
+ onSelect={onSelectProduct}
+ onClearAll={onClearProducts}
+ />
+ onToggleSection(expandedSection === "network" ? null : "network")}
+ onSelect={onSelectNetwork}
+ onClearAll={onClearNetworks}
+ />
+ onToggleSection(expandedSection === "type" ? null : "type")}
+ onSelect={onSelectType}
+ onClearAll={onClearTypes}
+ />
+
+
+
+
+
+
+ >
+ )
+}
+
+interface MobileFiltersProps {
+ products: string[]
+ networks: string[]
+ types: string[]
+ items: ChangelogItem[]
+ searchExpanded: boolean
+ onSearchExpandedChange: (expanded: boolean) => void
+}
+
+export const MobileFilters = ({
+ products,
+ networks,
+ types,
+ items,
+ searchExpanded,
+ onSearchExpandedChange,
+}: MobileFiltersProps) => {
+ const [isMobileFiltersOpen, setIsMobileFiltersOpen] = useState(false)
+ const [expandedSection, setExpandedSection] = useState(null)
+
+ const {
+ searchTerm,
+ selectedProducts,
+ selectedNetworks,
+ selectedTypes,
+ handleSearchChange,
+ toggleSelection,
+ clearProductFilters,
+ clearNetworkFilters,
+ clearTypeFilters,
+ clearAllFilters,
+ } = useChangelogFilters({ items })
+
+ const totalFilterCount = selectedProducts.length + selectedNetworks.length + selectedTypes.length
+
+ // Disable body scroll when mobile modal is open
+ useEffect(() => {
+ if (typeof window === "undefined") return
+
+ if (isMobileFiltersOpen) {
+ document.body.style.overflow = "hidden"
+ } else {
+ document.body.style.overflow = ""
+ }
+
+ return () => {
+ document.body.style.overflow = ""
+ }
+ }, [isMobileFiltersOpen])
+
+ const modalContent = (
+ setIsMobileFiltersOpen(false)}
+ products={products}
+ networks={networks}
+ types={types}
+ selectedProducts={selectedProducts}
+ selectedNetworks={selectedNetworks}
+ selectedTypes={selectedTypes}
+ onSelectProduct={(value) => toggleSelection("product", value)}
+ onSelectNetwork={(value) => toggleSelection("network", value)}
+ onSelectType={(value) => toggleSelection("type", value)}
+ onClearAll={clearAllFilters}
+ expandedSection={expandedSection}
+ onToggleSection={setExpandedSection}
+ onClearProducts={clearProductFilters}
+ onClearNetworks={clearNetworkFilters}
+ onClearTypes={clearTypeFilters}
+ />
+ )
+
+ return (
+ <>
+
+ setIsMobileFiltersOpen(true)} />
+
+
+
+ {typeof document !== "undefined" && createPortal(modalContent, document.body)}
+ >
+ )
+}
diff --git a/src/components/ChangelogFilters/styles.module.css b/src/components/ChangelogFilters/styles.module.css
new file mode 100644
index 00000000000..83f34ade5f6
--- /dev/null
+++ b/src/components/ChangelogFilters/styles.module.css
@@ -0,0 +1,418 @@
+.wrapper {
+ background: #252e42e6;
+ border-radius: var(--space-8x);
+ max-width: 700px;
+ min-width: 492px;
+
+ margin: 0 auto;
+ position: fixed;
+ bottom: var(--space-4x);
+ height: fit-content;
+ min-height: 56px;
+ z-index: 11;
+ left: 50%;
+ transform: translateX(-50%);
+
+ padding: var(--space-2x);
+ backdrop-filter: blur(2px);
+ -webkit-backdrop-filter: blur(2px);
+}
+
+.content {
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ align-items: center;
+ justify-content: space-evenly;
+}
+
+.btn {
+ border-radius: var(--space-8x);
+ padding: 0 var(--space-4x);
+ color: var(--gray-300);
+ display: flex;
+ transition: all 0.1s ease;
+ align-items: center;
+ justify-content: center;
+ height: 100%;
+ &:hover {
+ background-color: #252e42;
+ }
+
+ & div {
+ margin-right: var(--space-2x);
+ font-size: 16px;
+ display: flex;
+ align-items: center;
+ gap: var(--space-2x);
+ & span {
+ display: flex;
+ color: var(--white);
+ align-items: center;
+ gap: var(--space-2x);
+ background-color: var(--gray-500);
+ border-radius: var(--space-8x);
+ padding: var(--space-1x) var(--space-3x);
+ }
+ }
+}
+
+.searchInputWrapper.expanded {
+ width: 100%;
+ grid-column-end: span 4;
+}
+
+.searchIcon {
+ width: 22px;
+ height: 22px;
+}
+
+.searchInputWrapper {
+ background-color: #252e42;
+ display: flex;
+ padding: var(--space-2x) var(--space-3x);
+ border-radius: var(--space-8x);
+ align-items: center;
+ gap: var(--space-2x);
+ transition: all 0.1s ease;
+ min-width: 108px;
+}
+
+.searchInput {
+ background: transparent;
+ color: var(--gray-300);
+ font-size: 14px;
+ &::placeholder {
+ color: var(--gray-400);
+ font-style: normal;
+ }
+}
+
+.expandedContent {
+ max-height: 312px;
+ overflow-y: auto;
+ overflow-x: hidden;
+ padding: var(--space-4x);
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-1x);
+ margin-bottom: var(--space-2x);
+ border-bottom: 1px solid var(--gray-800);
+}
+
+.expandedContent::-webkit-scrollbar {
+ width: 6px;
+}
+
+.expandedContent::-webkit-scrollbar-track {
+ background: transparent;
+}
+
+.expandedContent::-webkit-scrollbar-thumb {
+ background: rgba(255, 255, 255, 0.2);
+ border-radius: 3px;
+}
+
+.expandedContent::-webkit-scrollbar-thumb:hover {
+ background: rgba(255, 255, 255, 0.3);
+}
+
+.pill {
+ background-color: transparent;
+ color: var(--white);
+ border-radius: var(--space-8x);
+ padding: var(--space-1x) var(--space-3x);
+ font-weight: 400;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--space-2x);
+ transition: all 0.2s ease;
+ white-space: nowrap;
+ cursor: pointer;
+ border: none;
+ width: fit-content;
+ text-align: left;
+}
+
+.pill:hover {
+ background-color: var(--gray-500);
+}
+
+.pillSelected {
+ color: var(--white);
+ background-color: var(--gray-500);
+
+ & span {
+ border-radius: var(--space-2x);
+ }
+}
+
+.btnActive {
+ background-color: #1e2635;
+}
+
+/* Mobile Styles */
+.mobileFiltersBtn {
+ background-color: #252e42;
+ border-radius: var(--space-8x);
+ padding: var(--space-2x) var(--space-3x);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ position: relative;
+ color: var(--gray-300);
+ transition: all 0.2s ease;
+ cursor: pointer;
+}
+
+.mobileFiltersBtn:hover {
+ background-color: #1e2635;
+}
+
+.mobileBadge {
+ position: absolute;
+ top: -4px;
+ right: -4px;
+ background-color: var(--blue-500);
+ color: var(--white);
+ border-radius: 50%;
+ width: 18px;
+ height: 18px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 10px;
+ font-weight: 600;
+}
+
+/* Mobile Modal */
+.mobileModalBackdrop {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background-color: rgba(0, 0, 0, 0.5);
+ z-index: 100;
+ animation: fadeIn 0.2s ease;
+}
+
+.mobileModal {
+ position: fixed;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ max-height: 80vh;
+ background: #252e42;
+ border-radius: var(--space-6x) var(--space-6x) 0 0;
+ z-index: 101;
+ display: flex;
+ flex-direction: column;
+ animation: slideUp 0.3s ease;
+}
+
+@keyframes fadeIn {
+ from {
+ opacity: 0;
+ }
+ to {
+ opacity: 1;
+ }
+}
+
+@keyframes slideUp {
+ from {
+ transform: translateY(100%);
+ }
+ to {
+ transform: translateY(0);
+ }
+}
+
+.mobileModalHeader {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: var(--space-6x) var(--space-6x) var(--space-4x);
+ border-bottom: 1px solid rgba(255, 255, 255, 0.1);
+}
+
+.mobileModalTitle {
+ font-size: 20px;
+ font-weight: 600;
+ color: var(--white);
+ margin: 0;
+}
+
+.mobileModalClose {
+ background: transparent;
+ padding: var(--space-2x);
+ color: var(--gray-300);
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.mobileModalBody {
+ flex: 1;
+ overflow-y: auto;
+ padding: var(--space-4x) var(--space-6x);
+}
+
+.mobileModalFooter {
+ display: flex;
+ gap: var(--space-3x);
+ padding: var(--space-4x) var(--space-6x);
+ border-top: 1px solid rgba(255, 255, 255, 0.1);
+}
+
+.mobileModalClearAll {
+ flex: 1;
+ padding: var(--space-3x) var(--space-4x);
+ background: transparent;
+ border: 1px solid rgba(255, 255, 255, 0.2);
+ border-radius: var(--space-2x);
+ color: var(--gray-300);
+ font-size: 14px;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all 0.2s ease;
+}
+
+.mobileModalClearAll:hover {
+ background-color: rgba(255, 255, 255, 0.05);
+}
+
+.mobileModalApply {
+ flex: 1;
+ padding: var(--space-3x) var(--space-4x);
+ background: var(--blue-500);
+ border-radius: var(--space-2x);
+ color: var(--white);
+ font-size: 14px;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all 0.2s ease;
+}
+
+.mobileModalApply:hover {
+ background: var(--blue-600);
+}
+
+/* Filter Section */
+.filterSection {
+ margin-bottom: var(--space-4x);
+}
+
+.filterSectionHeader {
+ width: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: var(--space-4x);
+ background: rgba(255, 255, 255, 0.05);
+ border-radius: var(--space-2x);
+ cursor: pointer;
+ transition: all 0.2s ease;
+}
+
+.filterSectionHeader:hover {
+ background: rgba(255, 255, 255, 0.08);
+}
+
+.filterSectionTitle {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2x);
+ font-size: 16px;
+ font-weight: 500;
+ color: var(--white);
+}
+
+.filterSectionCount {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2x);
+ background-color: var(--gray-500);
+ border-radius: var(--space-8x);
+ padding: var(--space-1x) var(--space-3x);
+ font-size: 14px;
+ color: var(--white);
+}
+
+.filterSectionChevron {
+ transition: transform 0.2s ease;
+ color: var(--gray-300);
+}
+
+.filterSectionChevronOpen {
+ transform: rotate(180deg);
+}
+
+.filterSectionContent {
+ padding: var(--space-4x);
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-1x);
+ max-height: 300px;
+ overflow-y: auto;
+}
+
+/* Desktop/Mobile Filter Visibility */
+.desktopFilters {
+ display: block;
+}
+
+.mobileFilters {
+ display: none;
+}
+
+/* Mobile Responsive */
+@media screen and (max-width: 576px) {
+ .desktopFilters {
+ display: none;
+ }
+
+ .mobileFilters {
+ display: block;
+ width: 100%;
+ }
+
+ .wrapper {
+ max-width: calc(100% - var(--space-4x));
+ padding: var(--space-2x);
+ min-width: unset;
+ }
+
+ .wrapper.searchExpanded {
+ width: 100%;
+ max-width: 100%;
+ }
+
+ .content {
+ grid-template-columns: auto 1fr;
+ gap: var(--space-2x);
+ }
+
+ /* When search is expanded on mobile, keep both elements inline */
+ .content.searchExpanded {
+ grid-template-columns: auto 1fr;
+ gap: var(--space-3x);
+ width: 100%;
+ }
+
+ /* Override desktop expanded behavior for mobile */
+ .searchInputWrapper.expanded {
+ width: auto;
+ grid-column-end: auto;
+ }
+
+ .searchInputWrapper {
+ padding: var(--space-2x);
+ }
+
+ .searchInput {
+ font-size: 14px;
+ }
+}
diff --git a/src/components/ChangelogFilters/useChangelogFilters.ts b/src/components/ChangelogFilters/useChangelogFilters.ts
new file mode 100644
index 00000000000..ef18c108cb3
--- /dev/null
+++ b/src/components/ChangelogFilters/useChangelogFilters.ts
@@ -0,0 +1,221 @@
+import { useState, useEffect, useRef } from "react"
+import type { ChangelogItem } from "~/components/ChangelogSnippet/types.ts"
+import { matchesFilters } from "~/utils/changelogFilters.ts"
+import { parseURLParams, updateFilterURL, toggleItemInArray } from "~/utils/changelogFilterUtils.ts"
+
+export interface UseChangelogFiltersProps {
+ items: ChangelogItem[]
+}
+
+interface FilterState {
+ selectedProducts: string[]
+ selectedNetworks: string[]
+ selectedTypes: string[]
+ searchTerm: string
+ searchExpanded: boolean
+}
+
+export const useChangelogFilters = ({ items }: UseChangelogFiltersProps) => {
+ const [filters, setFilters] = useState({
+ selectedProducts: [],
+ selectedNetworks: [],
+ selectedTypes: [],
+ searchTerm: "",
+ searchExpanded: false,
+ })
+ const isInitialMount = useRef(true)
+ const hasLoadedFromURL = useRef(false)
+
+ // Read URL parameters on mount
+ useEffect(() => {
+ const urlParams = parseURLParams()
+
+ setFilters({
+ selectedProducts: urlParams.products,
+ selectedNetworks: urlParams.networks,
+ selectedTypes: urlParams.types,
+ searchTerm: urlParams.searchTerm,
+ searchExpanded: urlParams.searchExpanded,
+ })
+
+ hasLoadedFromURL.current = true
+ }, [])
+
+ // Update URL when filters change (but not on initial mount)
+ useEffect(() => {
+ // Skip the first render and the initial load from URL
+ if (isInitialMount.current) {
+ isInitialMount.current = false
+ return
+ }
+
+ // Skip if we just loaded from URL
+ if (hasLoadedFromURL.current) {
+ hasLoadedFromURL.current = false
+ return
+ }
+
+ updateFilterURL(filters.selectedProducts, filters.selectedNetworks, filters.selectedTypes, filters.searchTerm)
+ }, [filters])
+
+ // Filter items and update the display
+ useEffect(() => {
+ if (typeof window === "undefined") return
+
+ const changelogItems = document.querySelectorAll(".changelog-item")
+ const loadMoreSection = document.querySelector(".load-more-section") as HTMLElement
+ const visibleCountSpan = document.getElementById("visible-count")
+ const emptyState = document.querySelector(".empty-state") as HTMLElement
+ const changelogList = document.querySelector(".changelog-list") as HTMLElement
+
+ if (filters.searchTerm) {
+ // Search takes priority - filter by search term
+ const searchLower = filters.searchTerm.toLowerCase()
+ let visibleCount = 0
+
+ changelogItems.forEach((item) => {
+ const index = parseInt(item.getAttribute("data-index") || "0")
+ const changelogItem = items[index]
+
+ const matchesSearch =
+ changelogItem?.name.toLowerCase().includes(searchLower) ||
+ changelogItem?.["text-description"]?.toLowerCase().includes(searchLower)
+
+ if (matchesSearch) {
+ ;(item as HTMLElement).style.display = ""
+ visibleCount++
+ } else {
+ ;(item as HTMLElement).style.display = "none"
+ }
+ })
+
+ // Hide load more section when searching
+ if (loadMoreSection) {
+ loadMoreSection.style.display = "none"
+ }
+
+ // Show/hide empty state
+ if (emptyState && changelogList) {
+ if (visibleCount === 0) {
+ emptyState.style.display = "flex"
+ changelogList.style.display = "none"
+ } else {
+ emptyState.style.display = "none"
+ changelogList.style.display = "flex"
+ }
+ }
+ } else {
+ // Apply filter logic
+ let visibleCount = 0
+ const hasFilters =
+ filters.selectedProducts.length > 0 || filters.selectedNetworks.length > 0 || filters.selectedTypes.length > 0
+
+ changelogItems.forEach((item) => {
+ const index = parseInt(item.getAttribute("data-index") || "0")
+ const changelogItem = items[index]
+
+ if (hasFilters && changelogItem) {
+ const matches = matchesFilters(
+ changelogItem,
+ filters.selectedProducts,
+ filters.selectedNetworks,
+ filters.selectedTypes
+ )
+ if (matches) {
+ ;(item as HTMLElement).style.display = ""
+ visibleCount++
+ } else {
+ ;(item as HTMLElement).style.display = "none"
+ }
+ } else {
+ // No filters - show first 25 items by default
+ if (visibleCount < 25) {
+ ;(item as HTMLElement).style.display = ""
+ visibleCount++
+ } else {
+ ;(item as HTMLElement).style.display = "none"
+ }
+ }
+ })
+
+ // Show/hide load more section based on filters
+ if (loadMoreSection) {
+ if (hasFilters) {
+ loadMoreSection.style.display = "none"
+ } else {
+ loadMoreSection.style.display = visibleCount >= items.length ? "none" : "flex"
+ }
+ }
+
+ // Update visible count
+ if (visibleCountSpan) {
+ visibleCountSpan.textContent = visibleCount.toString()
+ }
+
+ // Show/hide empty state
+ if (emptyState && changelogList) {
+ if (hasFilters && visibleCount === 0) {
+ emptyState.style.display = "flex"
+ changelogList.style.display = "none"
+ } else {
+ emptyState.style.display = "none"
+ changelogList.style.display = "flex"
+ }
+ }
+ }
+ }, [filters, items])
+
+ const handleSearchChange = (value: string) => {
+ setFilters((prev) => ({ ...prev, searchTerm: value }))
+ }
+
+ const handleSearchToggle = (expanded: boolean) => {
+ setFilters((prev) => ({ ...prev, searchExpanded: expanded }))
+ }
+
+ const toggleSelection = (type: "product" | "network" | "type", value: string) => {
+ setFilters((prev) => {
+ switch (type) {
+ case "product":
+ return { ...prev, selectedProducts: toggleItemInArray(prev.selectedProducts, value) }
+ case "network":
+ return { ...prev, selectedNetworks: toggleItemInArray(prev.selectedNetworks, value) }
+ case "type":
+ return { ...prev, selectedTypes: toggleItemInArray(prev.selectedTypes, value) }
+ default:
+ return prev
+ }
+ })
+ }
+
+ const clearProductFilters = () => {
+ setFilters((prev) => ({ ...prev, selectedProducts: [] }))
+ }
+
+ const clearNetworkFilters = () => {
+ setFilters((prev) => ({ ...prev, selectedNetworks: [] }))
+ }
+
+ const clearTypeFilters = () => {
+ setFilters((prev) => ({ ...prev, selectedTypes: [] }))
+ }
+
+ const clearAllFilters = () => {
+ setFilters((prev) => ({ ...prev, selectedProducts: [], selectedNetworks: [], selectedTypes: [] }))
+ }
+
+ return {
+ searchExpanded: filters.searchExpanded,
+ searchTerm: filters.searchTerm,
+ selectedProducts: filters.selectedProducts,
+ selectedNetworks: filters.selectedNetworks,
+ selectedTypes: filters.selectedTypes,
+ handleSearchChange,
+ handleSearchToggle,
+ toggleSelection,
+ clearProductFilters,
+ clearNetworkFilters,
+ clearTypeFilters,
+ clearAllFilters,
+ }
+}
diff --git a/src/components/ChangelogSnippet/ChangelogCard.astro b/src/components/ChangelogSnippet/ChangelogCard.astro
index 589bfb6e280..3ac9a16021b 100644
--- a/src/components/ChangelogSnippet/ChangelogCard.astro
+++ b/src/components/ChangelogSnippet/ChangelogCard.astro
@@ -160,19 +160,18 @@ const formattedDate = formatDate(item["date-of-release"])
const totalImages = images.length
const checkCardHeight = () => {
- // Determine max height based on viewport
- const isMobile = window.innerWidth <= 768
- const wrapperMaxHeight = isMobile ? 300 : 400
+ // Check if wrapper's content exceeds its visible height
+ const wrapperEl = wrapper as HTMLElement
+ const hasOverflow = card.scrollHeight > wrapperEl.clientHeight
- // Check if card exceeds wrapper height
- if (card.scrollHeight <= wrapperMaxHeight) {
- // Card fits, hide the button and fade
- footer.style.opacity = "0"
- footer.style.pointerEvents = "none"
- } else {
- // Card exceeds wrapper, show button and fade
+ if (hasOverflow) {
+ // Content exceeds wrapper, show button and fade
footer.style.opacity = "1"
footer.style.pointerEvents = "auto"
+ } else {
+ // Content fits within wrapper, hide the button and fade
+ footer.style.opacity = "0"
+ footer.style.pointerEvents = "none"
}
}
@@ -199,11 +198,11 @@ const formattedDate = formatDate(item["date-of-release"])
})
}
})
- } else {
- // No images, check immediately
- checkCardHeight()
}
+ // this is needed to make the fade/show more work.
+ setTimeout(checkCardHeight, 2000)
+
let isExpanded = false
button.addEventListener("click", () => {
@@ -230,12 +229,7 @@ const formattedDate = formatDate(item["date-of-release"])
})
}
- // Initialize on page load
- swapDataAttributeImages()
- initExpandableCards()
-
- // Re-initialize after navigation (for SPA-like behavior)
- document.addEventListener("astro:page-load", () => {
+ document.addEventListener("DOMContentLoaded", () => {
swapDataAttributeImages()
initExpandableCards()
})
diff --git a/src/components/ChangelogSnippet/ChangelogCard.module.css b/src/components/ChangelogSnippet/ChangelogCard.module.css
index 631d86ed1b3..5e9d3674fe8 100644
--- a/src/components/ChangelogSnippet/ChangelogCard.module.css
+++ b/src/components/ChangelogSnippet/ChangelogCard.module.css
@@ -16,6 +16,13 @@
& .card {
padding: var(--space-4x);
}
+
+ & ul {
+ padding-left: var(--space-6x);
+ }
+ & li {
+ list-style-type: disc;
+ }
}
/* Used on individual pages like CCIP/DataFeeds */
@@ -128,7 +135,7 @@
bottom: 0;
left: 0;
right: 0;
- z-index: 10;
+ z-index: 9;
height: calc(var(--space-6x) + 68px);
display: flex;
align-items: end;
@@ -203,9 +210,12 @@
}
@media screen and (max-width: 768px) {
+ .cardWrapper {
+ max-height: 440px;
+ }
.card {
- padding: 0 !important;
- gap: 0;
+ padding: var(--space-4x);
+ gap: var(--space-4x);
flex-direction: column;
}
diff --git a/src/pages/changelog.astro b/src/pages/changelog.astro
new file mode 100644
index 00000000000..dd94e3cc890
--- /dev/null
+++ b/src/pages/changelog.astro
@@ -0,0 +1,239 @@
+---
+import BaseLayout from "~/layouts/BaseLayout.astro"
+import * as CONFIG from "../config"
+import { Typography } from "@chainlink/blocks"
+import { ChangelogFilters } from "~/components/ChangelogFilters/ChangelogFilters.tsx"
+import { getSecret } from "astro:env/server"
+import { searchClient, SearchClient } from "@algolia/client-search"
+import { ChangelogItem } from "~/components/ChangelogSnippet/types"
+import ChangelogCard from "~/components/ChangelogSnippet/ChangelogCard.astro"
+import { getUniqueNetworks, getUniqueTopics, getUniqueTypes } from "~/utils/changelogFilters"
+const formattedContentTitle = `${CONFIG.PAGE.titleFallback} | ${CONFIG.SITE.title}`
+
+const appId = getSecret("ALGOLIA_APP_ID")
+const apiKey = getSecret("PUBLIC_ALGOLIA_SEARCH_PUBLIC_API_KEY")
+
+let client: SearchClient
+let logs: ChangelogItem[] | undefined = undefined
+
+if (appId && apiKey) {
+ client = searchClient(appId, apiKey)
+
+ const firstReq = await client.search({
+ requests: [
+ {
+ indexName: "Changelog",
+ page: 0,
+ hitsPerPage: 1000,
+ },
+ ],
+ })
+
+ const firstResult = firstReq.results[0]
+ let allHits = "hits" in firstResult ? (firstResult.hits as ChangelogItem[]) : []
+ const nbPages = "nbPages" in firstResult ? firstResult.nbPages : 1
+
+ if (nbPages && nbPages > 1) {
+ const remainingRequests = Array.from({ length: nbPages - 1 }, (_, i) => ({
+ indexName: "Changelog",
+ page: i + 1,
+ hitsPerPage: 1000,
+ }))
+
+ const remainingResults = await client.search({ requests: remainingRequests })
+
+ remainingResults.results.forEach((result) => {
+ if ("hits" in result) {
+ allHits = [...allHits, ...(result.hits as ChangelogItem[])]
+ }
+ })
+ }
+
+ logs = allHits
+}
+
+// Extract unique filter values
+const products = logs ? getUniqueTopics(logs) : []
+const networks = logs ? getUniqueNetworks(logs) : []
+const types = logs ? getUniqueTypes(logs) : []
+---
+
+
+
+
+
+
+ {
+ logs?.map((log, index) => (
+ = 25 ? "display: none;" : ""}>
+
+
+ ))
+ }
+
+
+
+ No updates found
+
+ We couldn't find anything matching your filters.
+
+
+
+
+ {
+ logs && logs.length > 25 && (
+
+
+
+ Showing 25 of {logs.length} updates
+
+
+ )
+ }
+
+
+
+
+
+
+
+
diff --git a/src/utils/changelogFilterUtils.ts b/src/utils/changelogFilterUtils.ts
new file mode 100644
index 00000000000..718e96c11ff
--- /dev/null
+++ b/src/utils/changelogFilterUtils.ts
@@ -0,0 +1,85 @@
+/**
+ * Utility functions for changelog filter management
+ */
+
+/**
+ * Parse URL parameters to extract filter state
+ */
+export function parseURLParams(): {
+ products: string[]
+ networks: string[]
+ types: string[]
+ searchTerm: string
+ searchExpanded: boolean
+} {
+ if (typeof window === "undefined") {
+ return {
+ products: [],
+ networks: [],
+ types: [],
+ searchTerm: "",
+ searchExpanded: false,
+ }
+ }
+
+ const params = new URLSearchParams(window.location.search)
+ const productParam = params.get("product")
+ const networkParam = params.get("network")
+ const typeParam = params.get("type")
+ const searchParam = params.get("*")
+
+ return {
+ products: productParam ? productParam.split(",") : [],
+ networks: networkParam ? networkParam.split(",") : [],
+ types: typeParam ? typeParam.split(",") : [],
+ searchTerm: searchParam || "",
+ searchExpanded: !!searchParam,
+ }
+}
+
+/**
+ * Build URL search parameters from filter state
+ */
+export function buildFilterURL(
+ products: string[],
+ networks: string[],
+ types: string[],
+ searchTerm: string
+): URLSearchParams {
+ const params = new URLSearchParams()
+
+ if (searchTerm) {
+ params.set("*", searchTerm)
+ } else {
+ if (products.length > 0) {
+ params.set("product", products.join(","))
+ }
+ if (networks.length > 0) {
+ params.set("network", networks.join(","))
+ }
+ if (types.length > 0) {
+ params.set("type", types.join(","))
+ }
+ }
+
+ return params
+}
+
+/**
+ * Update browser URL with filter parameters
+ */
+export function updateFilterURL(products: string[], networks: string[], types: string[], searchTerm: string): void {
+ if (typeof window === "undefined") return
+
+ const params = buildFilterURL(products, networks, types, searchTerm)
+ const newURL = params.toString() ? `?${params.toString()}` : window.location.pathname
+
+ window.history.replaceState({}, "", newURL)
+}
+
+/**
+ * Toggle an item in an array (add if not present, remove if present)
+ */
+export function toggleItemInArray(array: T[], item: T): T[] {
+ return array.includes(item) ? array.filter((i) => i !== item) : [...array, item]
+}
diff --git a/src/utils/changelogFilters.ts b/src/utils/changelogFilters.ts
new file mode 100644
index 00000000000..f9d2fc3290b
--- /dev/null
+++ b/src/utils/changelogFilters.ts
@@ -0,0 +1,106 @@
+import { ChangelogItem } from "~/components/ChangelogSnippet/types.ts"
+
+/**
+ * Extracts network names from the HTML networks field
+ * Networks are hidden in divs with fs-cmsfilter-field="network" class
+ */
+export function extractNetworkFromHtml(html: string): string[] {
+ const networks: string[] = []
+ const regex = /]*fs-cmsfilter-field="network"[^>]*class="hidden"[^>]*>(.*?)<\/div>/g
+ let match
+
+ while ((match = regex.exec(html)) !== null) {
+ const networkName = match[1].trim()
+ if (networkName) {
+ networks.push(networkName)
+ }
+ }
+
+ return networks
+}
+
+/**
+ * Extracts all unique networks from changelog items
+ */
+export function getUniqueNetworks(items: ChangelogItem[]): string[] {
+ const networksSet = new Set()
+
+ items.forEach((item) => {
+ if (item.networks) {
+ const networks = extractNetworkFromHtml(item.networks)
+ networks.forEach((network) => networksSet.add(network))
+ }
+ })
+
+ return Array.from(networksSet).sort()
+}
+
+/**
+ * Extracts all unique topics from changelog items
+ */
+export function getUniqueTopics(items: ChangelogItem[]): string[] {
+ const topicsSet = new Set()
+
+ items.forEach((item) => {
+ if (item.topic) {
+ topicsSet.add(item.topic)
+ }
+ })
+
+ return Array.from(topicsSet).sort()
+}
+
+/**
+ * Extracts all unique types from changelog items
+ */
+export function getUniqueTypes(items: ChangelogItem[]): string[] {
+ const typesSet = new Set()
+
+ items.forEach((item) => {
+ if (item.type) {
+ typesSet.add(item.type)
+ }
+ })
+
+ return Array.from(typesSet).sort()
+}
+
+/**
+ * Checks if a changelog item matches the selected filters
+ */
+export function matchesFilters(
+ item: ChangelogItem,
+ selectedProducts: string[],
+ selectedNetworks: string[],
+ selectedTypes: string[]
+): boolean {
+ // If no filters selected, show all items
+ const hasProductFilter = selectedProducts.length > 0
+ const hasNetworkFilter = selectedNetworks.length > 0
+ const hasTypeFilter = selectedTypes.length > 0
+
+ if (!hasProductFilter && !hasNetworkFilter && !hasTypeFilter) {
+ return true
+ }
+
+ // Check product filter (matches against item.topic field)
+ if (hasProductFilter && !selectedProducts.includes(item.topic)) {
+ return false
+ }
+
+ // Check type filter
+ if (hasTypeFilter && !selectedTypes.includes(item.type)) {
+ return false
+ }
+
+ // Check network filter
+ if (hasNetworkFilter) {
+ const itemNetworks = extractNetworkFromHtml(item.networks)
+ const hasMatchingNetwork = selectedNetworks.some((network) => itemNetworks.includes(network))
+ if (!hasMatchingNetwork) {
+ return false
+ }
+ }
+
+ return true
+}