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,
};