mirror of
https://github.com/hanzoai/gui.git
synced 2026-10-25 02:31:41 +00:00
Supabase auth, Stripe, purchase modals, Bento and Takeout promotion, the consulting link and the vendor's talk images were the upstream business, not documentation. The header, home page, theme page and quick nav lose the account and promotion surfaces; the content loses the paid gating.
358 lines
11 KiB
TypeScript
358 lines
11 KiB
TypeScript
import { href, usePathname } from 'one'
|
|
import { useEffect, useRef, useState } from 'react'
|
|
import {
|
|
H4,
|
|
Paragraph,
|
|
ScrollView,
|
|
Separator,
|
|
SizableText,
|
|
Theme,
|
|
XStack,
|
|
YStack,
|
|
} from '@hanzo/gui'
|
|
|
|
import { Link } from '~/components/Link'
|
|
|
|
export type Heading = {
|
|
id: string
|
|
title: string
|
|
priority: number
|
|
}
|
|
|
|
// SVG tree line indicator component with path-based animation
|
|
const NavLineIndicator = ({
|
|
items,
|
|
activeIndex,
|
|
totalHeight,
|
|
}: {
|
|
items: Array<{ top: number; height: number; level: number }>
|
|
activeIndex: number
|
|
totalHeight: number
|
|
}) => {
|
|
if (items.length === 0 || totalHeight === 0) return null
|
|
|
|
const levelIndent = 12
|
|
const baseX = 8
|
|
const segmentHalf = 6 // Half of the indicator segment length
|
|
|
|
const getX = (level: number) => baseX + Math.max(0, level - 2) * levelIndent
|
|
const getY = (item: { top: number; height: number }) => item.top + item.height / 2
|
|
|
|
// Build a continuous path and calculate distances to each item center
|
|
const buildPathAndDistances = () => {
|
|
const pathParts: string[] = []
|
|
const itemDistances: number[] = []
|
|
let totalDist = 0
|
|
|
|
for (let i = 0; i < items.length; i++) {
|
|
const item = items[i]
|
|
const x = getX(item.level)
|
|
const y = getY(item)
|
|
const prevItem = items[i - 1]
|
|
|
|
if (i === 0) {
|
|
// Start path at top of first item's segment
|
|
pathParts.push(`M ${x} ${y - segmentHalf}`)
|
|
// Distance to center of first item
|
|
itemDistances.push(segmentHalf)
|
|
totalDist = segmentHalf
|
|
} else {
|
|
const prevX = getX(prevItem.level)
|
|
const prevY = getY(prevItem)
|
|
|
|
if (item.level !== prevItem.level) {
|
|
// Different level - draw diagonal connector
|
|
const connectorStartY = prevY + segmentHalf
|
|
const connectorEndY = y - segmentHalf
|
|
|
|
// Line to end of previous item's segment
|
|
pathParts.push(`L ${prevX} ${connectorStartY}`)
|
|
totalDist += segmentHalf // from prev center to connector start
|
|
|
|
// Diagonal connector
|
|
pathParts.push(`L ${x} ${connectorEndY}`)
|
|
const dx = x - prevX
|
|
const dy = connectorEndY - connectorStartY
|
|
const connectorLength = Math.sqrt(dx * dx + dy * dy)
|
|
totalDist += connectorLength
|
|
|
|
// Distance to center of this item (from connector end)
|
|
totalDist += segmentHalf
|
|
itemDistances.push(totalDist)
|
|
} else {
|
|
// Same level - continuous vertical line
|
|
// Distance from prev center to this center
|
|
totalDist += Math.abs(y - prevY)
|
|
itemDistances.push(totalDist)
|
|
}
|
|
}
|
|
|
|
// Draw to bottom of this item's segment (for last item or before connector)
|
|
const nextItem = items[i + 1]
|
|
if (!nextItem || nextItem.level !== item.level) {
|
|
pathParts.push(`L ${x} ${y + segmentHalf}`)
|
|
}
|
|
}
|
|
|
|
// Calculate total path length (add remaining distance after last item center)
|
|
const totalLength = totalDist + segmentHalf
|
|
|
|
return {
|
|
path: pathParts.join(' '),
|
|
itemDistances,
|
|
totalLength,
|
|
}
|
|
}
|
|
|
|
const { path, itemDistances, totalLength } = buildPathAndDistances()
|
|
const activeDistance = itemDistances[activeIndex] || 0
|
|
|
|
return (
|
|
<svg
|
|
style={{
|
|
position: 'absolute',
|
|
left: 0,
|
|
top: 0,
|
|
width: 60,
|
|
height: totalHeight,
|
|
overflow: 'visible',
|
|
pointerEvents: 'none',
|
|
}}
|
|
>
|
|
{/* Background path (gray) */}
|
|
<path d={path} fill="none" stroke="var(--color4)" strokeWidth="1" />
|
|
|
|
{/* Active indicator (animated along path) */}
|
|
<path
|
|
d={path}
|
|
fill="none"
|
|
stroke="var(--color9)"
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
strokeDasharray={`${segmentHalf * 2} ${totalLength}`}
|
|
strokeDashoffset={-(activeDistance - segmentHalf)}
|
|
style={{
|
|
transition: 'stroke-dashoffset 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
|
}}
|
|
/>
|
|
</svg>
|
|
)
|
|
}
|
|
|
|
export function DocsQuickNav({ headings = [] }: { headings?: Heading[] }) {
|
|
const [activeIndex, setActiveIndex] = useState(0)
|
|
const [itemData, setItemData] = useState<
|
|
Array<{ top: number; height: number; level: number }>
|
|
>([])
|
|
const [containerHeight, setContainerHeight] = useState(0)
|
|
const containerRef = useRef<HTMLDivElement>(null)
|
|
const pathname = usePathname()
|
|
|
|
// Track all currently intersecting headings to pick the best one
|
|
const intersectingHeadings = useRef<Set<string>>(new Set())
|
|
|
|
// Reset active index on route change
|
|
useEffect(() => {
|
|
setActiveIndex(0)
|
|
setItemData([])
|
|
intersectingHeadings.current.clear()
|
|
}, [pathname])
|
|
|
|
// Track active heading with Intersection Observer
|
|
useEffect(() => {
|
|
if (headings.length === 0) return
|
|
|
|
const observer = new IntersectionObserver(
|
|
(entries) => {
|
|
entries.forEach((entry) => {
|
|
if (entry.isIntersecting) {
|
|
intersectingHeadings.current.add(entry.target.id)
|
|
} else {
|
|
intersectingHeadings.current.delete(entry.target.id)
|
|
}
|
|
})
|
|
|
|
// Pick the heading that has most recently passed the "reading line"
|
|
// (the highest index among all headings currently above the 70% mark)
|
|
if (intersectingHeadings.current.size > 0) {
|
|
let maxIndex = -1
|
|
intersectingHeadings.current.forEach((id) => {
|
|
const index = headings.findIndex((h) => h.id === id)
|
|
if (index > maxIndex) {
|
|
maxIndex = index
|
|
}
|
|
})
|
|
if (maxIndex !== -1) {
|
|
setActiveIndex(maxIndex)
|
|
}
|
|
} else if (typeof window !== 'undefined' && window.scrollY < 100) {
|
|
setActiveIndex(0)
|
|
}
|
|
},
|
|
{
|
|
// rootMargin: [top] [right] [bottom] [left]
|
|
// 100% top margin ensures headings stay in the 'set' even after scrolling past them
|
|
// -30% bottom margin puts the "reading line" 70% down the screen
|
|
rootMargin: '100% 0px -30% 0px',
|
|
threshold: 0,
|
|
}
|
|
)
|
|
|
|
// Observe heading elements by ID
|
|
headings.forEach(({ id }) => {
|
|
const el = document.getElementById(id)
|
|
if (el) observer.observe(el)
|
|
})
|
|
|
|
return () => observer.disconnect()
|
|
}, [headings])
|
|
|
|
// Measure container and item positions with levels
|
|
useEffect(() => {
|
|
if (!containerRef.current || headings.length === 0) return
|
|
|
|
const measurePositions = () => {
|
|
const container = containerRef.current
|
|
if (!container) return
|
|
|
|
const items = container.querySelectorAll('[data-nav-item]')
|
|
const data: Array<{ top: number; height: number; level: number }> = []
|
|
|
|
items.forEach((item, index) => {
|
|
const rect = item.getBoundingClientRect()
|
|
const containerRect = container.getBoundingClientRect()
|
|
const level = headings[index]?.priority ?? 2
|
|
data.push({
|
|
top: rect.top - containerRect.top,
|
|
height: rect.height,
|
|
level,
|
|
})
|
|
})
|
|
|
|
setItemData(data)
|
|
setContainerHeight(container.scrollHeight)
|
|
}
|
|
|
|
// Measure after render
|
|
requestAnimationFrame(measurePositions)
|
|
|
|
// Re-measure on resize
|
|
window.addEventListener('resize', measurePositions)
|
|
return () => window.removeEventListener('resize', measurePositions)
|
|
}, [headings])
|
|
|
|
return (
|
|
<YStack
|
|
render="aside"
|
|
className="is-sticky"
|
|
display="none"
|
|
$gtLg={{
|
|
display: 'flex',
|
|
width: 280,
|
|
z: 1,
|
|
position: 'sticky',
|
|
t: 20,
|
|
height: 'calc(100vh - 20px)',
|
|
alignSelf: 'flex-start',
|
|
shrink: 0,
|
|
}}
|
|
>
|
|
<ScrollView showsVerticalScrollIndicator={false}>
|
|
<YStack gap="$5" pt={68} pb="$10">
|
|
<XStack items="center" gap="$5">
|
|
<Link
|
|
target="_blank"
|
|
href={href(`${process.env.ONE_SERVER_URL}${pathname}.md` as any)}
|
|
>
|
|
<SizableText size="$3" fontFamily="$mono">
|
|
.md
|
|
</SizableText>
|
|
</Link>
|
|
|
|
<Separator minH={20} vertical />
|
|
|
|
<Link
|
|
target="_blank"
|
|
href={href(`${process.env.ONE_SERVER_URL}/llms.txt` as any)}
|
|
>
|
|
<SizableText size="$3" fontFamily="$mono">
|
|
llms.txt
|
|
</SizableText>
|
|
</Link>
|
|
</XStack>
|
|
|
|
<Separator opacity={0.5} mr="$6" />
|
|
|
|
<YStack
|
|
render="nav"
|
|
aria-labelledby="site-quick-nav-heading"
|
|
mb="$10"
|
|
mt="$2"
|
|
display={headings.length === 0 ? 'none' : 'flex'}
|
|
gap="$2"
|
|
>
|
|
<H4
|
|
fontFamily="$mono"
|
|
size="$5"
|
|
mb="$2"
|
|
color="$color10"
|
|
id="site-quick-nav-heading"
|
|
>
|
|
Contents
|
|
</H4>
|
|
|
|
<ScrollView maxH="calc(100vh - 300px)">
|
|
<YStack ref={containerRef as any} py="$2" pl={24} position="relative">
|
|
<NavLineIndicator
|
|
items={itemData}
|
|
activeIndex={activeIndex}
|
|
totalHeight={containerHeight}
|
|
/>
|
|
|
|
{headings.map(({ id, title, priority }, index) => {
|
|
const level = priority
|
|
|
|
return (
|
|
<XStack
|
|
key={`${id}-${index}`}
|
|
data-nav-item
|
|
pl={Math.max(0, level - 2) * 12}
|
|
py="$1"
|
|
>
|
|
<a
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
setActiveIndex(index)
|
|
}}
|
|
href={`#${id}`}
|
|
style={{ textDecoration: 'none' }}
|
|
>
|
|
<Paragraph
|
|
render="span"
|
|
size={level === 2 ? '$3' : '$2'}
|
|
color={
|
|
index === activeIndex
|
|
? '$color12'
|
|
: level === 2
|
|
? '$color11'
|
|
: '$color10'
|
|
}
|
|
cursor="pointer"
|
|
fontWeight={level === 2 ? '500' : '400'}
|
|
hoverStyle={{ color: '$color12' }}
|
|
>
|
|
{title}
|
|
</Paragraph>
|
|
</a>
|
|
</XStack>
|
|
)
|
|
})}
|
|
</YStack>
|
|
</ScrollView>
|
|
</YStack>
|
|
</YStack>
|
|
</ScrollView>
|
|
</YStack>
|
|
)
|
|
}
|