services/content.jsxjavascript
/*
  Provides the app's editorial content (texts, media references, ...), so
  exhibit content can be edited without touching code. Which JSON file gets
  loaded depends on the current route: each entry in PRESENTATION_MODES
  (presentationModes.js) names the content file for its route, so every
  presentation app can have its own small content file instead of one huge
  shared one. The ?contentFile= URL param (config-schema.js) overrides that
  route-based choice, e.g. for testing a file against any route.

  ContentContext: holds the fetched content object, empty ({}) until loaded.
  useContent:     hook to read the content anywhere in the tree.
  ContentProvider: resolves the file for the current route, fetches it (once
                    per file - already-seen files are served from an
                    in-memory cache so switching between apps doesn't
                    refetch) and provides it via context.
*/

// allow export of contexts and hooks - shouldn't be flagged as non-component
/* eslint react-refresh/only-export-components: 0 */

import React from "react";
import PropTypes from "prop-types";
import { useLocation } from "react-router";

import { useConfig } from "./config/config";
import { PRESENTATION_MODES } from "./presentationModes";
import { resolveAssetPath, resolveAssetPathsDeep } from "../utils/resolveAssetPath";

/**
 * Context carrying `{ content, views, stories, error, getView,
 * getAdjacentRootViews }` - consume via {@link useContent}.
 */
export const ContentContext = React.createContext();

/**
 * React hook to access the loaded content object.
 * Returns {} until the content.json fetch in ContentProvider has resolved.
 */
export const useContent = () => React.useContext(ContentContext);

// which PRESENTATION_MODES route (if any) the given pathname belongs to -
// matches the mode's own path exactly, or one of its `:viewId?` sub-routes
const getActiveMode = (pathname) =>
  PRESENTATION_MODES.find(({ path }) => pathname === path || pathname.startsWith(`${path}/`));

/**
 * Resolves the content file for the current route, fetches it (once per
 * file - already-fetched files are served from an in-memory cache so
 * switching between apps doesn't refetch) and makes it available to all
 * descendants via ContentContext. On fetch failure, content stays {}, the
 * failure is exposed as `error` (pages show a visible message instead of a
 * blank screen) and the fetch is retried until it succeeds.
 */
const RETRY_DELAY_MS = 10000;

export const ContentProvider = ({ children }) => {
  const { contentFile: contentFileOverride } = useConfig();
  const { pathname } = useLocation();
  const [content, setContent] = React.useState({});
  const [views, setViews] = React.useState(new Map());
  const [stories, setStories] = React.useState({});
  const [error, setError] = React.useState(null);
  const cacheRef = React.useRef(new Map());

  const contentFile = contentFileOverride || getActiveMode(pathname)?.contentFile || null;

  React.useEffect(() => {
    if (!contentFile) {
      setContent({});
      setViews(new Map());
      setStories({});
      setError(null);
      return undefined;
    }

    const cached = cacheRef.current.get(contentFile);
    if (cached) {
      setContent(cached);
      setViews(new Map(Object.entries(cached.views || {})));
      setStories(cached.stories || {});
      setError(null);
      return undefined;
    }

    let cancelled = false;
    let retryTimer;

    const load = async () => {
      try {
        // both the fetch URL and the asset paths inside the JSON are authored
        // root-absolute - resolve them against the app's base path once here,
        // so no consumer has to (see utils/resolveAssetPath.js)
        const response = await fetch(resolveAssetPath(contentFile));
        if (!response.ok) throw new Error(`HTTP ${response.status} for ${contentFile}`);
        const data = resolveAssetPathsDeep(await response.json());
        if (cancelled) return;

        cacheRef.current.set(contentFile, data);
        setContent(data);
        setViews(new Map(Object.entries(data.views || {})));
        setStories(data.stories || {});
        setError(null);
      } catch (fetchError) {
        // eslint-disable-next-line no-console
        console.error("Error fetching content:", fetchError);
        if (cancelled) return;
        // unattended kiosk: surface the failure and keep retrying - a
        // transient network drop must not leave a permanently blank exhibit
        setError(fetchError);
        retryTimer = setTimeout(load, RETRY_DELAY_MS);
      }
    };

    load();

    return () => {
      cancelled = true;
      if (retryTimer) clearTimeout(retryTimer);
    };
  }, [contentFile]);

  const getView = React.useCallback(
    (id) => {
      if (!views.size) return null;
      return views.get(id);
    },
    [views]
  );

  // reverse of the nextView links between standalone (story-less) views,
  // e.g. "rad-intro" -> "rad-select-story": lets us answer "which view leads
  // here" without the content file needing a prevView field. Story views
  // don't use nextView - their order lives in the story's `slides` array.
  const standalonePrevMap = React.useMemo(() => {
    const map = new Map();
    for (const [id, view] of views) {
      if (!view.story && view.nextView) map.set(view.nextView, id);
    }
    return map;
  }, [views]);

  // `storyHubViewId` (e.g. "rad-select-story") closes the loop at both ends
  // of a story: the first slide's "back" and the last slide's "next" lead to
  // the hub, since stories are entered by choice, not by a fixed link.
  const getAdjacentRootViews = React.useCallback(
    (id, { storyHubViewId } = {}) => {
      const view = views.get(id);
      if (!view) return { prevId: null, nextId: null };

      const hubId = storyHubViewId || null;

      if (!view.story) {
        // standalone chain (intro views, the hub itself)
        const nextId = view.nextView && views.has(view.nextView) ? view.nextView : null;
        return { prevId: standalonePrevMap.get(id) || null, nextId };
      }

      const slides = stories[view.story]?.slides || [];
      // a sub view's "back" is the root it branched off from; its "next" is
      // the slide after that root
      const rootId = view.parent || id;
      const slideIndex = slides.indexOf(rootId);

      const prevId = view.parent || (slideIndex > 0 ? slides[slideIndex - 1] : hubId);
      const nextCandidate = slideIndex >= 0 ? slides[slideIndex + 1] : null;
      const nextId = nextCandidate && views.has(nextCandidate) ? nextCandidate : hubId;

      return { prevId, nextId };
    },
    [views, stories, standalonePrevMap]
  );

  const value = React.useMemo(
    () => ({ content, views, stories, error, getView, getAdjacentRootViews }),
    [content, views, stories, error, getView, getAdjacentRootViews]
  );

  return <ContentContext.Provider value={value}>{children}</ContentContext.Provider>;
};

ContentProvider.propTypes = {
  children: PropTypes.node.isRequired,
};