diff --git a/typescript/packages/jumble/src/hooks/use-charm.ts b/typescript/packages/jumble/src/hooks/use-charm.ts index 02541661f..46f78b8c2 100644 --- a/typescript/packages/jumble/src/hooks/use-charm.ts +++ b/typescript/packages/jumble/src/hooks/use-charm.ts @@ -3,6 +3,37 @@ import { Charm, getIframeRecipe, IFrameRecipe } from "@commontools/charm"; import { Cell, effect } from "@commontools/runner"; import React from "react"; +// Helper function to load a charm and get its iframe recipe +const loadCharmData = async ( + charmId: string | undefined, + charmManager: any, +): Promise< + { charm: Cell | null; iframeRecipe: IFrameRecipe | null } +> => { + if (!charmId) { + return { charm: null, iframeRecipe: null }; + } + + const charm = (await charmManager.get(charmId)) ?? null; + let iframeRecipe = null; + + if (charm) { + await charmManager.syncRecipe(charm); + const ir = getIframeRecipe(charm); + iframeRecipe = ir?.iframe ?? null; + } + + return { charm, iframeRecipe }; +}; + +// Helper to create an effect that reloads charm data when charms change +const createCharmChangeEffect = ( + charmManager: any, + loadFn: () => void, +): () => void => { + return effect(charmManager.getCharms(), loadFn); +}; + export const useCharm = (charmId: string | undefined) => { const { charmManager } = useCharmManager(); const [currentFocus, setCurrentFocus] = React.useState | null>( @@ -14,23 +45,18 @@ export const useCharm = (charmId: string | undefined) => { React.useEffect(() => { async function loadCharm() { - if (charmId) { - const charm = (await charmManager.get(charmId)) ?? null; - if (charm) { - await charmManager.syncRecipe(charm); - const ir = getIframeRecipe(charm); - setIframeRecipe(ir?.iframe ?? null); - } - setCurrentFocus(charm); - } + const { charm, iframeRecipe: recipe } = await loadCharmData( + charmId, + charmManager, + ); + setCurrentFocus(charm); + setIframeRecipe(recipe); } loadCharm(); // Subscribe to changes in the charms list - const cleanup = effect(charmManager.getCharms(), () => { - loadCharm(); - }); + const cleanup = createCharmChangeEffect(charmManager, loadCharm); // Cleanup subscription when component unmounts or charmId/charmManager changes return cleanup; @@ -41,3 +67,53 @@ export const useCharm = (charmId: string | undefined) => { iframeRecipe, }; }; + +export const useCharms = (...charmIds: (string | undefined)[]) => { + const { charmManager } = useCharmManager(); + const [charms, setCharms] = React.useState<(Cell | null)[]>([]); + const [iframeRecipes, setIframeRecipes] = React.useState< + (IFrameRecipe | null)[] + >([]); + + // Memoize charmIds to prevent unnecessary rerenders + const memoizedCharmIds = React.useMemo(() => charmIds, [charmIds.join(",")]); + + // Memoize loadCharms function + const loadCharms = React.useCallback(async () => { + if (memoizedCharmIds.length === 0) { + setCharms([]); + setIframeRecipes([]); + return; + } + + const loadedCharms: (Cell | null)[] = []; + const loadedIframeRecipes: (IFrameRecipe | null)[] = []; + + for (const id of memoizedCharmIds) { + const { charm, iframeRecipe } = await loadCharmData(id, charmManager); + loadedCharms.push(charm); + loadedIframeRecipes.push(iframeRecipe); + } + + setCharms(loadedCharms); + setIframeRecipes(loadedIframeRecipes); + }, [memoizedCharmIds, charmManager]); + + React.useEffect(() => { + loadCharms(); + + // Subscribe to changes in the charms list + const cleanup = createCharmChangeEffect(charmManager, loadCharms); + + // Cleanup subscription when component unmounts or charmIds/charmManager changes + return cleanup; + }, [loadCharms, charmManager]); + + // Memoize the return value + const result = React.useMemo(() => ({ + charms, + iframeRecipes, + }), [charms, iframeRecipes]); + + return result; +}; diff --git a/typescript/packages/jumble/src/main.tsx b/typescript/packages/jumble/src/main.tsx index dbca164e3..af80d7ccb 100644 --- a/typescript/packages/jumble/src/main.tsx +++ b/typescript/packages/jumble/src/main.tsx @@ -22,6 +22,7 @@ import { setupIframe } from "./iframe-ctx.ts"; import GenerateJSONView from "@/views/utility/GenerateJSONView.tsx"; import SpellbookIndexView from "@/views/spellbook/SpellbookIndexView.tsx"; import SpellbookDetailView from "@/views/spellbook/SpellbookDetailView.tsx"; +import StackedCharmsView from "@/views/StackedCharmsView.tsx"; import SpellbookLaunchView from "./views/spellbook/SpellbookLaunchView.tsx"; import { ActionManagerProvider } from "./contexts/ActionManagerContext.tsx"; import { ROUTES } from "./routes.ts"; @@ -65,6 +66,10 @@ createRoot(document.getElementById("root")!).render( path={ROUTES.charmDetail} element={} /> + } + /> {/* Spellbook routes */} diff --git a/typescript/packages/jumble/src/routes.ts b/typescript/packages/jumble/src/routes.ts index 03c4b1ef6..a55a63331 100644 --- a/typescript/packages/jumble/src/routes.ts +++ b/typescript/packages/jumble/src/routes.ts @@ -6,6 +6,7 @@ export const ROUTES = { defaultReplica: "/common-knowledge", replicaRoot: "/:replicaName", charmShow: "/:replicaName/:charmId", + stackedCharms: "/:replicaName/stack/:charmIds", charmDetail: "/:replicaName/:charmId/detail", spellbookIndex: "/spellbook", spellbookDetail: "/spellbook/:spellId", diff --git a/typescript/packages/jumble/src/views/StackedCharmsView.tsx b/typescript/packages/jumble/src/views/StackedCharmsView.tsx new file mode 100644 index 000000000..7d160a993 --- /dev/null +++ b/typescript/packages/jumble/src/views/StackedCharmsView.tsx @@ -0,0 +1,45 @@ +import React from "react"; +import { useParams } from "react-router-dom"; +import { LoadingSpinner } from "@/components/Loader.tsx"; +import { useCharm, useCharms } from "@/hooks/use-charm.ts"; +import { CharmRenderer } from "@/components/CharmRunner.tsx"; +import { charmId } from "@/utils/charms.ts"; + +function StackedCharmsView() { + const { charmIds: paramCharmIds } = useParams(); + const charmIds = React.useMemo(() => { + return paramCharmIds?.split(",").filter((id) => id.trim() !== "") || []; + }, [paramCharmIds]); + + if (!charmIds || charmIds.length === 0) { + throw new Error("Missing charmIds"); + } + + const { charms } = useCharms(...charmIds); + + if (!charms || charms.length === 0) { + return ( +
+ +
+ ); + } + + return ( +
+
+ {charms.map((charm, index) => ( + + {index > 0 &&
} + +
+ ))} +
+
+ ); +} + +export default StackedCharmsView;