Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 88 additions & 12 deletions typescript/packages/jumble/src/hooks/use-charm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Charm> | 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<Cell<Charm> | null>(
Expand All @@ -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;
Expand All @@ -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<Charm> | 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<Charm> | 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;
};
5 changes: 5 additions & 0 deletions typescript/packages/jumble/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -65,6 +66,10 @@ createRoot(document.getElementById("root")!).render(
path={ROUTES.charmDetail}
element={<CharmDetailView />}
/>
<Route
path={ROUTES.stackedCharms}
element={<StackedCharmsView />}
/>
</Route>

{/* Spellbook routes */}
Expand Down
1 change: 1 addition & 0 deletions typescript/packages/jumble/src/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
45 changes: 45 additions & 0 deletions typescript/packages/jumble/src/views/StackedCharmsView.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="max-w-xl mx-auto">
<LoadingSpinner visible />
</div>
);
}

return (
<div className="h-full">
<div className="flex flex-row w-full h-full p-2">
{charms.map((charm, index) => (
<React.Fragment key={charmId(charm)}>
{index > 0 && <div className="w-px bg-gray-300 mx-2 h-full"></div>}
<CharmRenderer
className="h-full min-w-[512px]"
charm={charm}
/>
</React.Fragment>
))}
</div>
</div>
);
}

export default StackedCharmsView;