mirror of
https://github.com/hanzoai/gui.git
synced 2026-10-23 02:31:31 +00:00
Empties ADMIN_EMAILS and DEVELOPER_GITHUB_USERNAMES, so the impersonate, recent-purchases and whitelist routes answer 403 to every caller. Points security reports and funding at Hanzo.
1088 lines
28 KiB
TypeScript
1088 lines
28 KiB
TypeScript
import { LogoWords, setTintFamily, GuiLogo, ThemeTint, useTint } from '@hanzogui/logo'
|
|
import { Check, ExternalLink, Figma, LogIn, Menu } from '@hanzogui/lucide-icons-2'
|
|
import { isTouchable, useGet, useMedia } from '@hanzogui/web'
|
|
import { useFocusEffect, usePathname, useRouter } from 'one'
|
|
import * as React from 'react'
|
|
import { useWindowDimensions, type LayoutRectangle } from 'react-native'
|
|
import {
|
|
Adapt,
|
|
AnimatePresence,
|
|
Button,
|
|
Circle,
|
|
debounce,
|
|
isClient,
|
|
Paragraph,
|
|
Popover,
|
|
Separator,
|
|
Sheet,
|
|
SizableText,
|
|
styled,
|
|
TooltipGroup,
|
|
useComposedRefs,
|
|
useDebounce,
|
|
View,
|
|
XGroup,
|
|
XStack,
|
|
YStack,
|
|
type PopoverProps,
|
|
} from '@hanzo/gui'
|
|
import { Link } from '~/components/Link'
|
|
import { useBannerHeight } from '~/components/PromoBanner'
|
|
import { GithubIcon } from '~/features/icons/GithubIcon'
|
|
import { seasons, SeasonTogglePopover } from '~/features/site/seasons/SeasonTogglePopover'
|
|
import { ThemeToggle } from '~/features/site/theme/ThemeToggle'
|
|
import { useThemeBuilderStore } from '~/features/studio/theme/store/ThemeBuilderStore'
|
|
import { useLoginLink } from '../../auth/useLoginLink'
|
|
import { useBentoStore } from '../../bento/BentoStore'
|
|
import { useBentoTheme } from '../../bento/useBentoTheme'
|
|
import { DocsMenuContents } from '../../docs/DocsMenuContents'
|
|
import { useDocsMenu } from '../../docs/useDocsMenu'
|
|
import { AddEvenBrandIcon } from '../../icons/AddEvenBrandIcon'
|
|
import { BentoIcon } from '../../icons/BentoIcon'
|
|
import { TakeoutIcon } from '../../icons/TakeoutIcon'
|
|
import { useUser } from '../../user/useUser'
|
|
import { accountModal } from '../purchase/accountModalStore'
|
|
import { PromoCardTheme } from './PromoCards'
|
|
import { SearchButton } from './SearchButton'
|
|
import { UpgradeToProPopover } from './UpgradeToProPopover'
|
|
import { UserAvatar } from './UserAvatar'
|
|
import type { HeaderProps } from './types'
|
|
|
|
export function Header(props: HeaderProps) {
|
|
const [isScrolled, setIsScrolled] = React.useState(false)
|
|
const bannerHeight = useBannerHeight()
|
|
|
|
if (isClient) {
|
|
React.useEffect(() => {
|
|
const onScroll = () => {
|
|
setIsScrolled(window.scrollY > 30)
|
|
}
|
|
window.addEventListener('scroll', onScroll, { passive: true })
|
|
return () => {
|
|
window.removeEventListener('scroll', onScroll)
|
|
}
|
|
}, [])
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<XStack
|
|
position="fixed"
|
|
t={0}
|
|
l={0}
|
|
r={0}
|
|
items="center"
|
|
pointerEvents="none"
|
|
justify="center"
|
|
z={10000}
|
|
className="all ease-out s1"
|
|
$gtSm={{
|
|
px: '$1',
|
|
}}
|
|
>
|
|
<XStack pointerEvents="auto" width="100%" maxW={1200} position="relative">
|
|
<XStack
|
|
className={`ease-out all ms300`}
|
|
py="$1.5"
|
|
y={bannerHeight}
|
|
overflow="hidden"
|
|
contain="paint"
|
|
width="100%"
|
|
bg="transparent"
|
|
rounded="$10"
|
|
borderColor={'transparent'}
|
|
borderWidth={0.5}
|
|
$sm={{
|
|
rounded: 0,
|
|
borderWidth: 0,
|
|
y: -1 + (isScrolled ? 0 : bannerHeight),
|
|
py: '$2',
|
|
}}
|
|
{...(isScrolled && {
|
|
$gtSm: {
|
|
borderColor: '$color5',
|
|
y: 6,
|
|
},
|
|
})}
|
|
{...(props.hasBanner && {
|
|
$gtSm: {
|
|
y: isScrolled ? 6 : 35,
|
|
},
|
|
})}
|
|
>
|
|
<YStack
|
|
position="absolute"
|
|
inset={0}
|
|
className={'ease-out all ms100'}
|
|
style={{
|
|
...(isScrolled && {
|
|
backdropFilter: `blur(16px)`,
|
|
WebkitBackdropFilter: `blur(16px)`,
|
|
}),
|
|
}}
|
|
/>
|
|
<YStack
|
|
opacity={isScrolled ? 0.6 : 0}
|
|
className={`ease-out all ms300`}
|
|
fullscreen
|
|
bg="$color2"
|
|
/>
|
|
<YStack mx="auto" px="$4" width="100%">
|
|
<ThemeTint>
|
|
<HeaderContents floating {...props} />
|
|
</ThemeTint>
|
|
</YStack>
|
|
</XStack>
|
|
{/* do shadow separate so we can contain paint because its causing perf issues */}
|
|
<XStack
|
|
className="ease-in-out all ms200"
|
|
z={-1}
|
|
rounded="$10"
|
|
fullscreen
|
|
boxShadow="0 8px 20px $shadow3"
|
|
opacity={0}
|
|
{...(isScrolled && {
|
|
$gtSm: {
|
|
opacity: 1,
|
|
py: '$2',
|
|
y: 5,
|
|
},
|
|
})}
|
|
/>
|
|
</XStack>
|
|
</XStack>
|
|
<YStack height={54} width="100%" />
|
|
</>
|
|
)
|
|
}
|
|
|
|
const tooltipDelay = { open: 0, close: 150 }
|
|
|
|
export const HeaderContents = React.memo((props: HeaderProps) => {
|
|
const pathname = usePathname()
|
|
const isHome = pathname === '/'
|
|
const tint = useTint()
|
|
|
|
return (
|
|
<XStack
|
|
items="center"
|
|
position="relative"
|
|
render="header"
|
|
py={props.minimal ? '$4' : props.floating ? 0 : '$2'}
|
|
z={50000}
|
|
>
|
|
<XStack items="center" gap="$4">
|
|
<TooltipGroup delay={tooltipDelay}>
|
|
<XGroup maxH={32} bg="transparent" items="center" size="$4">
|
|
<XGroup.Item>
|
|
<ThemeToggle borderWidth={0} chromeless />
|
|
</XGroup.Item>
|
|
</XGroup>
|
|
</TooltipGroup>
|
|
|
|
<SearchButton size="$2" rounded="$10" elevation="$0.5" />
|
|
|
|
<Link target="_blank" href="https://github.com/hanzoai/gui">
|
|
<XStack group containerType="normal">
|
|
<XStack
|
|
items="center"
|
|
gap="$2"
|
|
p="$2"
|
|
opacity={0.9}
|
|
hoverStyle={{ opacity: 1 }}
|
|
>
|
|
<GithubIcon width={22} />
|
|
<>
|
|
<SizableText
|
|
$xl={{ display: 'none' }}
|
|
size="$3"
|
|
color="$color12"
|
|
opacity={0.5}
|
|
$group-hover={{
|
|
opacity: 0.8,
|
|
}}
|
|
>
|
|
GitHub
|
|
</SizableText>
|
|
</>
|
|
</XStack>
|
|
</XStack>
|
|
</Link>
|
|
|
|
<UpgradeToProPopover />
|
|
</XStack>
|
|
|
|
<View flex={1} />
|
|
|
|
<XStack
|
|
position="absolute"
|
|
$md={{
|
|
opacity: 0,
|
|
pointerEvents: 'none',
|
|
}}
|
|
z={-1}
|
|
justify="center"
|
|
fullscreen
|
|
pointerEvents="none"
|
|
items="center"
|
|
>
|
|
<Link href="/" aria-label="Homepage">
|
|
<XStack
|
|
cursor={isHome ? 'default' : 'pointer'}
|
|
pointerEvents="auto"
|
|
self="center"
|
|
gap="$3"
|
|
ml="$-5"
|
|
items="center"
|
|
>
|
|
<SeasonTogglePopover>
|
|
<YStack
|
|
cursor="pointer"
|
|
opacity={1}
|
|
{...(isHome && {
|
|
onPress(e) {
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
tint.setNextTint()
|
|
},
|
|
})}
|
|
>
|
|
<GuiLogo downscale={2.6} />
|
|
</YStack>
|
|
</SeasonTogglePopover>
|
|
<LogoWords animated />
|
|
</XStack>
|
|
</Link>
|
|
</XStack>
|
|
|
|
<XStack height={40} justify="flex-end" pointerEvents="auto" render="nav">
|
|
<XStack items="center" gap="$2">
|
|
<HeaderLinksPopover>
|
|
<HeaderLink id="core" href="/docs/intro/introduction">
|
|
Core
|
|
</HeaderLink>
|
|
|
|
<HeaderLink id="ui" href="/ui/intro">
|
|
UI
|
|
</HeaderLink>
|
|
|
|
<HeaderLink id="theme" href="/theme">
|
|
Theme
|
|
</HeaderLink>
|
|
|
|
<HeaderMenuButton />
|
|
</HeaderLinksPopover>
|
|
</XStack>
|
|
</XStack>
|
|
</XStack>
|
|
)
|
|
})
|
|
|
|
const HeaderMenuButton = () => {
|
|
const { open, setOpen } = useDocsMenu()
|
|
const context = React.useContext(SlidingPopoverContext)
|
|
const userSwr = useUser()
|
|
const haveUser = !!userSwr.data?.user
|
|
|
|
return (
|
|
<Popover.Trigger>
|
|
<SlidingPopoverTarget id="menu">
|
|
<Button
|
|
size="$5"
|
|
circular
|
|
my={-1}
|
|
bg="transparent"
|
|
borderWidth={0}
|
|
onPress={(e) => {
|
|
if (isTouchable) {
|
|
setOpen(!open)
|
|
// Ensure the active state is set for the menu to open properly on mobile
|
|
// On mobile, we just need to set the active state, not the layout
|
|
// The Sheet doesn't need positioning data
|
|
context.setActive('menu')
|
|
return
|
|
}
|
|
if (isOnLink) {
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
return
|
|
}
|
|
if (open) {
|
|
setOpen(false)
|
|
return
|
|
}
|
|
}}
|
|
aria-label="Open the main menu"
|
|
hoverStyle={{
|
|
bg: '$shadow1',
|
|
}}
|
|
>
|
|
<Circle size={34} items="center" justify="center">
|
|
{haveUser ? <UserAvatar /> : <Menu size={20} />}
|
|
</Circle>
|
|
</Button>
|
|
</SlidingPopoverTarget>
|
|
</Popover.Trigger>
|
|
)
|
|
}
|
|
|
|
let isOnMenu = false
|
|
const isOnLink = new Set<string>()
|
|
|
|
export const HeaderLinksPopover = (props: PopoverProps) => {
|
|
const popoverRef = React.useRef<Popover>(null)
|
|
const [active, setActive] = React.useState<ID | ''>('')
|
|
|
|
const close = () => {
|
|
setActive('')
|
|
popoverRef.current?.close()
|
|
}
|
|
|
|
const val = React.useMemo(() => {
|
|
return {
|
|
setActive(id: ID, layout: LayoutRectangle) {
|
|
popoverRef.current?.anchorTo(layout)
|
|
setActive(id)
|
|
},
|
|
close,
|
|
}
|
|
}, [])
|
|
|
|
const check = React.useRef<any>(undefined)
|
|
|
|
const checkForClose = () => {
|
|
if (isTouchable) return
|
|
check.current = setInterval(() => {
|
|
if (!isOnMenu && !isOnLink.size) {
|
|
close()
|
|
}
|
|
}, 500)
|
|
}
|
|
|
|
const cancelCheckForClose = () => {
|
|
if (isTouchable) return
|
|
clearInterval(check.current)
|
|
}
|
|
|
|
return (
|
|
<Popover
|
|
disableRTL
|
|
hoverable={{
|
|
delay: 0,
|
|
restMs: 0,
|
|
move: false,
|
|
enabled: !isTouchable,
|
|
}}
|
|
stayInFrame={{
|
|
padding: 20,
|
|
}}
|
|
open={!!active}
|
|
onOpenChange={(val, event) => {
|
|
if (!val) {
|
|
setActive('')
|
|
}
|
|
}}
|
|
ref={popoverRef}
|
|
{...props}
|
|
>
|
|
<SlidingPopoverContext.Provider value={val}>
|
|
<XStack onMouseEnter={cancelCheckForClose} onMouseLeave={checkForClose}>
|
|
{props.children}
|
|
</XStack>
|
|
<HeaderLinksPopoverContent active={active} />
|
|
</SlidingPopoverContext.Provider>
|
|
|
|
<Adapt platform="touch" when="sm">
|
|
<Sheet transition="medium" zIndex={100000000} modal dismissOnSnapToBottom>
|
|
<Sheet.Frame>
|
|
<Sheet.ScrollView showsVerticalScrollIndicator={false}>
|
|
<Adapt.Contents />
|
|
</Sheet.ScrollView>
|
|
</Sheet.Frame>
|
|
<Sheet.Overlay z={100} bg="$shadow4" />
|
|
</Sheet>
|
|
</Adapt>
|
|
</Popover>
|
|
)
|
|
}
|
|
|
|
type ID = 'core' | 'ui' | 'theme' | 'menu'
|
|
|
|
export const HeaderLink = (props: { id: ID; children: string; href: string }) => {
|
|
const pathname = usePathname()
|
|
const section = getDocsSectionFromPath(pathname)
|
|
const isActive =
|
|
props.id === section || (props.id === 'theme' && pathname.startsWith('/theme'))
|
|
|
|
return (
|
|
<SlidingPopoverTarget id={props.id}>
|
|
<Link asChild href={props.href as any}>
|
|
<HeadAnchor
|
|
{...(isActive && { active: true })}
|
|
$sm={{
|
|
display: 'none',
|
|
}}
|
|
>
|
|
{props.children}
|
|
</HeadAnchor>
|
|
</Link>
|
|
</SlidingPopoverTarget>
|
|
)
|
|
}
|
|
|
|
const SlidingPopoverContext = React.createContext({
|
|
setActive(id: ID, layout?: LayoutRectangle) {},
|
|
close() {},
|
|
})
|
|
|
|
export const SlidingPopoverTarget = YStack.styleable<{ id: ID }>(
|
|
({ id, ...props }, ref) => {
|
|
const context = React.useContext(SlidingPopoverContext)
|
|
const [layout, setLayout] = React.useState<LayoutRectangle | undefined>()
|
|
const triggerRef = React.useRef<HTMLElement>(null)
|
|
const combinedRef = useComposedRefs(ref)
|
|
const [hovered, setHovered] = React.useState(false)
|
|
const getLayout = useGet(layout)
|
|
|
|
React.useEffect(() => {
|
|
if (!hovered) return
|
|
|
|
const handleMove = debounce(() => {
|
|
const layout = triggerRef.current?.getBoundingClientRect()
|
|
if (layout) {
|
|
setLayout(layout as any)
|
|
}
|
|
}, 32)
|
|
window.addEventListener('resize', handleMove)
|
|
return () => {
|
|
window.removeEventListener('resize', handleMove)
|
|
}
|
|
}, [hovered])
|
|
|
|
const setActive = () => {
|
|
const layout = getLayout()
|
|
if (layout) {
|
|
isOnLink.add(id)
|
|
context.setActive(id, layout)
|
|
}
|
|
setHovered(true)
|
|
}
|
|
|
|
const setActiveDebounced = useDebounce(setActive, 100)
|
|
|
|
return (
|
|
<YStack
|
|
onMouseEnter={setActiveDebounced}
|
|
onMouseLeave={() => {
|
|
setActiveDebounced.cancel()
|
|
isOnLink.delete(id)
|
|
setHovered(false)
|
|
}}
|
|
onPress={() => {
|
|
if (isTouchable) return
|
|
setActiveDebounced.cancel()
|
|
setTimeout(() => {
|
|
context.close()
|
|
}, 400)
|
|
}}
|
|
onLayout={(e) => {
|
|
React.startTransition(() => {
|
|
setLayout({
|
|
...e.nativeEvent.layout,
|
|
// @ts-ignore
|
|
x: e.nativeEvent.layout.pageX,
|
|
// @ts-ignore
|
|
y: e.nativeEvent.layout.pageY,
|
|
})
|
|
})
|
|
}}
|
|
ref={combinedRef}
|
|
{...props}
|
|
/>
|
|
)
|
|
}
|
|
)
|
|
|
|
const order = ['', 'core', 'ui', 'theme', 'menu']
|
|
|
|
const HeaderLinksPopoverContent = React.memo((props: { active: ID | '' }) => {
|
|
const { data } = useUser()
|
|
const [active, setActive] = React.useState<ID>(
|
|
props.active === '' ? 'menu' : props.active
|
|
)
|
|
|
|
// Fix: Move setState to useEffect to avoid React #185 error (setState during render)
|
|
React.useEffect(() => {
|
|
if (props.active !== '' && active !== props.active) {
|
|
setActive(props.active)
|
|
}
|
|
}, [props.active])
|
|
|
|
const pathname = usePathname()
|
|
|
|
const context = React.useContext(SlidingPopoverContext)
|
|
const pointerFine = !isTouchable
|
|
const isOnlyShowingMenu = useMedia().maxMd
|
|
|
|
useFocusEffect(() => {
|
|
context.close()
|
|
}, [pathname])
|
|
|
|
const last = React.useRef(active)
|
|
|
|
const curI = order.indexOf(active)
|
|
const lastI = order.indexOf(last.current)
|
|
const going = curI > lastI ? 1 : -1
|
|
|
|
React.useEffect(() => {
|
|
last.current = active
|
|
}, [active])
|
|
|
|
const { height } = useWindowDimensions()
|
|
const maxHeight = height - 150
|
|
|
|
const heights = {
|
|
core: Math.min(maxHeight, 1300),
|
|
compiler: 117,
|
|
ui: Math.min(maxHeight, 1300),
|
|
theme: data?.user ? 300 : 240,
|
|
menu: Math.min(maxHeight, isOnlyShowingMenu ? 1000 : 520),
|
|
}
|
|
|
|
return (
|
|
<Popover.Content
|
|
onMouseEnter={() => {
|
|
isOnMenu = true
|
|
}}
|
|
onMouseLeave={() => {
|
|
isOnMenu = false
|
|
}}
|
|
animatePosition
|
|
transition="medium"
|
|
bg="$background06"
|
|
backdropFilter="blur(40px)"
|
|
maxH="90vh"
|
|
maxW={360}
|
|
minW={360}
|
|
elevation="$2"
|
|
p={0}
|
|
rounded="$6"
|
|
opacity={1}
|
|
y={0}
|
|
enterStyle={{
|
|
y: 3,
|
|
opacity: 0,
|
|
}}
|
|
exitStyle={{
|
|
y: 5,
|
|
opacity: 0,
|
|
}}
|
|
>
|
|
<Popover.Arrow transition="medium" animatePosition bg="$background06" size="$4" />
|
|
|
|
{pointerFine ? (
|
|
<YStack
|
|
width="100%"
|
|
transition="200ms"
|
|
height={heights[active]}
|
|
maxHeight="90vh"
|
|
overflow="hidden"
|
|
rounded="$6"
|
|
>
|
|
<AnimatePresence custom={{ going }} initial={false}>
|
|
<HeaderMenuContents key={active} id={active} />
|
|
</AnimatePresence>
|
|
</YStack>
|
|
) : (
|
|
<YStack p="$4">
|
|
<HeaderMenuContents key={active} id={active} />
|
|
</YStack>
|
|
)}
|
|
</Popover.Content>
|
|
)
|
|
})
|
|
|
|
const getDocsSectionFromPath = (pathName: string): 'core' | 'ui' | null => {
|
|
if (!pathName || pathName === '/' || pathName === '') return null
|
|
if (pathName.startsWith('/ui/')) return 'ui'
|
|
if (
|
|
pathName.startsWith('/docs') ||
|
|
pathName.startsWith('/community') ||
|
|
pathName.startsWith('/blog')
|
|
)
|
|
return 'core'
|
|
return null
|
|
}
|
|
|
|
const ActivePageDocsMenuContents = () => {
|
|
const pathName = usePathname()
|
|
const section = getDocsSectionFromPath(pathName)
|
|
|
|
if (!section) return null
|
|
|
|
return (
|
|
<>
|
|
<Separator bg="$color02" opacity={0.25} my="$4" />
|
|
<DocsMenuContents inMenu section={section} />
|
|
</>
|
|
)
|
|
}
|
|
|
|
const HeaderMenuContents = (props: { id: ID }) => {
|
|
const { data } = useUser()
|
|
const { updateGenerate } = useThemeBuilderStore()
|
|
const bentoStore = useBentoStore()
|
|
const themeHistories = data?.themeHistories || []
|
|
const bentoTheme = useBentoTheme()
|
|
const isOnlyShowingMenu = useMedia().maxMd
|
|
const isMobile = isTouchable && isOnlyShowingMenu
|
|
|
|
const contents = (() => {
|
|
/**
|
|
* When the theme_histories are fetched,
|
|
* we can apply one of them to Bento components from dropdown
|
|
*/
|
|
if (props.id === 'menu') {
|
|
return (
|
|
<>
|
|
<HeaderMenuMoreContents />
|
|
<Separator borderColor="$color02" opacity={0.25} my="$2" />
|
|
{isOnlyShowingMenu && (
|
|
<>
|
|
<ActivePageDocsMenuContents />
|
|
<Separator borderColor="$color02" opacity={0.25} my="$2" />
|
|
</>
|
|
)}
|
|
<SeasonChooser />
|
|
</>
|
|
)
|
|
}
|
|
|
|
if (props.id === 'theme') {
|
|
return (
|
|
<YStack flex={1} gap="$2" flexBasis="auto">
|
|
{!themeHistories.length ? (
|
|
<>
|
|
<PromoCardTheme />
|
|
<Paragraph
|
|
pointerEvents="none"
|
|
borderWidth={0.5}
|
|
bg="$color6"
|
|
rounded="$5"
|
|
fontFamily="$mono"
|
|
size="$4"
|
|
opacity={0.5}
|
|
p="$4"
|
|
>
|
|
Create themes to preview them across the site.
|
|
{`\n`}
|
|
<Link href="/theme" theme="blue" style={{ pointerEvents: 'auto' }}>
|
|
Go to Theme Builder →
|
|
</Link>
|
|
</Paragraph>
|
|
</>
|
|
) : (
|
|
<YStack gap="$2">
|
|
<XStack>
|
|
<HeadAnchor
|
|
grid
|
|
items="center"
|
|
onPress={() => {
|
|
bentoStore.disableCustomTheme = !bentoStore.disableCustomTheme
|
|
}}
|
|
>
|
|
<SizableText size="$3" color="$color11" ellipsis>
|
|
Enabled
|
|
</SizableText>
|
|
|
|
{bentoTheme.enabled ? <Check ml="$2" size={12} /> : null}
|
|
</HeadAnchor>
|
|
<HeadAnchor
|
|
grid
|
|
onPress={() => {
|
|
bentoStore.disableTint = !bentoStore.disableTint
|
|
}}
|
|
>
|
|
<SizableText size="$3" color="$color11" ellipsis>
|
|
Tint
|
|
</SizableText>
|
|
|
|
{!bentoStore.disableTint ? <Check ml="$2" size={12} /> : null}
|
|
</HeadAnchor>
|
|
</XStack>
|
|
|
|
<Separator mb="$3" opacity={0.5} />
|
|
|
|
<SizableText size="$3" fontFamily="$mono" px="$4" color="$color9">
|
|
Recent Themes
|
|
</SizableText>
|
|
|
|
{themeHistories.map((history) => (
|
|
<HeadAnchor
|
|
key={history.id}
|
|
grid
|
|
onPress={() => updateGenerate(history.theme_data)}
|
|
>
|
|
<XStack items="center" justify="space-between">
|
|
<SizableText size="$3" color="$color11" ellipsis>
|
|
{history.search_query}
|
|
</SizableText>
|
|
</XStack>
|
|
</HeadAnchor>
|
|
))}
|
|
|
|
{themeHistories.length === 0 && (
|
|
<YStack p="$4" items="center">
|
|
<SizableText size="$2" color="$color9">
|
|
{data?.user ? 'No theme history yet' : 'Login to save themes'}
|
|
</SizableText>
|
|
</YStack>
|
|
)}
|
|
</YStack>
|
|
)}
|
|
</YStack>
|
|
)
|
|
}
|
|
|
|
return <DocsMenuContents inMenu section={props.id} />
|
|
})()
|
|
|
|
// For mobile, render content directly without Frame wrapper
|
|
if (isMobile) {
|
|
return (
|
|
<YStack width="100%" p="$3">
|
|
{contents}
|
|
</YStack>
|
|
)
|
|
}
|
|
|
|
// For desktop, use Frame wrapper
|
|
return (
|
|
<Frame>
|
|
{/* BUG: when adapted to sheet this scrollview will get scroll events not the inner one */}
|
|
<Popover.ScrollView
|
|
showsVerticalScrollIndicator={false}
|
|
style={{
|
|
flex: 1,
|
|
width: '100%',
|
|
}}
|
|
contentContainerStyle={{ width: '100%' }}
|
|
>
|
|
<YStack width="100%" p="$3">
|
|
{contents}
|
|
</YStack>
|
|
</Popover.ScrollView>
|
|
</Frame>
|
|
)
|
|
}
|
|
|
|
const HeaderMenuMoreContents = () => {
|
|
const userSwr = useUser()
|
|
const router = useRouter()
|
|
const { handleLogin } = useLoginLink()
|
|
const context = React.useContext(SlidingPopoverContext)
|
|
|
|
const handlePress = (e: any) => {
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
router.push(e.target.href)
|
|
}
|
|
|
|
return (
|
|
<YStack gap="$2" aria-label="Home menu contents">
|
|
<YStack gap="$2" $gtSm={{ display: 'none' }}>
|
|
<Link asChild href="/">
|
|
<HeadAnchor grid>Home</HeadAnchor>
|
|
</Link>
|
|
<Separator bg="$color02" opacity={0.25} my="$2" />
|
|
</YStack>
|
|
|
|
<XStack flex={1} flexBasis="auto" flexWrap="wrap" gap="$2" width="100%">
|
|
<Link asChild href="/docs/intro/introduction">
|
|
<HeadAnchor grid half>
|
|
Core
|
|
</HeadAnchor>
|
|
</Link>
|
|
|
|
<Link asChild href="/docs/intro/compiler-install" onPress={handlePress}>
|
|
<HeadAnchor grid half>
|
|
Compiler
|
|
</HeadAnchor>
|
|
</Link>
|
|
|
|
<Link asChild href="/ui/intro" onPress={handlePress}>
|
|
<HeadAnchor grid half>
|
|
UI
|
|
</HeadAnchor>
|
|
</Link>
|
|
|
|
<Link asChild href="/theme" onPress={handlePress}>
|
|
<HeadAnchor grid half>
|
|
Theme
|
|
</HeadAnchor>
|
|
</Link>
|
|
</XStack>
|
|
|
|
<Separator bg="$color02" opacity={0.25} my="$2" />
|
|
|
|
{!userSwr.data?.user && (
|
|
<HeadAnchor grid onPress={handleLogin}>
|
|
<span>Login</span>
|
|
<YStack display={'inline-block' as any} y={2} x={10} self="flex-end">
|
|
<LogIn color="$color10" size={14} />
|
|
</YStack>
|
|
</HeadAnchor>
|
|
)}
|
|
|
|
{userSwr.data?.user && (
|
|
<HeadAnchor
|
|
grid
|
|
onPress={() => {
|
|
context.close()
|
|
accountModal.show = true
|
|
}}
|
|
>
|
|
<XStack items="center" justify="center">
|
|
<span>Account</span>
|
|
<YStack flex={10} />
|
|
<YStack display={'inline-block' as any} y={-2} my={-3} self="flex-end">
|
|
<UserAvatar size={22} />
|
|
</YStack>
|
|
</XStack>
|
|
</HeadAnchor>
|
|
)}
|
|
|
|
<Separator bg="$color02" opacity={0.25} my="$2" />
|
|
|
|
<Link asChild href="/takeout">
|
|
<HeadAnchor grid render="a">
|
|
<XStack items="center">
|
|
<span>Takeout</span>
|
|
<YStack display={'inline-block' as any} x={6} my={-20} opacity={0.8}>
|
|
<TakeoutIcon scale={0.65} />
|
|
</YStack>
|
|
</XStack>
|
|
<SizableText size="$2" color="$color9">
|
|
Starter Kit
|
|
</SizableText>
|
|
</HeadAnchor>
|
|
</Link>
|
|
|
|
<Link asChild href="/bento">
|
|
<HeadAnchor grid render="a">
|
|
<XStack items="center">
|
|
<span>Bento</span>
|
|
<YStack
|
|
ml={3}
|
|
display={'inline-block' as any}
|
|
x={6}
|
|
y={-1}
|
|
my={-10}
|
|
opacity={0.8}
|
|
>
|
|
<BentoIcon scale={0.65} />
|
|
</YStack>
|
|
</XStack>
|
|
<SizableText size="$2" color="$color9">
|
|
Copy-paste UI
|
|
</SizableText>
|
|
</HeadAnchor>
|
|
</Link>
|
|
|
|
<Link asChild href="https://addeven.com" target="_blank">
|
|
<HeadAnchor grid render="a">
|
|
<XStack items="center">
|
|
<SizableText>Add Even</SizableText>
|
|
<YStack
|
|
ml={3}
|
|
display={'inline-block' as any}
|
|
x={6}
|
|
y={-1}
|
|
my={-10}
|
|
opacity={0.8}
|
|
>
|
|
<AddEvenBrandIcon scale={0.65} />
|
|
</YStack>
|
|
</XStack>
|
|
<SizableText size="$2" color="$color9">
|
|
Expert Consulting
|
|
</SizableText>
|
|
</HeadAnchor>
|
|
</Link>
|
|
|
|
<Separator bg="$color02" opacity={0.25} my="$2" />
|
|
|
|
<Link asChild href="/community">
|
|
<HeadAnchor grid render="a">
|
|
Community
|
|
</HeadAnchor>
|
|
</Link>
|
|
|
|
<Separator borderColor="$color02" opacity={0.25} my="$2" />
|
|
|
|
<Link asChild href="https://github.com/hanzoai/gui">
|
|
<HeadAnchor target="_blank" grid>
|
|
Github{' '}
|
|
<YStack display={'inline-block' as any} y={10} my={-20} opacity={0.8}>
|
|
<GithubIcon width={14} />
|
|
</YStack>
|
|
</HeadAnchor>
|
|
</Link>
|
|
|
|
<Link
|
|
asChild
|
|
href="https://www.figma.com/community/file/1326593766534421119/hanzogui-v1-2-1"
|
|
>
|
|
<HeadAnchor target="_blank" grid>
|
|
Figma{' '}
|
|
<YStack display={'inline-block' as any} y={2} my={-20} opacity={0.8}>
|
|
<Figma size={14} />
|
|
</YStack>
|
|
</HeadAnchor>
|
|
</Link>
|
|
|
|
<Link asChild href="/blog">
|
|
<HeadAnchor grid>Blog</HeadAnchor>
|
|
</Link>
|
|
|
|
<Link asChild href="https://github.com/sponsors/hanzoai">
|
|
<HeadAnchor grid target="_blank">
|
|
Sponsor
|
|
<YStack display={'inline-block' as any} y={0} my={-20} ml={12} opacity={0.8}>
|
|
<ExternalLink size={10} opacity={0.5} />
|
|
</YStack>
|
|
</HeadAnchor>
|
|
</Link>
|
|
</YStack>
|
|
)
|
|
}
|
|
|
|
const SeasonChooser = () => {
|
|
const { name } = useTint()
|
|
|
|
return (
|
|
<XStack flexWrap="wrap" items="center" justify="center">
|
|
{Object.keys(seasons).map((seasonName) => {
|
|
const isActive = name === seasonName
|
|
return (
|
|
<Circle
|
|
key={seasonName}
|
|
size="$4"
|
|
cursor="pointer"
|
|
items="center"
|
|
justify="center"
|
|
hoverStyle={{
|
|
bg: '$backgroundHover',
|
|
}}
|
|
pressStyle={{
|
|
bg: '$backgroundPress',
|
|
}}
|
|
{...(isActive && {
|
|
bg: '$color5',
|
|
hoverStyle: {
|
|
bg: '$color5',
|
|
},
|
|
})}
|
|
onPress={() => {
|
|
setTintFamily(seasonName as any)
|
|
}}
|
|
>
|
|
<SizableText size="$5">{seasons[seasonName]}</SizableText>
|
|
</Circle>
|
|
)
|
|
})}
|
|
</XStack>
|
|
)
|
|
}
|
|
|
|
const HeadAnchor = styled(Paragraph, {
|
|
render: 'a',
|
|
fontFamily: '$mono',
|
|
px: '$4',
|
|
py: '$3',
|
|
cursor: 'pointer',
|
|
fontSize: 18,
|
|
color: '$color11',
|
|
|
|
hoverStyle: {
|
|
color: '$color',
|
|
rounded: '$3',
|
|
},
|
|
|
|
focusVisibleStyle: {
|
|
outlineColor: '$outlineColor',
|
|
outlineWidth: 2,
|
|
outlineStyle: 'solid',
|
|
outlineOffset: -2,
|
|
},
|
|
|
|
pressStyle: {
|
|
opacity: 0.25,
|
|
},
|
|
|
|
variants: {
|
|
active: {
|
|
true: {
|
|
color: '$color12',
|
|
},
|
|
},
|
|
|
|
grid: {
|
|
true: {
|
|
fontWeight: '200',
|
|
letterSpacing: 1,
|
|
width: '100%',
|
|
flex: 1,
|
|
flexBasis: 'auto',
|
|
p: '$2',
|
|
px: '$4',
|
|
|
|
hoverStyle: {
|
|
backgroundColor:
|
|
'color-mix(in srgb, var(--color8) 10%, transparent 50%)' as any,
|
|
},
|
|
},
|
|
},
|
|
|
|
half: {
|
|
true: {
|
|
maxWidth: '48.5%',
|
|
overflow: 'hidden',
|
|
},
|
|
},
|
|
} as const,
|
|
})
|
|
|
|
const Frame = styled(YStack, {
|
|
className: 'header-popover-frame',
|
|
transition: '300ms',
|
|
flex: 1,
|
|
rounded: '$5',
|
|
overflow: 'hidden',
|
|
position: 'absolute',
|
|
t: 0,
|
|
l: 0,
|
|
r: 0,
|
|
b: 0,
|
|
z: 1,
|
|
x: 0,
|
|
opacity: 1,
|
|
|
|
variants: {
|
|
// 1 = right, 0 = nowhere, -1 = left
|
|
going: {
|
|
':number': (going) => ({
|
|
enterStyle: {
|
|
x: going > 0 ? 50 : -50,
|
|
opacity: 0,
|
|
},
|
|
exitStyle: {
|
|
zIndex: 0,
|
|
x: going < 0 ? 50 : -50,
|
|
opacity: 0,
|
|
},
|
|
}),
|
|
},
|
|
} as const,
|
|
})
|