From 29fa03532657ba078ccd8dd367a69d94d7a8234a Mon Sep 17 00:00:00 2001 From: alexander-sei Date: Wed, 5 Nov 2025 04:09:09 +0100 Subject: [PATCH 1/3] add tabs to ecosystem --- .../EcosystemContracts/TabsView.tsx | 227 ++++++++++++++++++ src/components/EcosystemContracts/index.tsx | 106 +------- src/components/Tabs/index.tsx | 6 +- 3 files changed, 233 insertions(+), 106 deletions(-) create mode 100644 src/components/EcosystemContracts/TabsView.tsx diff --git a/src/components/EcosystemContracts/TabsView.tsx b/src/components/EcosystemContracts/TabsView.tsx new file mode 100644 index 00000000..daafce05 --- /dev/null +++ b/src/components/EcosystemContracts/TabsView.tsx @@ -0,0 +1,227 @@ +'use client'; + +import * as React from 'react'; +import { Tabs, TabsList, TabsTrigger, TabsContent } from '../Tabs'; + +type GroupedContracts = { + projectName: string; + contracts: Record[]; + contractCount: number; +}; + +interface EcosystemContractsTabsProps { + groupedData: GroupedContracts[]; + nameKey: string; + addressKey: string; +} + +export function EcosystemContractsTabs({ groupedData, nameKey, addressKey }: EcosystemContractsTabsProps) { + const defaultTab = groupedData[0]?.projectName || 'default'; + const [selectedTab, setSelectedTab] = React.useState(defaultTab); + const [query, setQuery] = React.useState(''); + + const toDomSafe = (s: string) => + s + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); + const getContractDomId = (groupName: string, addressLower: string) => `contract-${toDomSafe(groupName)}-${addressLower}`; + + const scrollToContract = (groupName: string, addressLower: string) => { + const rawId = getContractDomId(groupName, addressLower); + const safeId = typeof CSS !== 'undefined' && (CSS as any).escape ? (CSS as any).escape(rawId) : rawId.replace(/[^a-zA-Z0-9_-]/g, '_'); + let attempts = 0; + const maxAttempts = 24; + + const tryScroll = () => { + attempts++; + const activePanel = document.querySelector("[role='tabpanel'][data-state='active']") as HTMLElement | null; + if (!activePanel) { + if (attempts < maxAttempts) requestAnimationFrame(tryScroll); + return; + } + + const isVisible = (el: HTMLElement) => el.getClientRects().length > 0 && window.getComputedStyle(el).visibility !== 'hidden'; + const panel = activePanel as HTMLElement; // narrow for callbacks below + + const inPanel = Array.from(panel.querySelectorAll(`#${safeId}`)) as HTMLElement[]; + let target: HTMLElement | null = inPanel.filter((el) => !el.closest('[data-search-content]') && isVisible(el))[0] || null; + if (!target) { + // Fallback: search globally, prefer visible element inside active panel + const allMatches = Array.from(document.querySelectorAll(`#${safeId}`)) as HTMLElement[]; + target = + allMatches.find((el) => panel.contains(el) && !el.closest('[data-search-content]') && isVisible(el)) || + allMatches.find((el) => !el.closest('[data-search-content]') && isVisible(el)) || + null; + } + + if (target) { + let offset = 0; + const stickyTabs = document.querySelector('[data-sticky-tabs="true"]') as HTMLElement | null; + if (stickyTabs) offset += stickyTabs.getBoundingClientRect().height + 12; + const header = (document.querySelector('header') || document.querySelector('nav')) as HTMLElement | null; + if (header) { + const style = window.getComputedStyle(header); + if (style.position === 'fixed' || style.position === 'sticky') { + offset += header.getBoundingClientRect().height; + } + } + + const rect = target.getBoundingClientRect(); + const top = window.scrollY + rect.top - Math.max(offset, 80); + window.scrollTo({ top, behavior: 'smooth' }); + return; + } + + if (attempts < maxAttempts) requestAnimationFrame(tryScroll); + }; + + requestAnimationFrame(tryScroll); + }; + + const handleSearchSubmit = (e: React.FormEvent) => { + e.preventDefault(); + const q = query.trim().toLowerCase(); + if (!q) return; + + for (const group of groupedData) { + for (const contract of group.contracts) { + const name = String(contract[nameKey] || '').toLowerCase(); + const address = String(contract[addressKey] || '').toLowerCase(); + if (name.includes(q) || address.includes(q)) { + setSelectedTab(group.projectName); + // Scroll to the visible contract row (exclude hidden indexing duplicates) + scrollToContract(group.projectName, address); + return; + } + } + } + }; + + return ( +
+
+ setQuery(e.target.value)} + placeholder='Search by contract name or address' + aria-label='Search by contract name or address' + autoComplete='off' + className='w-full px-3 py-2 rounded border border-neutral-300 dark:border-neutral-700 bg-neutral-50 dark:!bg-neutral-900 text-sm text-neutral-900 dark:text-neutral-100 placeholder:text-neutral-400 dark:placeholder:text-neutral-500 focus:outline-none focus:ring-2 focus:ring-red-500/25 dark:focus:ring-red-500/20 focus:border-red-500 dark:focus:border-red-500 transition-colors' + /> + +
+ + + + {groupedData.map((group) => ( + + {group.projectName} + ({group.contractCount}) + + ))} + + + {groupedData.map((group, groupIndex) => ( + +
+
+
+

{group.projectName}

+ + {group.contractCount} contract{group.contractCount !== 1 ? 's' : ''} + +
+
+ + {/* Desktop table view */} +
+ + + + + + + + + {group.contracts.map((contract, contractIndex) => { + const addr = String(contract[addressKey] || '').toLowerCase(); + const id = `contract-${group.projectName + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '')}-${addr}`; + return ( + + + + + ); + })} + +
+ Contract Name + + Contract Address +
+ {contract[nameKey] || 'Unnamed Contract'} + +
+ {contract[addressKey]} + + SeiScan ↗ + +
+
+
+ + {/* Mobile card view */} +
+ {group.contracts.map((contract, contractIndex) => { + const addr = String(contract[addressKey] || '').toLowerCase(); + const id = `contract-${group.projectName + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '')}-${addr}`; + return ( +
+
+
+ Contract Name + {contract[nameKey] || 'Unnamed Contract'} +
+
+ Contract Address +
+ {contract[addressKey]} + + SeiScan ↗ + +
+
+
+
+ ); + })} +
+
+
+ ))} +
+
+ ); +} diff --git a/src/components/EcosystemContracts/index.tsx b/src/components/EcosystemContracts/index.tsx index fcde3018..0344690a 100644 --- a/src/components/EcosystemContracts/index.tsx +++ b/src/components/EcosystemContracts/index.tsx @@ -1,4 +1,5 @@ import { Callout } from 'nextra/components'; +import { EcosystemContractsTabs } from './TabsView'; // CSV file component - reads a local CSV file at build time // Just download your Google Sheet as CSV and place it in your project @@ -81,35 +82,6 @@ export async function RemoteSheetData() { // ---------------------------- helpers ---------------------------- - const ContractLink = ({ address, name }: { address: string; name: string }) => { - if (!address || !address.trim()) { - return No contract address; - } - - // Clean the address - const cleanAddress = address.trim(); - - let explorerName = 'SeiScan'; - - let explorerUrl = `https://seiscan.io/address/${cleanAddress}`; - const shortAddress = `${cleanAddress}`; - - return ( -
- {shortAddress} - {explorerUrl !== '#' && ( - - {explorerName} ↗ - - )} -
- ); - }; - const groupDataByProject = (data: any[]) => { if (!data || data.length === 0) return []; @@ -141,81 +113,7 @@ export async function RemoteSheetData() { const groupedData = groupDataByProject(data); const headers = Object.keys(data[0]); - const totalContracts = data.length; - const totalProjects = groupedData.length; - - return ( -
- {/* Summary stats */} - - {/* Project groups */} - {groupedData.map((group, groupIndex) => ( -
- {/* Project header */} -
-
-

{group.projectName}

- - {group.contractCount} contract{group.contractCount !== 1 ? 's' : ''} - -
-
#{groupIndex + 1}
-
- - {/* Contracts table for this project */} -
- - - - - - - - - - {group.contracts.map((contract, contractIndex) => ( - - - - - - ))} - -
# - Contract Name - - Contract Address -
{contractIndex + 1} - {contract[headers[2]] || 'Unnamed Contract'} - - -
-
- - {/* Mobile card view for this project */} -
- {group.contracts.map((contract, contractIndex) => ( -
-
- Contract #{contractIndex + 1} -
-
-
- Contract Name - {contract[headers[2]] || 'Unnamed Contract'} -
-
- Contract Address - -
-
-
- ))} -
-
- ))} -
- ); + return ; }; // ---------------------------- rendering ---------------------------- diff --git a/src/components/Tabs/index.tsx b/src/components/Tabs/index.tsx index 6ec254d1..a37db4a0 100644 --- a/src/components/Tabs/index.tsx +++ b/src/components/Tabs/index.tsx @@ -5,6 +5,8 @@ import * as TabsPrimitive from '@radix-ui/react-tabs'; interface TabsProps { defaultValue: string; + value?: string; + onValueChange?: (value: string) => void; className?: string; children: React.ReactNode; } @@ -26,8 +28,8 @@ interface TabsContentProps { children: React.ReactNode; } -export const Tabs = React.forwardRef(({ defaultValue, className, children }, ref) => ( - +export const Tabs = React.forwardRef(({ defaultValue, value, onValueChange, className, children }, ref) => ( + {children} )); From b0d54f1f8f61ee4232544066e3c9b59812a0b23f Mon Sep 17 00:00:00 2001 From: alexander-sei Date: Wed, 5 Nov 2025 14:58:35 +0100 Subject: [PATCH 2/3] change tab behavior --- .../EcosystemContracts/TabsView.tsx | 262 ++++++++---------- src/components/EcosystemContracts/index.tsx | 2 +- 2 files changed, 110 insertions(+), 154 deletions(-) diff --git a/src/components/EcosystemContracts/TabsView.tsx b/src/components/EcosystemContracts/TabsView.tsx index daafce05..e4f0e6d4 100644 --- a/src/components/EcosystemContracts/TabsView.tsx +++ b/src/components/EcosystemContracts/TabsView.tsx @@ -1,7 +1,6 @@ 'use client'; import * as React from 'react'; -import { Tabs, TabsList, TabsTrigger, TabsContent } from '../Tabs'; type GroupedContracts = { projectName: string; @@ -16,67 +15,18 @@ interface EcosystemContractsTabsProps { } export function EcosystemContractsTabs({ groupedData, nameKey, addressKey }: EcosystemContractsTabsProps) { - const defaultTab = groupedData[0]?.projectName || 'default'; - const [selectedTab, setSelectedTab] = React.useState(defaultTab); const [query, setQuery] = React.useState(''); + const [openSections, setOpenSections] = React.useState>({}); - const toDomSafe = (s: string) => - s + const slugify = (value: string): string => { + return String(value) .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, ''); - const getContractDomId = (groupName: string, addressLower: string) => `contract-${toDomSafe(groupName)}-${addressLower}`; - - const scrollToContract = (groupName: string, addressLower: string) => { - const rawId = getContractDomId(groupName, addressLower); - const safeId = typeof CSS !== 'undefined' && (CSS as any).escape ? (CSS as any).escape(rawId) : rawId.replace(/[^a-zA-Z0-9_-]/g, '_'); - let attempts = 0; - const maxAttempts = 24; - - const tryScroll = () => { - attempts++; - const activePanel = document.querySelector("[role='tabpanel'][data-state='active']") as HTMLElement | null; - if (!activePanel) { - if (attempts < maxAttempts) requestAnimationFrame(tryScroll); - return; - } - - const isVisible = (el: HTMLElement) => el.getClientRects().length > 0 && window.getComputedStyle(el).visibility !== 'hidden'; - const panel = activePanel as HTMLElement; // narrow for callbacks below - - const inPanel = Array.from(panel.querySelectorAll(`#${safeId}`)) as HTMLElement[]; - let target: HTMLElement | null = inPanel.filter((el) => !el.closest('[data-search-content]') && isVisible(el))[0] || null; - if (!target) { - // Fallback: search globally, prefer visible element inside active panel - const allMatches = Array.from(document.querySelectorAll(`#${safeId}`)) as HTMLElement[]; - target = - allMatches.find((el) => panel.contains(el) && !el.closest('[data-search-content]') && isVisible(el)) || - allMatches.find((el) => !el.closest('[data-search-content]') && isVisible(el)) || - null; - } - - if (target) { - let offset = 0; - const stickyTabs = document.querySelector('[data-sticky-tabs="true"]') as HTMLElement | null; - if (stickyTabs) offset += stickyTabs.getBoundingClientRect().height + 12; - const header = (document.querySelector('header') || document.querySelector('nav')) as HTMLElement | null; - if (header) { - const style = window.getComputedStyle(header); - if (style.position === 'fixed' || style.position === 'sticky') { - offset += header.getBoundingClientRect().height; - } - } - - const rect = target.getBoundingClientRect(); - const top = window.scrollY + rect.top - Math.max(offset, 80); - window.scrollTo({ top, behavior: 'smooth' }); - return; - } - - if (attempts < maxAttempts) requestAnimationFrame(tryScroll); - }; + }; - requestAnimationFrame(tryScroll); + const toggleSection = (projectName: string) => { + setOpenSections((prev) => ({ ...prev, [projectName]: !prev[projectName] })); }; const handleSearchSubmit = (e: React.FormEvent) => { @@ -89,9 +39,14 @@ export function EcosystemContractsTabs({ groupedData, nameKey, addressKey }: Eco const name = String(contract[nameKey] || '').toLowerCase(); const address = String(contract[addressKey] || '').toLowerCase(); if (name.includes(q) || address.includes(q)) { - setSelectedTab(group.projectName); - // Scroll to the visible contract row (exclude hidden indexing duplicates) - scrollToContract(group.projectName, address); + // open the matched section + setOpenSections((prev) => ({ ...prev, [group.projectName]: true })); + // attempt to scroll to the matched contract row/card + const id = `contract-${slugify(group.projectName)}-${address}`; + requestAnimationFrame(() => { + const el = document.getElementById(id); + if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' }); + }); return; } } @@ -117,111 +72,112 @@ export function EcosystemContractsTabs({ groupedData, nameKey, addressKey }: Eco - - - {groupedData.map((group) => ( - - {group.projectName} - ({group.contractCount}) - - ))} - - - {groupedData.map((group, groupIndex) => ( - -
-
-
-

{group.projectName}

+
+ {groupedData.map((group) => { + const isOpen = !!openSections[group.projectName]; + const sectionId = `section-${slugify(group.projectName)}`; + return ( +
+
-
- - {/* Desktop table view */} -
- - - - - - - - + + + +
+
+ {/* Desktop table view */} +
+
- Contract Name - - Contract Address -
+ + + + + + + + {group.contracts.map((contract, contractIndex) => { + const addr = String(contract[addressKey] || '').toLowerCase(); + const id = `contract-${slugify(group.projectName)}-${addr}`; + return ( + + + + + ); + })} + +
+ Contract Name + + Contract Address +
+ {contract[nameKey] || 'Unnamed Contract'} + +
+ {contract[addressKey]} + + SeiScan ↗ + +
+
+
+ + {/* Mobile card view */} +
{group.contracts.map((contract, contractIndex) => { const addr = String(contract[addressKey] || '').toLowerCase(); - const id = `contract-${group.projectName - .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-+|-+$/g, '')}-${addr}`; + const id = `contract-${slugify(group.projectName)}-${addr}`; return ( - - - {contract[nameKey] || 'Unnamed Contract'} - - -
- {contract[addressKey]} - - SeiScan ↗ - +
+
+
+ Contract Name + {contract[nameKey] || 'Unnamed Contract'} +
+
+ Contract Address +
+ {contract[addressKey]} + + SeiScan ↗ + +
- - - ); - })} - - -
- - {/* Mobile card view */} -
- {group.contracts.map((contract, contractIndex) => { - const addr = String(contract[addressKey] || '').toLowerCase(); - const id = `contract-${group.projectName - .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-+|-+$/g, '')}-${addr}`; - return ( -
-
-
- Contract Name - {contract[nameKey] || 'Unnamed Contract'} -
-
- Contract Address -
- {contract[addressKey]} - - SeiScan ↗ -
-
-
- ); - })} + ); + })} +
+
- - ))} - + ); + })} +
); } + +export default EcosystemContractsTabs; diff --git a/src/components/EcosystemContracts/index.tsx b/src/components/EcosystemContracts/index.tsx index 0344690a..02f339c7 100644 --- a/src/components/EcosystemContracts/index.tsx +++ b/src/components/EcosystemContracts/index.tsx @@ -1,5 +1,5 @@ import { Callout } from 'nextra/components'; -import { EcosystemContractsTabs } from './TabsView'; +import EcosystemContractsTabs from './TabsView'; // CSV file component - reads a local CSV file at build time // Just download your Google Sheet as CSV and place it in your project From b23f3415939c2c28d23895848619fa18621d051b Mon Sep 17 00:00:00 2001 From: alexander-sei Date: Wed, 5 Nov 2025 17:12:56 +0100 Subject: [PATCH 3/3] Fix auto open on search --- src/components/EcosystemContracts/TabsView.tsx | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/components/EcosystemContracts/TabsView.tsx b/src/components/EcosystemContracts/TabsView.tsx index e4f0e6d4..d8f7b63e 100644 --- a/src/components/EcosystemContracts/TabsView.tsx +++ b/src/components/EcosystemContracts/TabsView.tsx @@ -39,13 +39,23 @@ export function EcosystemContractsTabs({ groupedData, nameKey, addressKey }: Eco const name = String(contract[nameKey] || '').toLowerCase(); const address = String(contract[addressKey] || '').toLowerCase(); if (name.includes(q) || address.includes(q)) { - // open the matched section setOpenSections((prev) => ({ ...prev, [group.projectName]: true })); - // attempt to scroll to the matched contract row/card const id = `contract-${slugify(group.projectName)}-${address}`; requestAnimationFrame(() => { const el = document.getElementById(id); - if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' }); + if (!el) return; + + // Center the element in the viewport for better visibility (works for both and card
) + const rect = el.getBoundingClientRect(); + const currentScrollY = window.scrollY || window.pageYOffset; + const absoluteTop = rect.top + currentScrollY; + const viewportHeight = window.innerHeight; + const targetTop = absoluteTop - Math.max(0, (viewportHeight - rect.height) / 2); + + const maxScroll = Math.max(0, (document.documentElement.scrollHeight || document.body.scrollHeight) - viewportHeight); + const clampedTop = Math.min(Math.max(0, targetTop), maxScroll); + + window.scrollTo({ top: clampedTop, behavior: 'smooth' }); }); return; }