import { Component, createContext, createElement, useCallback, useContext, useEffect, useMemo, useState, useSyncExternalStore, type AnchorHTMLAttributes, type ButtonHTMLAttributes, type ErrorInfo, type MouseEvent, type ReactElement, type ReactNode, } from "react"; import type { ColineRecordQuery } from "../capabilities/types"; import type { LiveCollectionOptions } from "../capabilities/live-collection"; import type { SiteSignInProvider } from "../manifest/surfaces"; import { getSession, liveCollection, matchRoute, signIn, signOut, site, SiteError, type SiteFile, type SiteSession, } from "./index"; import type { SiteAppCollectionName, SiteAppRecord, SiteAppToolInput, SiteAppToolName, SiteAppToolOutput } from "./types"; /** * `@colineapp/sdk/site/react`: hooks and components for a site's React * page (SDK 2.1). Everything runs as the visitor through * `@colineapp/sdk/site`. * * ```tsx * import { useCollection, useTool, SignInButton, Router, Routes, Route, Link } from "@colineapp/sdk/site/react"; * ``` * * For types from the app itself, `createSiteHooks()` returns * the same hooks typed by collection and tool name. */ // ── Session ──────────────────────────────────────────────────────────── /** The visitor and site. `loading` until the first answer. */ export function useSession(): { session: SiteSession | null; loading: boolean; error: SiteError | null; signIn: typeof signIn; signOut: typeof signOut; } { const [state, setState] = useState<{ session: SiteSession | null; error: SiteError | null; loading: boolean }>({ session: null, error: null, loading: true, }); useEffect(() => { let active = true; getSession() .then((session) => active && setState({ session, error: null, loading: false })) .catch((error: unknown) => active && setState({ session: null, error: asSiteError(error), loading: false })); return () => { active = false; }; }, []); return { ...state, signIn, signOut }; } /** A plain button that starts sign-in; style it like any other button. */ export function SignInButton({ provider, returnTo, children, ...props }: { provider?: SiteSignInProvider; returnTo?: string; children?: ReactNode } & Omit< ButtonHTMLAttributes, "onClick" | "type" >): ReactElement { const label = provider === "google" ? "Sign in with Google" : provider === "coline" ? "Sign in with Coline" : "Sign in"; return createElement( "button", { ...props, type: "button", onClick: () => signIn({ ...(provider ? { provider } : {}), ...(returnTo ? { returnTo } : {}) }), }, children ?? label, ); } // ── Data ─────────────────────────────────────────────────────────────── /** * A live collection: records for this visitor, refreshed when the site's * data changes, with optimistic `insert`, `update` and `delete`. */ export function useCollection = Record>( name: string, query: ColineRecordQuery = {}, options: LiveCollectionOptions = {}, ) { const key = JSON.stringify([name, query, options]); // The view is rebuilt only when what it shows changes. // eslint-disable-next-line react-hooks/exhaustive-deps const view = useMemo(() => liveCollection(name, query, options), [key]); useEffect(() => { void view.start(); return () => view.stop(); }, [view]); const snapshot = useSyncExternalStore(view.subscribe, view.getSnapshot, view.getSnapshot); return { ...snapshot, insert: view.insert, update: view.update, delete: view.delete, refresh: view.refresh, }; } /** `useCollection` that grows by `pageSize` with `loadMore()`. */ export function usePaginatedCollection = Record>( name: string, query: Omit, "limit" | "cursor"> = {}, pageSize = 20, ) { const [limit, setLimit] = useState(pageSize); const result = useCollection(name, { ...query, limit } as ColineRecordQuery); const hasMore = result.records.length >= limit; return { ...result, hasMore, loadMore: useCallback(() => setLimit((current) => current + pageSize), [pageSize]), }; } /** Run a site tool: `run(input)` resolves with its output. */ export function useTool(name: string) { const [state, setState] = useState<{ running: boolean; error: SiteError | null; output: TOutput | null }>({ running: false, error: null, output: null, }); const run = useCallback( async (input: TInput): Promise => { setState((current) => ({ ...current, running: true, error: null })); try { const result = await site.tools.invoke(name, input); setState({ running: false, error: null, output: result.output }); return result.output; } catch (error) { const failure = asSiteError(error); setState((current) => ({ ...current, running: false, error: failure })); throw failure; } }, [name], ); return { run, ...state }; } /** Upload files when the site accepts them. */ export function useUpload() { const [state, setState] = useState<{ uploading: boolean; error: SiteError | null }>({ uploading: false, error: null }); const upload = useCallback(async (file: Blob, options?: { name?: string; public?: boolean }): Promise => { setState({ uploading: true, error: null }); try { const stored = await site.files.upload(file, options); setState({ uploading: false, error: null }); return stored; } catch (error) { const failure = asSiteError(error); setState({ uploading: false, error: failure }); throw failure; } }, []); return { upload, ...state }; } /** * The hooks above, typed from the app: collection names and record shapes * from each collection's `schema`, tool names, inputs and outputs from * `defineTool`. `import type app from "../app.config"` keeps the app's * code out of the page. */ export function createSiteHooks() { return { useCollection: >( name: K, query?: ColineRecordQuery>, options?: LiveCollectionOptions, ) => useCollection>(name, query, options), usePaginatedCollection: >( name: K, query?: Omit>, "limit" | "cursor">, pageSize?: number, ) => usePaginatedCollection>(name, query, pageSize), useTool: >(name: N) => useTool, SiteAppToolOutput>(name), }; } // ── Routing ──────────────────────────────────────────────────────────── const RouterContext = createContext<{ path: string; navigate: (to: string, options?: { replace?: boolean }) => void } | null>(null); const ParamsContext = createContext>({}); function subscribeLocation(onChange: () => void): () => void { window.addEventListener("popstate", onChange); window.addEventListener("coline:navigate", onChange); return () => { window.removeEventListener("popstate", onChange); window.removeEventListener("coline:navigate", onChange); }; } /** Go to a path on the site without reloading. Routes still need to be in `surfaces.site.routes`. */ export function navigate(to: string, options: { replace?: boolean } = {}): void { if (options.replace) window.history.replaceState(null, "", to); else window.history.pushState(null, "", to); window.dispatchEvent(new Event("coline:navigate")); window.scrollTo(0, 0); } /** Client-side routing for the page. Wrap the app in it once. */ export function Router({ children }: { children?: ReactNode }): ReactElement { const path = useSyncExternalStore( subscribeLocation, () => window.location.pathname, () => "/", ); const value = useMemo(() => ({ path, navigate }), [path]); return createElement(RouterContext.Provider, { value }, children); } /** Renders the first `Route` whose path matches, like a switch. */ export function Routes({ children }: { children?: ReactNode }): ReactElement | null { const router = useContext(RouterContext); const path = router?.path ?? (typeof window === "undefined" ? "/" : window.location.pathname); const routes = (Array.isArray(children) ? children : [children]).flat() as Array | null>; for (const route of routes) { if (!route || typeof route !== "object" || !("props" in route)) continue; const params = matchRoute(route.props.path, path); if (params) return createElement(ParamsContext.Provider, { value: params }, route.props.children ?? null); } return null; } export interface RouteProps { /** `/`, `/notes/:id`, or `*` for everything else. */ path: string; children?: ReactNode; } /** One route inside `Routes`. */ export function Route({ children }: RouteProps): ReactElement | null { return (children as ReactElement | null) ?? null; } /** A link that navigates without reloading. */ export function Link({ to, onClick, ...props }: { to: string } & AnchorHTMLAttributes): ReactElement { return createElement("a", { ...props, href: to, onClick: (event: MouseEvent) => { onClick?.(event); if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; if (props.target && props.target !== "_self") return; event.preventDefault(); navigate(to); }, }); } /** The current route's `:param` values. */ export function useParams = Record>(): T { return useContext(ParamsContext) as T; } /** `navigate(to)` from a component. */ export function useNavigate(): typeof navigate { return navigate; } // ── Errors ───────────────────────────────────────────────────────────── /** Catches render errors below it and shows `fallback(error, retry)`. */ export class SiteErrorBoundary extends Component< { fallback: (error: Error, retry: () => void) => ReactNode; children?: ReactNode }, { error: Error | null } > { override state: { error: Error | null } = { error: null }; static getDerivedStateFromError(error: Error) { return { error }; } override componentDidCatch(error: Error, info: ErrorInfo) { console.error(error, info.componentStack); } override render() { if (this.state.error) return this.props.fallback(this.state.error, () => this.setState({ error: null })); return this.props.children ?? null; } } function asSiteError(error: unknown): SiteError { if (error instanceof SiteError) return error; return new SiteError("site_error", error instanceof Error ? error.message : "Something went wrong.", 0); }