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) : [] +--- + + +
+
+ Changelog + Never miss an update +
+ +
+ { + logs?.map((log, index) => ( +
= 25 ? "display: none;" : ""}> + +
+ )) + } +
+ + + + { + 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 +}