1+ import { useEffect , useState } from "react"
12import { Link } from "react-router-dom"
23
34import { DocsProvider } from "./docs/context"
5+ import { useToasts } from "../hooks/useToasts.jsx"
46
57// One file per <section>. Each component is self-contained — sections that
68// need shared state (the OS-tabs choice, the Copy-button toast) pull it from
@@ -27,7 +29,133 @@ import ApiRateLimits from "./docs/ApiRateLimits"
2729import Resources from "./docs/Resources"
2830
2931
30- function DocsSidebar ( ) {
32+ // Order matches the sidebar order — drives the scrollspy's "topmost
33+ // visible section" calculation. Each entry is the section's id.
34+ const DOC_SECTIONS = [
35+ "getting-started" ,
36+ "architecture" ,
37+ "cloudnode-setup" ,
38+ "configuration" ,
39+ "deployment" ,
40+ "motion-detection" ,
41+ "terminal-dashboard" ,
42+ "dashboard" ,
43+ "recording" ,
44+ "camera-groups" ,
45+ "notifications" ,
46+ "mcp" ,
47+ "sentinel" ,
48+ "plans" ,
49+ "security-procedures" ,
50+ "troubleshooting" ,
51+ "faq" ,
52+ "api-reference" ,
53+ "api-rate-limits" ,
54+ ]
55+
56+
57+ /**
58+ * Highlights the sidebar link matching whichever section is currently
59+ * topmost-visible in the viewport. Uses a single IntersectionObserver
60+ * scoped to the section IDs in DOC_SECTIONS.
61+ *
62+ * The "active" section is the one with the smallest non-negative
63+ * `boundingClientRect.top` — i.e. the one that just entered (or is
64+ * about to enter) the top of the viewport. Falls back to the
65+ * highest-positioned section among the currently-intersecting set
66+ * when nothing has entered yet (e.g. when the page loads scrolled
67+ * mid-document).
68+ */
69+ function useScrollspy ( sectionIds ) {
70+ const [ activeId , setActiveId ] = useState ( sectionIds [ 0 ] || "" )
71+
72+ useEffect ( ( ) => {
73+ if ( typeof IntersectionObserver === "undefined" ) return
74+
75+ const visible = new Map ( )
76+
77+ const recompute = ( ) => {
78+ // Pick the section whose top is closest to (but not far below)
79+ // the viewport top. Sections above the fold have negative top
80+ // values; we want the largest of those (closest to zero).
81+ let bestId = null
82+ let bestTop = - Infinity
83+ for ( const [ id , top ] of visible ) {
84+ if ( top <= 80 && top > bestTop ) {
85+ bestTop = top
86+ bestId = id
87+ }
88+ }
89+ // If nothing is above the fold (we're at the top of the page),
90+ // pick the topmost intersecting section.
91+ if ( ! bestId ) {
92+ let lowestTop = Infinity
93+ for ( const [ id , top ] of visible ) {
94+ if ( top < lowestTop ) {
95+ lowestTop = top
96+ bestId = id
97+ }
98+ }
99+ }
100+ if ( bestId ) setActiveId ( bestId )
101+ }
102+
103+ const observer = new IntersectionObserver (
104+ ( entries ) => {
105+ for ( const entry of entries ) {
106+ const id = entry . target . id
107+ if ( entry . isIntersecting ) {
108+ visible . set ( id , entry . boundingClientRect . top )
109+ } else {
110+ visible . delete ( id )
111+ }
112+ }
113+ recompute ( )
114+ } ,
115+ {
116+ // rootMargin top -80px so a section is considered "active" once
117+ // its heading scrolls under the page's sticky chrome (the docs
118+ // page has no sticky bar today, but this leaves headroom for
119+ // future "back to top" / breadcrumb additions).
120+ rootMargin : "-80px 0px -60% 0px" ,
121+ threshold : [ 0 , 1 ] ,
122+ } ,
123+ )
124+
125+ const elements = sectionIds
126+ . map ( ( id ) => document . getElementById ( id ) )
127+ . filter ( Boolean )
128+ elements . forEach ( ( el ) => observer . observe ( el ) )
129+
130+ // Recompute on plain scroll too — IntersectionObserver only fires on
131+ // boundary crossings, so a slow scroll between two intersecting
132+ // sections wouldn't update the active state otherwise.
133+ const onScroll = ( ) => {
134+ for ( const el of elements ) {
135+ const rect = el . getBoundingClientRect ( )
136+ visible . set ( el . id , rect . top )
137+ }
138+ recompute ( )
139+ }
140+ window . addEventListener ( "scroll" , onScroll , { passive : true } )
141+
142+ return ( ) => {
143+ observer . disconnect ( )
144+ window . removeEventListener ( "scroll" , onScroll )
145+ }
146+ } , [ sectionIds ] )
147+
148+ return activeId
149+ }
150+
151+
152+ function DocsSidebar ( { activeId } ) {
153+ // Render each link with an active class when its href matches the
154+ // currently-spied section. className is computed at render time so
155+ // React doesn't have to re-mount the <a>s on every active change.
156+ const linkClass = ( id ) =>
157+ `docs-sidebar-link${ activeId === id ? " active" : "" } `
158+
31159 return (
32160 < aside className = "docs-sidebar" >
33161 < div className = "docs-sidebar-header" >
@@ -37,46 +165,46 @@ function DocsSidebar() {
37165 < nav className = "docs-sidebar-nav" >
38166 < div className = "docs-sidebar-group" >
39167 < div className = "docs-sidebar-group-label" > Introduction</ div >
40- < a href = "#getting-started" className = "docs-sidebar-link" > Getting Started</ a >
41- < a href = "#architecture" className = "docs-sidebar-link" > Architecture</ a >
168+ < a href = "#getting-started" className = { linkClass ( "getting-started" ) } > Getting Started</ a >
169+ < a href = "#architecture" className = { linkClass ( "architecture" ) } > Architecture</ a >
42170 </ div >
43171 < div className = "docs-sidebar-group" >
44172 < div className = "docs-sidebar-group-label" > CloudNode</ div >
45- < a href = "#cloudnode-setup" className = "docs-sidebar-link" > Setup</ a >
46- < a href = "#configuration" className = "docs-sidebar-link" > Configuration</ a >
47- < a href = "#deployment" className = "docs-sidebar-link" > Deployment</ a >
48- < a href = "#motion-detection" className = "docs-sidebar-link" > Motion Detection</ a >
49- < a href = "#terminal-dashboard" className = "docs-sidebar-link" > Terminal Dashboard</ a >
173+ < a href = "#cloudnode-setup" className = { linkClass ( "cloudnode-setup" ) } > Setup</ a >
174+ < a href = "#configuration" className = { linkClass ( "configuration" ) } > Configuration</ a >
175+ < a href = "#deployment" className = { linkClass ( "deployment" ) } > Deployment</ a >
176+ < a href = "#motion-detection" className = { linkClass ( "motion-detection" ) } > Motion Detection</ a >
177+ < a href = "#terminal-dashboard" className = { linkClass ( "terminal-dashboard" ) } > Terminal Dashboard</ a >
50178 </ div >
51179 < div className = "docs-sidebar-group" >
52180 < div className = "docs-sidebar-group-label" > Command Center</ div >
53- < a href = "#dashboard" className = "docs-sidebar-link" > Dashboard & Features </ a >
54- < a href = "#recording" className = "docs-sidebar-link" > Recording & Retention </ a >
55- < a href = "#camera-groups" className = "docs-sidebar-link" > Camera Groups</ a >
56- < a href = "#notifications" className = "docs-sidebar-link" > Notifications</ a >
181+ < a href = "#dashboard" className = { linkClass ( "dashboard" ) } > Dashboard & Features </ a >
182+ < a href = "#recording" className = { linkClass ( "recording" ) } > Recording & Retention </ a >
183+ < a href = "#camera-groups" className = { linkClass ( "camera-groups" ) } > Camera Groups</ a >
184+ < a href = "#notifications" className = { linkClass ( "notifications" ) } > Notifications</ a >
57185 </ div >
58186 < div className = "docs-sidebar-group" >
59187 < div className = "docs-sidebar-group-label" > Integrations</ div >
60- < a href = "#mcp" className = "docs-sidebar-link" > MCP Integration</ a >
188+ < a href = "#mcp" className = { linkClass ( "mcp" ) } > MCP Integration</ a >
61189 </ div >
62190 < div className = "docs-sidebar-group" >
63191 < div className = "docs-sidebar-group-label" > AI Agent</ div >
64- < a href = "#sentinel" className = "docs-sidebar-link" > Sentinel</ a >
192+ < a href = "#sentinel" className = { linkClass ( "sentinel" ) } > Sentinel</ a >
65193 </ div >
66194 < div className = "docs-sidebar-group" >
67195 < div className = "docs-sidebar-group-label" > Account & Security </ div >
68- < a href = "#plans" className = "docs-sidebar-link" > Plans & Limits </ a >
69- < a href = "#security-procedures" className = "docs-sidebar-link" > Security Procedures</ a >
196+ < a href = "#plans" className = { linkClass ( "plans" ) } > Plans & Limits </ a >
197+ < a href = "#security-procedures" className = { linkClass ( "security-procedures" ) } > Security Procedures</ a >
70198 </ div >
71199 < div className = "docs-sidebar-group" >
72200 < div className = "docs-sidebar-group-label" > Help</ div >
73- < a href = "#troubleshooting" className = "docs-sidebar-link" > Troubleshooting</ a >
74- < a href = "#faq" className = "docs-sidebar-link" > FAQ</ a >
201+ < a href = "#troubleshooting" className = { linkClass ( "troubleshooting" ) } > Troubleshooting</ a >
202+ < a href = "#faq" className = { linkClass ( "faq" ) } > FAQ</ a >
75203 </ div >
76204 < div className = "docs-sidebar-group" >
77205 < div className = "docs-sidebar-group-label" > Reference</ div >
78- < a href = "#api-reference" className = "docs-sidebar-link" > API Reference</ a >
79- < a href = "#api-rate-limits" className = "docs-sidebar-link" > API Rate Limits</ a >
206+ < a href = "#api-reference" className = { linkClass ( "api-reference" ) } > API Reference</ a >
207+ < a href = "#api-rate-limits" className = { linkClass ( "api-rate-limits" ) } > API Rate Limits</ a >
80208 </ div >
81209 </ nav >
82210 < div className = "docs-sidebar-footer" >
@@ -89,11 +217,95 @@ function DocsSidebar() {
89217}
90218
91219
220+ /**
221+ * Floating Back-to-top button. Fades in once the page is scrolled
222+ * past ~600px; smooth-scrolls back on click. Position is fixed
223+ * bottom-right with a generous mobile-safe inset. Single instance
224+ * mounted by DocsPage — not shared across the app, since other pages
225+ * are short enough not to need it.
226+ */
227+ function BackToTopButton ( ) {
228+ const [ visible , setVisible ] = useState ( false )
229+
230+ useEffect ( ( ) => {
231+ const onScroll = ( ) => setVisible ( window . scrollY > 600 )
232+ onScroll ( )
233+ window . addEventListener ( "scroll" , onScroll , { passive : true } )
234+ return ( ) => window . removeEventListener ( "scroll" , onScroll )
235+ } , [ ] )
236+
237+ return (
238+ < button
239+ type = "button"
240+ className = { `docs-back-to-top${ visible ? " visible" : "" } ` }
241+ onClick = { ( ) => window . scrollTo ( { top : 0 , behavior : "smooth" } ) }
242+ aria-label = "Back to top"
243+ title = "Back to top"
244+ >
245+ < svg width = "18" height = "18" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" strokeWidth = "2.5" strokeLinecap = "round" strokeLinejoin = "round" aria-hidden = "true" >
246+ < line x1 = "12" y1 = "19" x2 = "12" y2 = "5" />
247+ < polyline points = "5 12 12 5 19 12" />
248+ </ svg >
249+ </ button >
250+ )
251+ }
252+
253+
92254function DocsPage ( ) {
255+ const { showToast } = useToasts ( )
256+ const activeId = useScrollspy ( DOC_SECTIONS )
257+
258+ // Delegated click handler for any `.docs-anchor` link inside a docs
259+ // heading. Three things on click:
260+ // 1. Copy the full URL+hash to the clipboard.
261+ // 2. Update the URL bar via history.pushState (so the link IS
262+ // shareable from the address bar too).
263+ // 3. Smooth-scroll the target heading into view.
264+ // Single handler bound to the document instead of touching 19
265+ // individual section files.
266+ useEffect ( ( ) => {
267+ const handler = ( event ) => {
268+ const anchor = event . target . closest ( ".docs-anchor" )
269+ if ( ! anchor ) return
270+ event . preventDefault ( )
271+ const href = anchor . getAttribute ( "href" ) || ""
272+ const targetId = href . startsWith ( "#" ) ? href . slice ( 1 ) : ""
273+ const fullUrl = `${ window . location . origin } ${ window . location . pathname } ${ href } `
274+
275+ // Update history first so the URL bar shows the deep-link. We
276+ // use pushState rather than assigning location.hash because the
277+ // latter triggers a default scroll-jump that competes with our
278+ // smooth-scroll.
279+ window . history . pushState ( null , "" , href )
280+
281+ // Smooth-scroll to the target. Falls back silently if the
282+ // element doesn't exist (shouldn't happen — every `.docs-anchor`
283+ // we render points at its parent section's id).
284+ if ( targetId ) {
285+ const target = document . getElementById ( targetId )
286+ if ( target ) target . scrollIntoView ( { behavior : "smooth" , block : "start" } )
287+ }
288+
289+ // Copy to clipboard + toast. Clipboard API requires a secure
290+ // context; if we don't have it (rare — local file:// preview),
291+ // we silently skip the copy and rely on the URL bar update.
292+ if ( navigator . clipboard ?. writeText ) {
293+ navigator . clipboard . writeText ( fullUrl ) . then (
294+ ( ) => showToast ( "Link copied to clipboard" , "success" ) ,
295+ ( ) => showToast ( "Couldn't copy — clipboard blocked" , "error" ) ,
296+ )
297+ } else {
298+ showToast ( "Link in URL bar — copy manually" , "info" )
299+ }
300+ }
301+ document . addEventListener ( "click" , handler )
302+ return ( ) => document . removeEventListener ( "click" , handler )
303+ } , [ showToast ] )
304+
93305 return (
94306 < DocsProvider >
95307 < div className = "docs-layout" >
96- < DocsSidebar />
308+ < DocsSidebar activeId = { activeId } />
97309 < main className = "docs-content" >
98310 < div className = "docs-content-inner" >
99311 < div className = "docs-hero-banner" aria-hidden = "true" >
@@ -142,6 +354,7 @@ function DocsPage() {
142354 </ div >
143355 </ div >
144356 </ main >
357+ < BackToTopButton />
145358 </ div >
146359 </ DocsProvider >
147360 )
0 commit comments