diff --git a/packages/chronicle/src/lib/page-context.tsx b/packages/chronicle/src/lib/page-context.tsx index a5069afb..e075974e 100644 --- a/packages/chronicle/src/lib/page-context.tsx +++ b/packages/chronicle/src/lib/page-context.tsx @@ -1,8 +1,10 @@ import { createContext, type ReactNode, + useCallback, useContext, useEffect, + useRef, useState } from 'react'; import { useLocation } from 'react-router'; @@ -10,7 +12,7 @@ import type { ApiSpec } from '@/lib/openapi'; import { resolveRoute, RouteType } from '@/lib/route-resolver'; import type { VersionContext } from '@/lib/version-source'; import { LATEST_CONTEXT } from '@/lib/version-source'; -import type { ChronicleConfig, Frontmatter, Page, Root, TableOfContents } from '@/types'; +import type { ChronicleConfig, Frontmatter, Page, PageNavLink, Root, TableOfContents } from '@/types'; export type MdxLoader = (relativePath: string) => Promise<{ content: ReactNode; toc: TableOfContents }>; @@ -18,6 +20,7 @@ interface PageContextValue { config: ChronicleConfig; tree: Root; page: Page | null; + isLoading: boolean; errorStatus: number | null; apiSpecs: ApiSpec[]; version: VersionContext; @@ -36,6 +39,7 @@ export function usePageContext(): PageContextValue { }, tree: { name: 'root', children: [] } as Root, page: null, + isLoading: false, errorStatus: null, apiSpecs: [], version: LATEST_CONTEXT, @@ -82,11 +86,71 @@ export function PageProvider({ const [errorStatus, setErrorStatus] = useState(getInitialErrorStatus(initialPage, initialConfig, pathname)); const [apiSpecs, setApiSpecs] = useState(initialApiSpecs); const [version, setVersion] = useState(initialVersion); - const [currentPath, setCurrentPath] = useState(pathname); + const [isLoading, setIsLoading] = useState(false); + const currentPathRef = useRef(pathname); + + const fetchApiSpecs = useCallback(async (route: { version: VersionContext }, cancelled: { current: boolean }) => { + setIsLoading(true); + try { + const specsUrl = route.version.dir + ? `/api/specs?version=${encodeURIComponent(route.version.dir)}` + : '/api/specs'; + const res = await fetch(specsUrl); + const specs = await res.json(); + if (!cancelled.current) setApiSpecs(specs); + } catch { + // best-effort on client nav + } finally { + setIsLoading(false); + } + }, []); + + interface PageData { + frontmatter: Frontmatter; + relativePath: string; + originalPath?: string; + prev?: PageNavLink | null; + next?: PageNavLink | null; + } + + const fetchPageData = useCallback(async (slug: string[]): Promise => { + const apiPath = slug.length === 0 + ? '/api/page' + : `/api/page?slug=${slug.join(',')}`; + const res = await fetch(apiPath); + if (!res.ok) throw new Error(String(res.status)); + return res.json(); + }, []); + + const loadDocsPage = useCallback(async (slug: string[], cancelled: { current: boolean }) => { + setIsLoading(true); + try { + const data = await fetchPageData(slug); + if (cancelled.current) return; + const { content, toc } = await loadMdx(data.originalPath || data.relativePath); + if (cancelled.current) return; + setErrorStatus(null); + setPage({ + slug, + frontmatter: data.frontmatter, + content, + toc, + prev: data.prev ?? null, + next: data.next ?? null, + }); + } catch (err) { + if (cancelled.current) return; + const status = Number((err as Error).message) || 500; + setPage(null); + setErrorStatus(status); + } finally { + if (!cancelled.current) setIsLoading(false); + } + }, [fetchPageData, loadMdx]); useEffect(() => { - if (pathname === currentPath) return; - setCurrentPath(pathname); + if (pathname === currentPathRef.current) return; + currentPathRef.current = pathname; const route = resolveRoute(pathname, initialConfig); if (route.type !== RouteType.Redirect) setVersion(route.version); @@ -96,17 +160,7 @@ export function PageProvider({ if (route.type === RouteType.ApiIndex || route.type === RouteType.ApiPage) { setPage(null); setErrorStatus(null); - const specsUrl = route.version.dir - ? `/api/specs?version=${encodeURIComponent(route.version.dir)}` - : '/api/specs'; - fetch(specsUrl) - .then(res => res.json()) - .then(specs => { - if (!cancelled.current) setApiSpecs(specs); - }) - .catch(() => { - // swallow — api specs are best-effort on client nav - }); + fetchApiSpecs(route, cancelled); return () => { cancelled.current = true; }; } @@ -116,41 +170,15 @@ export function PageProvider({ return () => { cancelled.current = true; }; } - const apiPath = route.slug.length === 0 - ? '/api/page' - : `/api/page?slug=${route.slug.join(',')}`; - - fetch(apiPath) - .then(res => { - if (!res.ok) { - if (!cancelled.current) { - setPage(null); - setErrorStatus(res.status); - } - return; - } - return res.json(); - }) - .then(async (data: { frontmatter: Frontmatter; relativePath: string; originalPath?: string; prev?: Page['prev']; next?: Page['next'] } | undefined) => { - if (cancelled.current || !data) return; - const { content, toc } = await loadMdx(data.originalPath || data.relativePath); - if (cancelled.current) return; - setErrorStatus(null); - setPage({ slug: route.slug, frontmatter: data.frontmatter, content, toc, prev: data.prev, next: data.next }); - }) - .catch(() => { - if (!cancelled.current) { - setPage(null); - setErrorStatus(500); - } - }); - + setPage(null); + setErrorStatus(null); + loadDocsPage(route.slug, cancelled); return () => { cancelled.current = true; }; - }, [pathname]); + }, [pathname, initialConfig, fetchApiSpecs, loadDocsPage]); return ( {children} diff --git a/packages/chronicle/src/pages/DocsPage.tsx b/packages/chronicle/src/pages/DocsPage.tsx index 8dfb6adc..344b9f03 100644 --- a/packages/chronicle/src/pages/DocsPage.tsx +++ b/packages/chronicle/src/pages/DocsPage.tsx @@ -8,13 +8,13 @@ interface DocsPageProps { } export function DocsPage({ slug }: DocsPageProps) { - const { config, tree, page, errorStatus } = usePageContext(); + const { config, tree, page, isLoading, errorStatus } = usePageContext(); if (errorStatus === 404) return ; if (errorStatus) return ; - if (!page) return null; + const { Page, Skeleton } = getTheme(config.theme?.name); - const { Page } = getTheme(config.theme?.name); + if (isLoading || !page) return ; const pageUrl = config.url ? `${config.url}/${slug.join('/')}` : undefined; const markdownHref = `/${slug.join('/')}.md`; diff --git a/packages/chronicle/src/themes/default/Skeleton.tsx b/packages/chronicle/src/themes/default/Skeleton.tsx new file mode 100644 index 00000000..b8cde1ea --- /dev/null +++ b/packages/chronicle/src/themes/default/Skeleton.tsx @@ -0,0 +1,27 @@ +import { Skeleton } from '@raystack/apsara'; +import { Flex } from '@raystack/apsara'; +import styles from './Page.module.css'; + +export function PageSkeleton() { + return ( + +
+ + + + + + + + + + + + + + + +
+
+ ); +} diff --git a/packages/chronicle/src/themes/default/index.ts b/packages/chronicle/src/themes/default/index.ts index c0811972..795b13ef 100644 --- a/packages/chronicle/src/themes/default/index.ts +++ b/packages/chronicle/src/themes/default/index.ts @@ -1,11 +1,13 @@ import type { Theme } from '@/types'; import { Layout } from './Layout'; import { Page } from './Page'; +import { PageSkeleton } from './Skeleton'; import { Toc } from './Toc'; export const defaultTheme: Theme = { Layout, - Page + Page, + Skeleton: PageSkeleton, }; -export { Layout, Page, Toc }; +export { Layout, Page, PageSkeleton, Toc }; diff --git a/packages/chronicle/src/themes/paper/Page.module.css b/packages/chronicle/src/themes/paper/Page.module.css index 92bcad75..0c0764b4 100644 --- a/packages/chronicle/src/themes/paper/Page.module.css +++ b/packages/chronicle/src/themes/paper/Page.module.css @@ -160,6 +160,7 @@ 0 1px 3px rgba(0, 0, 0, 0.08), 0 4px 12px rgba(0, 0, 0, 0.04); margin-bottom: var(--rs-space-9); + min-height: calc(100vh - var(--rs-space-12)); } .content h1, @@ -236,3 +237,12 @@ padding-left: 1rem; border-left: 3px solid var(--rs-color-border-base-primary); } + +.headerLoader { + align-items: center; + margin-bottom: var(--rs-space-5) +} + +.loader { + margin-bottom: var(--rs-space-3) +} diff --git a/packages/chronicle/src/themes/paper/Skeleton.tsx b/packages/chronicle/src/themes/paper/Skeleton.tsx new file mode 100644 index 00000000..bf715ce5 --- /dev/null +++ b/packages/chronicle/src/themes/paper/Skeleton.tsx @@ -0,0 +1,23 @@ +import { Skeleton } from '@raystack/apsara'; +import styles from './Page.module.css'; + +export function PageSkeleton() { + return ( +
+
+
+ + + +
+
+ { + [...new Array(30)].map((_, i) => { + return + }) + } +
+
+
+ ); +} diff --git a/packages/chronicle/src/themes/paper/index.ts b/packages/chronicle/src/themes/paper/index.ts index 65543a58..c10679c2 100644 --- a/packages/chronicle/src/themes/paper/index.ts +++ b/packages/chronicle/src/themes/paper/index.ts @@ -1,8 +1,10 @@ import type { Theme } from '@/types'; import { Layout } from './Layout'; import { Page } from './Page'; +import { PageSkeleton } from './Skeleton'; export const paperTheme: Theme = { Layout, - Page + Page, + Skeleton: PageSkeleton, }; diff --git a/packages/chronicle/src/types/theme.ts b/packages/chronicle/src/types/theme.ts index cfcdd68d..627a8dd2 100644 --- a/packages/chronicle/src/types/theme.ts +++ b/packages/chronicle/src/types/theme.ts @@ -20,5 +20,6 @@ export interface ThemePageProps { export interface Theme { Layout: React.ComponentType Page: React.ComponentType + Skeleton: React.ComponentType className?: string }