diff --git a/apps/web/next.config.js b/apps/web/next.config.js index 4c6b81724..38e9445bd 100644 --- a/apps/web/next.config.js +++ b/apps/web/next.config.js @@ -1,6 +1,7 @@ const { createVanillaExtractPlugin } = require('@vanilla-extract/next-plugin') const { withSentryConfig } = require('@sentry/nextjs') const createBundleAnalyzerPlugin = require('@next/bundle-analyzer') +const path = require('path') const webpack = require('webpack') const { @@ -136,6 +137,14 @@ const basicConfig = { }) config.resolve.fallback = { fs: false, net: false, tls: false } + config.resolve.alias = { + ...(config.resolve.alias || {}), + // MetaMask's SDK references React Native AsyncStorage even in web bundles. + '@react-native-async-storage/async-storage': path.resolve( + __dirname, + 'src/shims/reactNativeAsyncStorage.js' + ), + } config.externals = config.externals || [] config.externals.push('pino-pretty') diff --git a/apps/web/package.json b/apps/web/package.json index c5d324bcf..b2b999c8f 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -91,8 +91,8 @@ "scripts": { "analyze": "ANALYZE=true next build", "build": "next build", - "clean": "rm -rf .next .turbo", - "dev": "pnpm clean && NODE_OPTIONS='--max-old-space-size=4096' next dev", + "clean": "node -e \"require('fs').rmSync('.next', { recursive: true, force: true }); require('fs').rmSync('.turbo', { recursive: true, force: true });\"", + "dev": "pnpm exec next dev", "lint": "pnpm type-check && eslint --fix .", "start": "next start", "test": "vitest run", diff --git a/apps/web/src/components/HiddenDaoDisclosure.css.ts b/apps/web/src/components/HiddenDaoDisclosure.css.ts new file mode 100644 index 000000000..63d630542 --- /dev/null +++ b/apps/web/src/components/HiddenDaoDisclosure.css.ts @@ -0,0 +1,44 @@ +import { color } from '@buildeross/zord' +import { style } from '@vanilla-extract/css' + +export const hiddenDaoDisclosure = style({ + width: '100%', +}) + +export const hiddenDaoDisclosureTrigger = style({ + width: '100%', + minHeight: '28px', + display: 'flex', + alignItems: 'center', + gap: '8px', + padding: '4px 6px', + border: '0', + borderRadius: '10px', + background: 'transparent', + color: 'inherit', + textAlign: 'left', + transition: 'background-color 0.12s ease', + selectors: { + '&:hover': { + backgroundColor: color.background2, + }, + }, +}) + +export const hiddenDaoDisclosureChevron = style({ + flexShrink: 0, + transition: 'transform 0.12s ease', +}) + +export const hiddenDaoDisclosureChevronOpen = style({ + transform: 'rotate(0deg)', +}) + +export const hiddenDaoDisclosureChevronClosed = style({ + transform: 'rotate(-90deg)', +}) + +export const hiddenDaoDisclosureContent = style({ + width: '100%', + marginTop: '8px', +}) diff --git a/apps/web/src/components/HiddenDaoDisclosure.tsx b/apps/web/src/components/HiddenDaoDisclosure.tsx new file mode 100644 index 000000000..2f0071a45 --- /dev/null +++ b/apps/web/src/components/HiddenDaoDisclosure.tsx @@ -0,0 +1,50 @@ +import { Box, Flex, Icon, Text } from '@buildeross/zord' +import React from 'react' + +import { + hiddenDaoDisclosure, + hiddenDaoDisclosureChevron, + hiddenDaoDisclosureChevronClosed, + hiddenDaoDisclosureChevronOpen, + hiddenDaoDisclosureContent, + hiddenDaoDisclosureTrigger, +} from './HiddenDaoDisclosure.css' + +type HiddenDaoDisclosureProps = { + children: React.ReactNode + count: number + isOpen: boolean + onToggle: () => void +} + +export const HiddenDaoDisclosure: React.FC = ({ + children, + count, + isOpen, + onToggle, +}) => { + return ( + + + + + + {`Hidden DAOs (${count})`} + + {isOpen ? {children} : null} + + ) +} diff --git a/apps/web/src/components/ProfileDaoList.tsx b/apps/web/src/components/ProfileDaoList.tsx new file mode 100644 index 000000000..4b3ffd907 --- /dev/null +++ b/apps/web/src/components/ProfileDaoList.tsx @@ -0,0 +1,753 @@ +import { PUBLIC_DEFAULT_CHAINS } from '@buildeross/constants/chains' +import { DaoAvatar } from '@buildeross/ui/Avatar' +import { Box, Button, Flex, Icon, Text } from '@buildeross/zord' +import NextImage from 'next/image' +import Link from 'next/link' +import React from 'react' +import { createPortal } from 'react-dom' +import { HiddenDaoDisclosure } from 'src/components/HiddenDaoDisclosure' +import { + daoEditorButtonGroup, + daoEditorDoneButton, + daoEditorDragging, + daoEditorDragHandle, + daoEditorIconButton, + daoEditorRow, + daoEditorSpacer, + daoEditorSpacerActive, + profileDaoLink, + profileHiddenDaoLink, +} from 'src/styles/profile.css' +import { + getDaoListPreferenceItemKey, + useDaoListPreferences, +} from 'src/utils/useDaoListPreferences' + +type ProfileDaoListItem = { + auctionAddress: string + chainId: number + collectionAddress: string + name: string +} + +type DragOverlayState = { + daoKey: string + width: number + x: number + y: number +} + +type DragMeta = { + daoKey: string + pointerOffsetY: number + pointerId: number +} + +type RowMetric = { + daoKey: string + midpoint: number +} + +type ProfileDaoListProps = { + daos: ProfileDaoListItem[] + isOwnProfile: boolean + userAddress: string +} + +type ProfileDaoListRowProps = { + chainIcon?: string + dao: ProfileDaoListItem + daoKey: string + isDragging: boolean + isEditing: boolean + isHidden: boolean + isInsertGapActive: boolean + onPointerDown: (event: React.PointerEvent, daoKey: string) => void + onToggleHidden: (dao: ProfileDaoListItem, isHidden: boolean) => void + setRowRef?: (daoKey: string, node: HTMLDivElement | null) => void +} + +const ProfileDaoListRow = React.memo( + ({ + chainIcon, + dao, + daoKey, + isDragging, + isEditing, + isHidden, + isInsertGapActive, + onPointerDown, + onToggleHidden, + setRowRef, + }: ProfileDaoListRowProps) => { + const handleToggleHidden = React.useCallback(() => { + onToggleHidden(dao, isHidden) + }, [dao, isHidden, onToggleHidden]) + + const handlePointerDown = React.useCallback( + (event: React.PointerEvent) => { + onPointerDown(event, daoKey) + }, + [daoKey, onPointerDown] + ) + + const handleRowRef = React.useCallback( + (node: HTMLDivElement | null) => { + setRowRef?.(daoKey, node) + }, + [daoKey, setRowRef] + ) + + return ( + + {isEditing ? ( + + ) : null} + + + + + {dao.name} + {chainIcon ? ( + + ) : null} + + + {isEditing ? ( + + + + + ) : null} + + + ) + } +) + +ProfileDaoListRow.displayName = 'ProfileDaoListRow' + +const getScrollableAncestor = (node: HTMLElement): HTMLElement | Window => { + let currentNode = node.parentElement + + while (currentNode) { + const { overflowY } = window.getComputedStyle(currentNode) + const canScroll = + (overflowY === 'auto' || overflowY === 'scroll') && + currentNode.scrollHeight > currentNode.clientHeight + + if (canScroll) { + return currentNode + } + + currentNode = currentNode.parentElement + } + + return window +} + +export const ProfileDaoList: React.FC = ({ + daos, + isOwnProfile, + userAddress, +}) => { + const [isEditingDaos, setIsEditingDaos] = React.useState(false) + const [isHiddenDaosOpen, setIsHiddenDaosOpen] = React.useState(false) + const [activeDragKey, setActiveDragKey] = React.useState(null) + const [dragInsertIndex, setDragInsertIndex] = React.useState(null) + const [dragOverlay, setDragOverlay] = React.useState(null) + + const { groupHiddenDaosLast, isDaoHidden, persistOrderedDaos, setDaoHidden, sortDaos } = + useDaoListPreferences(userAddress) + + const overlayRef = React.useRef(null) + const listRef = React.useRef(null) + const rowRefs = React.useRef>({}) + const dragMetaRef = React.useRef(null) + const latestPointerYRef = React.useRef(0) + const dragInsertIndexRef = React.useRef(null) + const rafRef = React.useRef(null) + const autoScrollRafRef = React.useRef(null) + const rowMetricsRef = React.useRef([]) + const scrollContainerRef = React.useRef(null) + const chainIconsById = React.useMemo( + () => new Map(PUBLIC_DEFAULT_CHAINS.map((chain) => [chain.id, chain.icon])), + [] + ) + const chainSlugsById = React.useMemo( + () => new Map(PUBLIC_DEFAULT_CHAINS.map((chain) => [chain.id, chain.slug])), + [] + ) + + const orderedDaos = React.useMemo(() => { + const sortedOrderedDaos = sortDaos( + daos, + (dao) => dao.collectionAddress, + (dao) => dao.chainId + ) + + return groupHiddenDaosLast( + sortedOrderedDaos, + (dao) => dao.collectionAddress, + (dao) => dao.chainId + ) + }, [daos, groupHiddenDaosLast, sortDaos]) + + const hiddenDaosCount = React.useMemo( + () => + orderedDaos.filter((dao) => isDaoHidden(dao.chainId, dao.collectionAddress)).length, + [isDaoHidden, orderedDaos] + ) + + const visibleDaos = React.useMemo( + () => orderedDaos.filter((dao) => !isDaoHidden(dao.chainId, dao.collectionAddress)), + [isDaoHidden, orderedDaos] + ) + + const hiddenDaos = React.useMemo( + () => orderedDaos.filter((dao) => isDaoHidden(dao.chainId, dao.collectionAddress)), + [isDaoHidden, orderedDaos] + ) + + const daosForDisplay = isEditingDaos ? orderedDaos : visibleDaos + + const getDaoKey = React.useCallback( + (chainId: number, collectionAddress: string) => + getDaoListPreferenceItemKey(chainId, collectionAddress), + [] + ) + + const getInsertIndexForPointer = React.useCallback( + (pointerY: number, draggingKey: string) => { + for (let index = 0; index < rowMetricsRef.current.length; index++) { + const rowMetric = rowMetricsRef.current[index] + if (rowMetric.daoKey === draggingKey) continue + + if (pointerY < rowMetric.midpoint) { + return index + } + } + + return rowMetricsRef.current.length + }, + [] + ) + + const moveDaoToIndex = React.useCallback( + (fromIndex: number, toIndex: number) => { + if ( + fromIndex < 0 || + toIndex < 0 || + fromIndex >= daos.length || + toIndex > daos.length || + fromIndex === toIndex + ) { + return + } + + persistOrderedDaos((currentOrderedDaoKeys) => { + const sortedCurrentDaos = sortDaos( + daos, + (dao) => dao.collectionAddress, + (dao) => dao.chainId + ) + const currentOrderedDaos = groupHiddenDaosLast( + sortedCurrentDaos, + (dao) => dao.collectionAddress, + (dao) => dao.chainId + ) + + if ( + fromIndex < 0 || + toIndex < 0 || + fromIndex >= currentOrderedDaos.length || + toIndex > currentOrderedDaos.length || + fromIndex === toIndex + ) { + return currentOrderedDaoKeys + } + + const nextDaos = [...currentOrderedDaos] + const [movedDao] = nextDaos.splice(fromIndex, 1) + nextDaos.splice(toIndex, 0, movedDao) + + return nextDaos.map((dao) => getDaoKey(dao.chainId, dao.collectionAddress)) + }) + }, + [daos, getDaoKey, groupHiddenDaosLast, persistOrderedDaos, sortDaos] + ) + + React.useEffect(() => { + dragInsertIndexRef.current = dragInsertIndex + }, [dragInsertIndex]) + + React.useEffect(() => { + if (isOwnProfile) return + setIsEditingDaos(false) + }, [isOwnProfile]) + + React.useEffect(() => { + if (isEditingDaos) return + setActiveDragKey(null) + setDragInsertIndex(null) + setDragOverlay(null) + dragMetaRef.current = null + rowMetricsRef.current = [] + scrollContainerRef.current = null + }, [isEditingDaos]) + + React.useEffect(() => { + if (!dragOverlay || !dragMetaRef.current) { + document.body.style.userSelect = '' + document.body.style.touchAction = '' + document.documentElement.style.overscrollBehavior = '' + return + } + + document.body.style.userSelect = 'none' + document.body.style.touchAction = 'none' + document.documentElement.style.overscrollBehavior = 'none' + + const computeRowMetrics = () => { + rowMetricsRef.current = orderedDaos + .map((dao) => { + const daoKey = getDaoKey(dao.chainId, dao.collectionAddress) + const row = rowRefs.current[daoKey] + if (!row) return null + + const rect = row.getBoundingClientRect() + return { + daoKey, + midpoint: rect.top + rect.height / 2, + } + }) + .filter((metric): metric is RowMetric => metric !== null) + } + + computeRowMetrics() + + const flushDragFrame = () => { + rafRef.current = null + + const dragMeta = dragMetaRef.current + const overlay = overlayRef.current + if (!dragMeta || !overlay) return + + const nextTop = latestPointerYRef.current - dragMeta.pointerOffsetY + overlay.style.transform = `translate3d(${dragOverlay.x}px, ${nextTop}px, 0)` + + const nextInsertIndex = getInsertIndexForPointer( + latestPointerYRef.current, + dragMeta.daoKey + ) + setDragInsertIndex((current) => + current === nextInsertIndex ? current : nextInsertIndex + ) + } + + const runAutoScrollFrame = () => { + autoScrollRafRef.current = null + + const scrollContainer = scrollContainerRef.current + if (!scrollContainer) return + + const threshold = 56 + const maxSpeed = 14 + let delta = 0 + const listRect = listRef.current?.getBoundingClientRect() + + if (scrollContainer === window) { + const boundsTop = listRect ? Math.max(listRect.top, 0) : 0 + const boundsBottom = listRect + ? Math.min(listRect.bottom, window.innerHeight) + : window.innerHeight + const topDistance = latestPointerYRef.current - boundsTop + const bottomDistance = boundsBottom - latestPointerYRef.current + + if (topDistance < threshold) { + delta = -Math.min(maxSpeed, ((threshold - topDistance) / threshold) * maxSpeed) + } else if (bottomDistance < threshold) { + delta = Math.min( + maxSpeed, + ((threshold - bottomDistance) / threshold) * maxSpeed + ) + } + + if (delta !== 0) { + window.scrollBy(0, delta) + } + } else { + const element = scrollContainer as HTMLElement + const rect = element.getBoundingClientRect() + const boundsTop = listRect ? Math.max(rect.top, listRect.top) : rect.top + const boundsBottom = listRect ? Math.min(rect.bottom, listRect.bottom) : rect.bottom + const topDistance = latestPointerYRef.current - boundsTop + const bottomDistance = boundsBottom - latestPointerYRef.current + + if (topDistance < threshold) { + delta = -Math.min(maxSpeed, ((threshold - topDistance) / threshold) * maxSpeed) + } else if (bottomDistance < threshold) { + delta = Math.min( + maxSpeed, + ((threshold - bottomDistance) / threshold) * maxSpeed + ) + } + + if (delta !== 0) { + element.scrollTop += delta + } + } + + if (delta !== 0) { + computeRowMetrics() + + if (rafRef.current === null) { + rafRef.current = window.requestAnimationFrame(flushDragFrame) + } + + autoScrollRafRef.current = window.requestAnimationFrame(runAutoScrollFrame) + } + } + + const handlePointerMove = (event: PointerEvent) => { + if (event.pointerId !== dragMetaRef.current?.pointerId) return + latestPointerYRef.current = event.clientY + + if (rafRef.current === null) { + rafRef.current = window.requestAnimationFrame(flushDragFrame) + } + + if (autoScrollRafRef.current === null) { + autoScrollRafRef.current = window.requestAnimationFrame(runAutoScrollFrame) + } + } + + const handleViewportChange = () => { + computeRowMetrics() + } + + const finishDrag = () => { + const dragMeta = dragMetaRef.current + if (!dragMeta) return + + const fromIndex = orderedDaos.findIndex( + (dao) => getDaoKey(dao.chainId, dao.collectionAddress) === dragMeta.daoKey + ) + const insertIndex = dragInsertIndexRef.current + + if (insertIndex !== null) { + const adjustedInsertIndex = + insertIndex > fromIndex ? insertIndex - 1 : insertIndex + moveDaoToIndex(fromIndex, adjustedInsertIndex) + } + + if (rafRef.current !== null) { + window.cancelAnimationFrame(rafRef.current) + rafRef.current = null + } + if (autoScrollRafRef.current !== null) { + window.cancelAnimationFrame(autoScrollRafRef.current) + autoScrollRafRef.current = null + } + + dragMetaRef.current = null + scrollContainerRef.current = null + setActiveDragKey(null) + setDragInsertIndex(null) + setDragOverlay(null) + } + + const handlePointerUp = (event: PointerEvent) => { + if (event.pointerId !== dragMetaRef.current?.pointerId) return + finishDrag() + } + + const handlePointerCancel = (event: PointerEvent) => { + if (event.pointerId !== dragMetaRef.current?.pointerId) return + finishDrag() + } + + const handleTouchMove = (event: TouchEvent) => { + if (!dragMetaRef.current || !event.cancelable) return + event.preventDefault() + } + + window.addEventListener('pointermove', handlePointerMove) + window.addEventListener('pointerup', handlePointerUp) + window.addEventListener('pointercancel', handlePointerCancel) + window.addEventListener('scroll', handleViewportChange, true) + window.addEventListener('resize', handleViewportChange) + window.addEventListener('touchmove', handleTouchMove, { passive: false }) + + return () => { + document.body.style.userSelect = '' + document.body.style.touchAction = '' + document.documentElement.style.overscrollBehavior = '' + if (rafRef.current !== null) { + window.cancelAnimationFrame(rafRef.current) + rafRef.current = null + } + if (autoScrollRafRef.current !== null) { + window.cancelAnimationFrame(autoScrollRafRef.current) + autoScrollRafRef.current = null + } + window.removeEventListener('pointermove', handlePointerMove) + window.removeEventListener('pointerup', handlePointerUp) + window.removeEventListener('pointercancel', handlePointerCancel) + window.removeEventListener('scroll', handleViewportChange, true) + window.removeEventListener('resize', handleViewportChange) + window.removeEventListener('touchmove', handleTouchMove) + } + }, [dragOverlay, getDaoKey, getInsertIndexForPointer, moveDaoToIndex, orderedDaos]) + + const draggedDao = React.useMemo(() => { + if (!activeDragKey) return null + return ( + orderedDaos.find( + (dao) => getDaoKey(dao.chainId, dao.collectionAddress) === activeDragKey + ) || null + ) + }, [activeDragKey, getDaoKey, orderedDaos]) + + const handleToggleHidden = React.useCallback( + (dao: ProfileDaoListItem, isHidden: boolean) => { + setDaoHidden(dao.chainId, dao.collectionAddress, !isHidden) + }, + [setDaoHidden] + ) + + const handlePointerDown = React.useCallback( + (event: React.PointerEvent, daoKey: string) => { + const rowNode = rowRefs.current[daoKey] + if (!rowNode) return + + event.preventDefault() + event.currentTarget.setPointerCapture(event.pointerId) + const rect = rowNode.getBoundingClientRect() + scrollContainerRef.current = getScrollableAncestor(rowNode) + latestPointerYRef.current = event.clientY + dragMetaRef.current = { + daoKey, + pointerOffsetY: event.clientY - rect.top, + pointerId: event.pointerId, + } + setActiveDragKey(daoKey) + setDragInsertIndex( + orderedDaos.findIndex( + (item) => getDaoKey(item.chainId, item.collectionAddress) === daoKey + ) + ) + setDragOverlay({ + daoKey, + width: rect.width, + x: rect.left, + y: rect.top, + }) + }, + [getDaoKey, orderedDaos] + ) + + const setRowRef = React.useCallback((daoKey: string, node: HTMLDivElement | null) => { + rowRefs.current[daoKey] = node + }, []) + + return ( + + + DAOs + {isOwnProfile && daos.length > 0 ? ( + + ) : null} + + + {daosForDisplay.map((dao, index) => { + const daoKey = getDaoKey(dao.chainId, dao.collectionAddress) + const isHidden = isDaoHidden(dao.chainId, dao.collectionAddress) + const isDragging = activeDragKey === daoKey + const row = ( + + ) + + return ( + + {isEditingDaos ? ( + row + ) : ( + + {row} + + )} + + ) + })} + + {isEditingDaos ? ( + + ) : null} + + {hiddenDaosCount > 0 && !isEditingDaos ? ( + setIsHiddenDaosOpen((current) => !current)} + > + + {hiddenDaos.map((dao) => { + const daoKey = getDaoKey(dao.chainId, dao.collectionAddress) + const row = ( + + ) + + return ( + + + {row} + + + ) + })} + + + ) : null} + + {dragOverlay && draggedDao + ? typeof document !== 'undefined' + ? createPortal( + + + undefined} + onPointerDown={() => undefined} + /> + + , + document.body + ) + : null + : null} + + ) +} diff --git a/apps/web/src/layouts/DefaultLayout/Nav.styles.css.ts b/apps/web/src/layouts/DefaultLayout/Nav.styles.css.ts index 23579c7da..2561456b1 100644 --- a/apps/web/src/layouts/DefaultLayout/Nav.styles.css.ts +++ b/apps/web/src/layouts/DefaultLayout/Nav.styles.css.ts @@ -165,6 +165,15 @@ export const daoButton = style({ }, }) +export const hiddenDaoButton = style({ + background: color.background2, + selectors: { + '&:hover': { + background: color.ghostHover, + }, + }, +}) + export const navLogo = style({ zIndex: z.NAV_LAYER, position: 'relative', diff --git a/apps/web/src/layouts/DefaultLayout/NavMenu/ProfileMenu.tsx b/apps/web/src/layouts/DefaultLayout/NavMenu/ProfileMenu.tsx index 09a1aaca7..5344b245f 100644 --- a/apps/web/src/layouts/DefaultLayout/NavMenu/ProfileMenu.tsx +++ b/apps/web/src/layouts/DefaultLayout/NavMenu/ProfileMenu.tsx @@ -1,4 +1,4 @@ -import { PUBLIC_DEFAULT_CHAINS } from '@buildeross/constants/chains' +import { PUBLIC_DEFAULT_CHAINS } from '@buildeross/constants/chains' import { MOBILE_PROFILE_MENU_LAYER, NAV_BUTTON_LAYER } from '@buildeross/constants/layers' import { useEnsData } from '@buildeross/hooks/useEnsData' import { useUserDaos } from '@buildeross/hooks/useUserDaos' @@ -12,6 +12,8 @@ import { Box, Button, Flex, Icon, PopUp, Text } from '@buildeross/zord' import NextImage from 'next/image' import Link from 'next/link' import React from 'react' +import { HiddenDaoDisclosure } from 'src/components/HiddenDaoDisclosure' +import { useDaoListPreferences } from 'src/utils/useDaoListPreferences' import { formatUnits } from 'viem' import { useAccount, useBalance } from 'wagmi' @@ -20,6 +22,7 @@ import { activeNavAvatar, daoButton, disconnectButton, + hiddenDaoButton, mobileMenuSlideIn, myDaosWrapper, navButton, @@ -52,6 +55,46 @@ export const ProfileMenu: React.FC = ({ : undefined const { daos } = useUserDaos({ address }) + const [isHiddenDaosOpen, setIsHiddenDaosOpen] = React.useState(false) + const { isDaoHidden, sortDaos, groupHiddenDaosLast } = useDaoListPreferences(address) + const chainSortedDaos = React.useMemo( + () => + [...daos].sort((a, b) => { + const aIndex = PUBLIC_DEFAULT_CHAINS.findIndex((chain) => chain.id === a.chainId) + const bIndex = PUBLIC_DEFAULT_CHAINS.findIndex((chain) => chain.id === b.chainId) + return aIndex - bIndex + }), + [daos] + ) + const hiddenDaosCount = React.useMemo( + () => + chainSortedDaos.filter((dao) => isDaoHidden(dao.chainId, dao.collectionAddress)) + .length, + [chainSortedDaos, isDaoHidden] + ) + const orderedDaos = React.useMemo(() => { + const orderedDaos = sortDaos( + chainSortedDaos, + (dao) => dao.collectionAddress, + (dao) => dao.chainId + ) + + return groupHiddenDaosLast( + orderedDaos, + (dao) => dao.collectionAddress, + (dao) => dao.chainId + ) + }, [chainSortedDaos, sortDaos, groupHiddenDaosLast]) + + const visibleDaos = React.useMemo( + () => orderedDaos.filter((dao) => !isDaoHidden(dao.chainId, dao.collectionAddress)), + [orderedDaos, isDaoHidden] + ) + + const hiddenDaos = React.useMemo( + () => orderedDaos.filter((dao) => isDaoHidden(dao.chainId, dao.collectionAddress)), + [orderedDaos, isDaoHidden] + ) const handleOpenMenu = React.useCallback( (open: boolean) => { @@ -153,58 +196,141 @@ export const ProfileMenu: React.FC = ({ } } > - {daos.map((dao, index) => { + {visibleDaos.map((dao, index) => { + const daoKey = `${dao.chainId}:${dao.collectionAddress}` const chainMeta = PUBLIC_DEFAULT_CHAINS.find((c) => c.id === dao.chainId) return ( - - - - - {dao.name} - - {chainMeta?.icon && ( - - )} - - {chainMeta?.name} - + + + + {dao.name} + + + + {chainMeta?.icon && ( + + )} + - + ) })} + {hiddenDaosCount > 0 && ( + setIsHiddenDaosOpen((x) => !x)} + > + + {hiddenDaos.map((dao, index) => { + const daoKey = `${dao.chainId}:${dao.collectionAddress}` + const chainMeta = PUBLIC_DEFAULT_CHAINS.find((c) => c.id === dao.chainId) + + return ( + + + + + + {dao.name} + + + + + + {chainMeta?.icon && ( + + )} + + + + ) + })} + + + )} diff --git a/apps/web/src/modules/dashboard/AuctionPaused.tsx b/apps/web/src/modules/dashboard/AuctionPaused.tsx index 0c95cdb7f..77386adf8 100644 --- a/apps/web/src/modules/dashboard/AuctionPaused.tsx +++ b/apps/web/src/modules/dashboard/AuctionPaused.tsx @@ -1,4 +1,4 @@ -import { AddressType, Chain } from '@buildeross/types' +import { AddressType, Chain } from '@buildeross/types' import { useLinks } from '@buildeross/ui/LinksProvider' import { LinkWrapper as Link } from '@buildeross/ui/LinkWrapper' import { atoms, Box, Flex, Icon, icons, Text } from '@buildeross/zord' @@ -6,19 +6,55 @@ import Image from 'next/image' import React from 'react' import { DashboardDaoProps } from './Dashboard' -import { bidBox, daoAvatarBox, daoTokenName, outerAuctionCard } from './dashboard.css' +import { + bidBox, + daoAvatarBox, + daoTokenName, + hiddenAuctionCard, + outerAuctionCard, +} from './dashboard.css' type PausedType = DashboardDaoProps & { chain: Chain tokenAddress: AddressType + isHidden: boolean } -export const AuctionPaused = ({ name, tokenAddress, chain }: PausedType) => { +export const AuctionPaused = ({ name, tokenAddress, chain, isHidden }: PausedType) => { const Paused = icons.pause const { getDaoLink } = useLinks() return ( - + + + {chain.icon && ( + {chain.name} + )} + + {chain.name} + + + @@ -42,27 +78,9 @@ export const AuctionPaused = ({ name, tokenAddress, chain }: PausedType) => { {name} - - {chain.icon && ( - {chain.name} - )} - - {chain.name} - - + void + isHidden: boolean } export const DaoAuctionCard = (props: DaoAuctionCardProps) => { - const { currentAuction, chainId, auctionAddress, handleMutate, tokenAddress } = props + const { + currentAuction, + chainId, + auctionAddress, + handleMutate, + tokenAddress, + isHidden, + } = props const { getAuctionLink } = useLinks() const chain = PUBLIC_ALL_CHAINS.find((chain) => chain.id === chainId) @@ -78,7 +87,14 @@ export const DaoAuctionCard = (props: DaoAuctionCardProps) => { } if (!currentAuction) { - return + return ( + + ) } const bidText = currentAuction.highestBid?.amount @@ -88,7 +104,36 @@ export const DaoAuctionCard = (props: DaoAuctionCardProps) => { const tokenImage = currentAuction?.token?.image return ( - + + + {chain.icon && ( + {chain.name} + )} + + {chain.name} + + + { )} - - {chain.icon && ( - {chain.name} - )} - - {chain.name} - - + diff --git a/apps/web/src/modules/dashboard/Dashboard.tsx b/apps/web/src/modules/dashboard/Dashboard.tsx index 394e8a019..3c5665061 100644 --- a/apps/web/src/modules/dashboard/Dashboard.tsx +++ b/apps/web/src/modules/dashboard/Dashboard.tsx @@ -11,6 +11,8 @@ import { AccordionItem } from '@buildeross/ui/Accordion' import { DisplayPanel } from '@buildeross/ui/DisplayPanel' import { Box, Stack, Text } from '@buildeross/zord' import React, { useMemo } from 'react' +import { HiddenDaoDisclosure } from 'src/components/HiddenDaoDisclosure' +import { useDaoListPreferences } from 'src/utils/useDaoListPreferences' import { useAccount } from 'wagmi' import { CreateActions } from './CreateActions' @@ -29,6 +31,8 @@ export const Dashboard: React.FC = () => { const [openAccordion, setOpenAccordion] = React.useState<'daos' | 'proposals' | null>( null ) + const [isHiddenDaosOpen, setIsHiddenDaosOpen] = React.useState(false) + const { isDaoHidden, sortDaos, groupHiddenDaosLast } = useDaoListPreferences(address) const { daos, @@ -40,7 +44,7 @@ export const Dashboard: React.FC = () => { enabled: !!address, }) - const sortedDaos = useMemo(() => { + const chainSortedDaos = useMemo(() => { if (!daos) return [] return [...daos].sort((a, b) => { const aIndex = PUBLIC_DEFAULT_CHAINS.findIndex((chain) => chain.id === a.chainId) @@ -49,22 +53,66 @@ export const Dashboard: React.FC = () => { }) }, [daos]) + const sortedDaos = useMemo( + () => { + const orderedDaos = sortDaos( + chainSortedDaos, + (dao) => dao.tokenAddress, + (dao) => dao.chainId + ) + + return groupHiddenDaosLast( + orderedDaos, + (dao) => dao.tokenAddress, + (dao) => dao.chainId + ) + }, + [chainSortedDaos, sortDaos, groupHiddenDaosLast] + ) + + const visibleDaos = useMemo( + () => sortedDaos.filter((dao) => !isDaoHidden(dao.chainId, dao.tokenAddress)), + [sortedDaos, isDaoHidden] + ) + const hiddenDaos = useMemo( + () => sortedDaos.filter((dao) => isDaoHidden(dao.chainId, dao.tokenAddress)), + [sortedDaos, isDaoHidden] + ) + const hiddenDaosCount = sortedDaos.length - visibleDaos.length + const auctionCards = useMemo(() => { - if (!address || !sortedDaos.length) return null + if (!address || !visibleDaos.length) return null - return sortedDaos.map((dao) => ( - - )) - }, [sortedDaos, address, mutate]) + return visibleDaos.map((dao) => { + return ( + + + + ) + }) + }, [visibleDaos, address, mutate]) + + const hiddenAuctionCards = useMemo(() => { + if (!address || !hiddenDaos.length) return null + + return hiddenDaos.map((dao) => { + return ( + + + + ) + }) + }, [hiddenDaos, address, mutate]) const hasLiveProposals = useMemo(() => { if (!sortedDaos.length) return false @@ -238,8 +286,23 @@ export const Dashboard: React.FC = () => { {auctionCards}} + summary={`${visibleDaos.length} DAO${visibleDaos.length !== 1 ? 's' : ''}${ + hiddenDaosCount > 0 ? ` (${hiddenDaosCount} hidden)` : '' + }`} + description={ + + {auctionCards} + {hiddenDaosCount > 0 && ( + setIsHiddenDaosOpen((x) => !x)} + > + {hiddenAuctionCards} + + )} + + } titleFontSize={18} mb={'x0'} isOpen={openAccordion === 'daos'} diff --git a/apps/web/src/modules/dashboard/dashboard.css.ts b/apps/web/src/modules/dashboard/dashboard.css.ts index f20462fa8..2dd0baca2 100644 --- a/apps/web/src/modules/dashboard/dashboard.css.ts +++ b/apps/web/src/modules/dashboard/dashboard.css.ts @@ -23,6 +23,11 @@ export const outerAuctionCard = style([ }, ]) +export const hiddenAuctionCard = style({ + backgroundColor: theme.colors.background2, + borderColor: theme.colors.neutralHover, +}) + export const proposalCardVariants = { default: style({ transition: 'border-color 0.15s ease-in-out', diff --git a/apps/web/src/pages/api/og/dao.tsx b/apps/web/src/pages/api/og/dao.tsx index d470df8de..16dd1d1a4 100644 --- a/apps/web/src/pages/api/og/dao.tsx +++ b/apps/web/src/pages/api/og/dao.tsx @@ -46,7 +46,6 @@ const getTreasuryBalance = async ( blockTag: 'latest', }) - // Convert to ETH value const balanceInWei = BigInt(result.value) const balanceInEth = formatEther(balanceInWei) const data = formatCryptoVal(balanceInEth) diff --git a/apps/web/src/pages/profile/[user].tsx b/apps/web/src/pages/profile/[user].tsx index fbd850cbc..ae86eaa8a 100644 --- a/apps/web/src/pages/profile/[user].tsx +++ b/apps/web/src/pages/profile/[user].tsx @@ -1,4 +1,4 @@ -import { BASE_URL, CACHE_TIMES, SWR_KEYS } from '@buildeross/constants' +import { CACHE_TIMES, SWR_KEYS } from '@buildeross/constants' import { PUBLIC_DEFAULT_CHAINS } from '@buildeross/constants/chains' import { SectionHandler } from '@buildeross/dao-ui' import { Feed } from '@buildeross/feed-ui' @@ -7,7 +7,7 @@ import { useUserDaos } from '@buildeross/hooks/useUserDaos' import { myDaosRequest, tokensQuery } from '@buildeross/sdk/subgraph' import { useChainStore } from '@buildeross/stores' import { AddressType } from '@buildeross/types' -import { Avatar, DaoAvatar } from '@buildeross/ui/Avatar' +import { Avatar } from '@buildeross/ui/Avatar' import { CopyButton } from '@buildeross/ui/CopyButton' import { FallbackImage } from '@buildeross/ui/FallbackImage' import { Pagination } from '@buildeross/ui/Pagination' @@ -15,30 +15,29 @@ import { getEnsAddress, getEnsName } from '@buildeross/utils/ens' import { walletSnippet } from '@buildeross/utils/helpers' import { Box, Flex, Grid, Text } from '@buildeross/zord' import { GetServerSideProps } from 'next' -import NextImage from 'next/image' import Link from 'next/link' import { useRouter } from 'next/router' import React from 'react' import { Meta } from 'src/components/Meta' +import { ProfileDaoList } from 'src/components/ProfileDaoList' import { getProfileLayout } from 'src/layouts/ProfileLayout' import { NextPageWithLayout } from 'src/pages/_app' import { daosContainer, loadingSkeleton, noTokensContainer, - profileDaoLink, responsiveGrid, tokenContainer, } from 'src/styles/profile.css' import useSWR, { unstable_serialize } from 'swr' import { isAddress } from 'viem' +import { useAccount } from 'wagmi' interface ProfileProps { userAddress: string userName: string ogImageURL: string } - const ProfilePage: NextPageWithLayout = ({ userAddress, userName, @@ -46,10 +45,12 @@ const ProfilePage: NextPageWithLayout = ({ }) => { const chain = useChainStore((x) => x.chain) const { query, push, pathname } = useRouter() + const { address: connectedAddress } = useAccount() const page = query.page as string const { ensName, ensAvatar } = useEnsData(userAddress) + const isOwnProfile = connectedAddress?.toLowerCase() === userAddress.toLowerCase() const openTab = React.useCallback( async (tab: string, scroll?: boolean) => { @@ -245,9 +246,6 @@ const ProfilePage: NextPageWithLayout = ({ - - DAOs - {isLoadingDaos ? ( = ({ borderRadius="normal" /> ) : daos && daos?.length > 0 ? ( - - {daos.map((dao) => { - const chainMeta = PUBLIC_DEFAULT_CHAINS.find( - (chain) => chain.id === dao.chainId - ) - return ( - - - - - {dao.name} - - {chainMeta?.icon && ( - - )} - - {chainMeta?.name} - - - - - - ) - })} - + ) : ( No DAO tokens owned. )} diff --git a/apps/web/src/shims/reactNativeAsyncStorage.js b/apps/web/src/shims/reactNativeAsyncStorage.js new file mode 100644 index 000000000..6e8fc3d66 --- /dev/null +++ b/apps/web/src/shims/reactNativeAsyncStorage.js @@ -0,0 +1,40 @@ +const inMemoryStorage = new Map() + +const AsyncStorage = { + async getItem(key) { + return inMemoryStorage.has(key) ? inMemoryStorage.get(key) : null + }, + async setItem(key, value) { + inMemoryStorage.set(key, value) + }, + async removeItem(key) { + inMemoryStorage.delete(key) + }, + async clear() { + inMemoryStorage.clear() + }, + async getAllKeys() { + return Array.from(inMemoryStorage.keys()) + }, + async multiGet(keys) { + return keys.map((key) => [key, inMemoryStorage.has(key) ? inMemoryStorage.get(key) : null]) + }, + async multiSet(entries) { + entries.forEach(([key, value]) => { + inMemoryStorage.set(key, value) + }) + }, + async multiRemove(keys) { + keys.forEach((key) => { + inMemoryStorage.delete(key) + }) + }, +} + +export const useAsyncStorage = (key) => ({ + getItem: () => AsyncStorage.getItem(key), + setItem: (value) => AsyncStorage.setItem(key, value), + removeItem: () => AsyncStorage.removeItem(key), +}) + +export default AsyncStorage diff --git a/apps/web/src/styles/profile.css.ts b/apps/web/src/styles/profile.css.ts index c4ed66ed2..ee9b8a785 100644 --- a/apps/web/src/styles/profile.css.ts +++ b/apps/web/src/styles/profile.css.ts @@ -58,3 +58,62 @@ export const profileDaoLink = style({ }, }, }) + +export const profileHiddenDaoLink = style({ + backgroundColor: color.background2, +}) + +export const daoEditorRow = style({ + width: '100%', +}) + +export const daoEditorButtonGroup = style({ + display: 'flex', + alignItems: 'center', + gap: '8px', + flexShrink: 0, +}) + +export const daoEditorIconButton = style({ + minWidth: '28px', + width: '28px', + height: '28px', + padding: '0', +}) + +export const daoEditorDragHandle = style({ + cursor: 'grab', + touchAction: 'none', + selectors: { + '&:active': { + cursor: 'grabbing', + }, + }, +}) + +export const daoEditorDragging = style({ + position: 'relative', +}) + +export const daoEditorSpacer = style({ + height: '0', + transition: 'height 0.12s ease-out', +}) + +export const daoEditorSpacerActive = style({ + height: '18px', +}) + +export const daoEditorDoneButton = style({ + borderColor: '#2563eb', + color: '#2563eb', + backgroundColor: 'rgba(37, 99, 235, 0.06)', + selectors: { + '&:hover': { + borderColor: '#1d4ed8', + color: '#1d4ed8', + backgroundColor: 'rgba(37, 99, 235, 0.1)', + }, + }, +}) + diff --git a/apps/web/src/utils/useDaoListPreferences.ts b/apps/web/src/utils/useDaoListPreferences.ts new file mode 100644 index 000000000..33e34389c --- /dev/null +++ b/apps/web/src/utils/useDaoListPreferences.ts @@ -0,0 +1,280 @@ +import React from 'react' + +const getHiddenStorageKey = (address: string) => + `dao-list:hidden:${address.toLowerCase()}` +const getOrderStorageKey = (address: string) => `dao-list:order:${address.toLowerCase()}` +const getLegacyPinnedStorageKey = (address: string) => + `dao-shortlist:pinned:${address.toLowerCase()}` + +export const getDaoListPreferenceItemKey = (chainId: number, collectionAddress: string) => + `${chainId}:${collectionAddress.toLowerCase()}` + +const DAO_LIST_PREFERENCES_EVENT = 'dao-list-preferences:update' + +type DaoListPreferencesEventDetail = { + hiddenStorageKey?: string + orderStorageKey?: string + hiddenDaoKeys?: string[] + orderedDaoKeys?: string[] +} + +type PreferenceUpdater = string[] | ((currentKeys: string[]) => string[]) + +const parseStoredKeys = (storedValue: string | null) => { + if (!storedValue) return [] + + try { + const parsed = JSON.parse(storedValue) + if (!Array.isArray(parsed)) return [] + return parsed.filter((value): value is string => typeof value === 'string') + } catch { + return [] + } +} + +export const useDaoListPreferences = (address?: string) => { + const hiddenStorageKey = React.useMemo( + () => (address ? getHiddenStorageKey(address) : undefined), + [address] + ) + const orderStorageKey = React.useMemo( + () => (address ? getOrderStorageKey(address) : undefined), + [address] + ) + const legacyPinnedStorageKey = React.useMemo( + () => (address ? getLegacyPinnedStorageKey(address) : undefined), + [address] + ) + + const [hiddenDaoKeys, setHiddenDaoKeys] = React.useState([]) + const [orderedDaoKeys, setOrderedDaoKeys] = React.useState([]) + + const hiddenDaoKeySet = React.useMemo(() => new Set(hiddenDaoKeys), [hiddenDaoKeys]) + const orderedDaoKeySet = React.useMemo(() => new Set(orderedDaoKeys), [orderedDaoKeys]) + + React.useEffect(() => { + if (!hiddenStorageKey || typeof window === 'undefined') { + setHiddenDaoKeys([]) + return + } + + setHiddenDaoKeys(parseStoredKeys(window.localStorage.getItem(hiddenStorageKey))) + }, [hiddenStorageKey]) + + React.useEffect(() => { + if (!orderStorageKey || typeof window === 'undefined') { + setOrderedDaoKeys([]) + return + } + + setOrderedDaoKeys(parseStoredKeys(window.localStorage.getItem(orderStorageKey))) + }, [orderStorageKey]) + + React.useEffect(() => { + if (!legacyPinnedStorageKey || typeof window === 'undefined') return + + try { + window.localStorage.removeItem(legacyPinnedStorageKey) + } catch (e) { + console.error('Failed to clear legacy pinned DAO state', e) + } + }, [legacyPinnedStorageKey]) + + React.useEffect(() => { + if ((!hiddenStorageKey && !orderStorageKey) || typeof window === 'undefined') return + + const onPreferencesUpdate = (event: Event) => { + const customEvent = event as CustomEvent + + if ( + customEvent.detail?.hiddenStorageKey === hiddenStorageKey && + customEvent.detail.hiddenDaoKeys + ) { + setHiddenDaoKeys(customEvent.detail.hiddenDaoKeys) + } + + if ( + customEvent.detail?.orderStorageKey === orderStorageKey && + customEvent.detail.orderedDaoKeys + ) { + setOrderedDaoKeys(customEvent.detail.orderedDaoKeys) + } + } + + const onStorage = (event: StorageEvent) => { + if (event.key === hiddenStorageKey) { + setHiddenDaoKeys(parseStoredKeys(event.newValue)) + } + + if (event.key === orderStorageKey) { + setOrderedDaoKeys(parseStoredKeys(event.newValue)) + } + } + + window.addEventListener(DAO_LIST_PREFERENCES_EVENT, onPreferencesUpdate) + window.addEventListener('storage', onStorage) + + return () => { + window.removeEventListener(DAO_LIST_PREFERENCES_EVENT, onPreferencesUpdate) + window.removeEventListener('storage', onStorage) + } + }, [hiddenStorageKey, orderStorageKey]) + + const persistHiddenDaosToStorage = React.useCallback( + (nextHiddenDaos: string[]) => { + if (!hiddenStorageKey || typeof window === 'undefined') return + + try { + window.localStorage.setItem(hiddenStorageKey, JSON.stringify(nextHiddenDaos)) + window.dispatchEvent( + new CustomEvent(DAO_LIST_PREFERENCES_EVENT, { + detail: { + hiddenStorageKey, + hiddenDaoKeys: nextHiddenDaos, + }, + }) + ) + } catch (e) { + console.error('Failed to persist hidden DAO state', e) + } + }, + [hiddenStorageKey] + ) + + const persistOrderedDaosToStorage = React.useCallback( + (nextOrderedDaos: string[]) => { + if (!orderStorageKey || typeof window === 'undefined') return + + try { + window.localStorage.setItem(orderStorageKey, JSON.stringify(nextOrderedDaos)) + window.dispatchEvent( + new CustomEvent(DAO_LIST_PREFERENCES_EVENT, { + detail: { + orderStorageKey, + orderedDaoKeys: nextOrderedDaos, + }, + }) + ) + } catch (e) { + console.error('Failed to persist DAO order state', e) + } + }, + [orderStorageKey] + ) + + const persistHiddenDaos = React.useCallback( + (nextHiddenDaos: PreferenceUpdater) => { + setHiddenDaoKeys((currentHiddenDaos) => { + const resolvedHiddenDaos = + typeof nextHiddenDaos === 'function' ? nextHiddenDaos(currentHiddenDaos) : nextHiddenDaos + const dedupedHiddenDaos = Array.from(new Set(resolvedHiddenDaos)) + persistHiddenDaosToStorage(dedupedHiddenDaos) + return dedupedHiddenDaos + }) + }, + [persistHiddenDaosToStorage] + ) + + const persistOrderedDaos = React.useCallback( + (nextOrderedDaos: PreferenceUpdater) => { + setOrderedDaoKeys((currentOrderedDaos) => { + const resolvedOrderedDaos = + typeof nextOrderedDaos === 'function' ? nextOrderedDaos(currentOrderedDaos) : nextOrderedDaos + const dedupedOrderedDaos = Array.from(new Set(resolvedOrderedDaos)) + persistOrderedDaosToStorage(dedupedOrderedDaos) + return dedupedOrderedDaos + }) + }, + [persistOrderedDaosToStorage] + ) + + const isDaoHidden = React.useCallback( + (chainId: number, collectionAddress: string) => + hiddenDaoKeySet.has(getDaoListPreferenceItemKey(chainId, collectionAddress)), + [hiddenDaoKeySet] + ) + + const setDaoHidden = React.useCallback( + (chainId: number, collectionAddress: string, hidden: boolean) => { + const daoKey = getDaoListPreferenceItemKey(chainId, collectionAddress) + + if (hidden) { + persistHiddenDaos((currentHiddenDaos) => { + if (currentHiddenDaos.includes(daoKey)) { + return currentHiddenDaos + } + + return [...currentHiddenDaos, daoKey] + }) + return + } + + persistHiddenDaos((currentHiddenDaos) => + currentHiddenDaos.filter((key) => key !== daoKey) + ) + }, + [persistHiddenDaos] + ) + + const sortDaos = React.useCallback( + ( + daos: T[], + getCollectionAddress: (dao: T) => string, + getChainId: (dao: T) => number + ) => { + const keyedDaos = daos.map((dao) => ({ + dao, + key: getDaoListPreferenceItemKey(getChainId(dao), getCollectionAddress(dao)), + })) + + const daoKeySet = new Set(keyedDaos.map((item) => item.key)) + const orderedKeysForDaos = orderedDaoKeys.filter((key) => daoKeySet.has(key)) + const keyedDaoMap = new Map(keyedDaos.map((item) => [item.key, item.dao])) + + const orderedDaos = orderedKeysForDaos + .map((key) => keyedDaoMap.get(key)) + .filter((item): item is T => item !== undefined) + + const unorderedDaos = keyedDaos + .filter((item) => !orderedDaoKeySet.has(item.key)) + .map((item) => item.dao) + + return [...orderedDaos, ...unorderedDaos] + }, + [orderedDaoKeySet, orderedDaoKeys] + ) + + const groupHiddenDaosLast = React.useCallback( + ( + daos: T[], + getCollectionAddress: (dao: T) => string, + getChainId: (dao: T) => number + ) => { + const visibleDaos: T[] = [] + const hiddenDaos: T[] = [] + + daos.forEach((dao) => { + if (isDaoHidden(getChainId(dao), getCollectionAddress(dao))) { + hiddenDaos.push(dao) + return + } + + visibleDaos.push(dao) + }) + + return [...visibleDaos, ...hiddenDaos] + }, + [isDaoHidden] + ) + + return { + hiddenDaoKeys, + hiddenDaoCount: hiddenDaoKeys.length, + orderedDaoKeys, + isDaoHidden, + setDaoHidden, + persistOrderedDaos, + sortDaos, + groupHiddenDaosLast, + } +} diff --git a/packages/types/package.json b/packages/types/package.json index 58d603469..859720193 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -42,7 +42,7 @@ "build": "tsup", "clean": "rm -rf dist .turbo", "lint": "pnpm type-check && eslint src --fix", - "postbuild": "cp src/globals.d.ts dist/", + "postbuild": "node -e \"require('fs').copyFileSync('src/globals.d.ts', 'dist/globals.d.ts')\"", "type-check": "tsc --noEmit" }, "types": "./dist/index.d.ts",