diff --git a/typescript/packages/jumble/src/components/ActionBar.tsx b/typescript/packages/jumble/src/components/ActionBar.tsx new file mode 100644 index 000000000..7a6e11d5e --- /dev/null +++ b/typescript/packages/jumble/src/components/ActionBar.tsx @@ -0,0 +1,65 @@ +import { animated } from "@react-spring/web"; +import { useActionManager } from "../contexts/ActionManagerContext"; +import { NavLink } from "react-router-dom"; + +export function ActionBar() { + const { availableActions } = useActionManager(); + + return ( +
+ {availableActions.map((action) => { + // For NavLink actions + if (action.id.startsWith("link:") && action.to) { + return ( + + {action.icon} +
+ {action.label} +
+
+ ); + } + + // For regular button actions + return ( + + {action.icon} +
+ {action.label} +
+
+ ); + })} +
+ ); +} diff --git a/typescript/packages/jumble/src/components/CharmRunner.tsx b/typescript/packages/jumble/src/components/CharmRunner.tsx index f0dcb57a3..e4dba34e9 100644 --- a/typescript/packages/jumble/src/components/CharmRunner.tsx +++ b/typescript/packages/jumble/src/components/CharmRunner.tsx @@ -153,7 +153,7 @@ export function CharmRenderer({ charm, className = "" }: CharmRendererProps) { ) : null} -
+
); } diff --git a/typescript/packages/jumble/src/components/Publish.tsx b/typescript/packages/jumble/src/components/Publish.tsx new file mode 100644 index 000000000..4cc2c90dd --- /dev/null +++ b/typescript/packages/jumble/src/components/Publish.tsx @@ -0,0 +1,114 @@ +// hooks/useCharmPublisher.tsx +import { useState, useEffect } from "react"; +import { useNavigate } from "react-router-dom"; +import { useCharmManager } from "@/contexts/CharmManagerContext.tsx"; +import { TYPE } from "@commontools/builder"; +import { saveSpell } from "@/services/spellbook.ts"; +import { ShareDialog } from "@/components/spellbook/ShareDialog.tsx"; + +function useCharmPublisher() { + const { charmManager } = useCharmManager(); + const [isShareDialogOpen, setIsShareDialogOpen] = useState(false); + const [isPublishing, setIsPublishing] = useState(false); + const navigate = useNavigate(); + const [currentCharmId, setCurrentCharmId] = useState(); + const [currentCharmName, setCurrentCharmName] = useState(null); + + useEffect(() => { + const handlePublishCharm = (event: CustomEvent) => { + const { charmId, charmName } = event.detail || {}; + if (charmId) { + setCurrentCharmId(charmId); + setCurrentCharmName(charmName || null); + setIsShareDialogOpen(true); + } + }; + + window.addEventListener("publish-charm", handlePublishCharm as EventListener); + + return () => { + window.removeEventListener("publish-charm", handlePublishCharm as EventListener); + }; + }, []); + + const handleShare = async (data: { title: string; description: string; tags: string[] }) => { + if (!currentCharmId) return; + + setIsPublishing(true); + try { + const charm = await charmManager.get(currentCharmId); + if (!charm) throw new Error("Charm not found"); + const spell = charm.getSourceCell()?.get(); + const spellId = spell?.[TYPE]; + if (!spellId) throw new Error("Spell not found"); + + const success = await saveSpell(spellId, spell, data.title, data.description, data.tags); + + if (success) { + const fullUrl = `${window.location.protocol}//${window.location.host}/spellbook/${spellId}`; + try { + await navigator.clipboard.writeText(fullUrl); + } catch (err) { + console.error("Failed to copy to clipboard:", err); + } + navigate(`/spellbook/${spellId}`); + } else { + throw new Error("Failed to publish"); + } + } catch (error) { + console.error("Failed to publish:", error); + } finally { + setIsPublishing(false); + setIsShareDialogOpen(false); + } + }; + + return { + isShareDialogOpen, + setIsShareDialogOpen, + isPublishing, + handleShare, + defaultTitle: currentCharmName || "", + }; +} + +export function CharmPublisher() { + const { isShareDialogOpen, setIsShareDialogOpen, isPublishing, handleShare, defaultTitle } = + useCharmPublisher(); + + return ( + setIsShareDialogOpen(false)} + onSubmit={handleShare} + defaultTitle={defaultTitle} + isPublishing={isPublishing} + /> + ); +} + +// Usage example for a button that triggers the publish flow: +// +// import { LuShare2 } from "react-icons/lu"; +// +// function PublishButton({ charmId, charmName }: { charmId?: string, charmName?: string }) { +// if (!charmId) return null; +// +// const handleClick = () => { +// window.dispatchEvent(new CustomEvent('publish-charm', { +// detail: { charmId, charmName } +// })); +// }; +// +// return ( +// +// ); +// } diff --git a/typescript/packages/jumble/src/components/ShellHeader.tsx b/typescript/packages/jumble/src/components/ShellHeader.tsx index df2e52616..1d3970e42 100644 --- a/typescript/packages/jumble/src/components/ShellHeader.tsx +++ b/typescript/packages/jumble/src/components/ShellHeader.tsx @@ -1,86 +1,19 @@ -import { useState, useEffect } from "react"; import { NavLink } from "react-router-dom"; -import { LuPencil, LuShare2 } from "react-icons/lu"; import ShapeLogo from "@/assets/ShapeLogo.svg"; import { NavPath } from "@/components/NavPath.tsx"; -import { ShareDialog } from "@/components/spellbook/ShareDialog.tsx"; import { useCharmManager } from "@/contexts/CharmManagerContext.tsx"; -import { NAME, TYPE } from "@commontools/builder"; -import { useNavigate } from "react-router-dom"; -import { saveSpell } from "@/services/spellbook.ts"; import { User } from "@/components/User.tsx"; import { useSyncedStatus } from "@/hooks/use-synced-status"; type ShellHeaderProps = { replicaName?: string; charmId?: string; - isDetailActive: boolean; - togglePath: string; }; -export function ShellHeader({ - replicaName, - charmId, - isDetailActive, - togglePath, -}: ShellHeaderProps) { +export function ShellHeader({ replicaName, charmId }: ShellHeaderProps) { const { charmManager } = useCharmManager(); - const [isShareDialogOpen, setIsShareDialogOpen] = useState(false); - const [charmName, setCharmName] = useState(null); - const [isPublishing, setIsPublishing] = useState(false); const { isSyncing, lastSyncTime } = useSyncedStatus(charmManager); - const navigate = useNavigate(); - useEffect(() => { - let mounted = true; - let cancel: (() => void) | undefined; - async function getCharm() { - if (charmId) { - const charm = await charmManager.get(charmId); - cancel = charm?.key(NAME).sink((value) => { - if (mounted) setCharmName(value ?? null); - }); - } - } - getCharm(); - - return () => { - mounted = false; - cancel?.(); - }; - }, [charmId, charmManager]); - - const handleShare = async (data: { title: string; description: string; tags: string[] }) => { - if (!charmId) return; - - setIsPublishing(true); - try { - const charm = await charmManager.get(charmId); - if (!charm) throw new Error("Charm not found"); - const spell = charm.getSourceCell()?.get(); - const spellId = spell?.[TYPE]; - if (!spellId) throw new Error("Spell not found"); - - const success = await saveSpell(spellId, spell, data.title, data.description, data.tags); - - if (success) { - const fullUrl = `${window.location.protocol}//${window.location.host}/spellbook/${spellId}`; - try { - await navigator.clipboard.writeText(fullUrl); - } catch (err) { - console.error("Failed to copy to clipboard:", err); - } - navigate(`/spellbook/${spellId}`); - } else { - throw new Error("Failed to publish"); - } - } catch (error) { - console.error("Failed to publish:", error); - } finally { - setIsPublishing(false); - setIsShareDialogOpen(false); - } - }; return (
@@ -110,32 +43,7 @@ export function ShellHeader({
- {charmId && ( - <> - - -
- Edit -
-
- - - )} + - - setIsShareDialogOpen(false)} - onSubmit={handleShare} - defaultTitle={charmName || ""} - isPublishing={isPublishing} - />
); } diff --git a/typescript/packages/jumble/src/components/User.tsx b/typescript/packages/jumble/src/components/User.tsx index 408c48c18..ecfc62c6d 100644 --- a/typescript/packages/jumble/src/components/User.tsx +++ b/typescript/packages/jumble/src/components/User.tsx @@ -11,7 +11,7 @@ export function User() { if (!user) { return; } - let did = user.verifier().did(); + const did = user.verifier().did(); if (!ignore) { setDid(did); } @@ -25,10 +25,10 @@ export function User() { let h = "0"; let s = "50%"; - let l = "50%"; + const l = "50%"; if (did) { - let index = did.length - 4; + const index = did.length - 4; // DID string is `did:key:z{REST}`. Taking the last 3 characters, // we use the first two added for hue. h = `${did.charCodeAt(index) + did.charCodeAt(index + 1)}`; diff --git a/typescript/packages/jumble/src/contexts/ActionManagerContext.tsx b/typescript/packages/jumble/src/contexts/ActionManagerContext.tsx new file mode 100644 index 000000000..d03919fcd --- /dev/null +++ b/typescript/packages/jumble/src/contexts/ActionManagerContext.tsx @@ -0,0 +1,110 @@ +import React, { createContext, useContext, useCallback, useState, useEffect, useMemo } from "react"; + +export type KeyCombo = { + key: string; + ctrl?: boolean; + alt?: boolean; + shift?: boolean; + meta?: boolean; +}; + +export type Action = { + id: string; + label: string; + icon: React.ReactNode; + onClick: () => void; + predicate?: () => boolean; + priority?: number; + to?: string; // For NavLink actions + keyCombo?: KeyCombo; // Keyboard shortcut +}; + +type ActionManagerContextType = { + registerAction: (action: Action) => () => void; // Returns unregister function + availableActions: Action[]; +}; + +const ActionManagerContext = createContext(null); + +export function ActionManagerProvider({ children }: { children: React.ReactNode }) { + const [actions, setActions] = useState([]); + + // Combined register function that returns an unregister function + const registerAction = useCallback((action: Action) => { + setActions((prev) => { + // Don't add if it already exists with the same ID + if (prev.some((a) => a.id === action.id)) { + return prev; + } + return [...prev, action]; + }); + + // Handle keyboard shortcut registration + const handleKeyDown = (event: KeyboardEvent) => { + const combo = action.keyCombo; + if (!combo) return; + + if ( + event.key.toLowerCase() === combo.key.toLowerCase() && + !!event.ctrlKey === !!combo.ctrl && + !!event.altKey === !!combo.alt && + !!event.shiftKey === !!combo.shift && + !!event.metaKey === !!combo.meta + ) { + if (!action.predicate || action.predicate()) { + event.preventDefault(); + action.onClick(); + } + } + }; + + // Register keyboard listener if keyCombo exists + if (action.keyCombo) { + window.addEventListener("keydown", handleKeyDown); + } + + // Return a function to unregister this action and clean up event listeners + return () => { + setActions((prev) => prev.filter((a) => a.id !== action.id)); + if (action.keyCombo) { + window.removeEventListener("keydown", handleKeyDown); + } + }; + }, []); + + // Calculate available actions + const availableActions = useMemo(() => { + return actions + .filter((action) => !action.predicate || action.predicate()) + .sort((a, b) => (b.priority || 0) - (a.priority || 0)); + }, [actions]); + + const value = useMemo( + () => ({ + registerAction, + availableActions, + }), + [registerAction, availableActions], + ); + + return {children}; +} + +export function useActionManager() { + const context = useContext(ActionManagerContext); + if (!context) { + throw new Error("useActionManager must be used within an ActionManagerProvider"); + } + return context; +} + +// Simple hook for a component to register an action +export function useAction(action: Action) { + const { registerAction } = useActionManager(); + + // Register on mount, unregister on unmount + useEffect(() => { + const unregister = registerAction(action); + return unregister; + }, [action, registerAction]); // Only re-register if ID changes +} diff --git a/typescript/packages/jumble/src/main.tsx b/typescript/packages/jumble/src/main.tsx index b5d14b1f4..df717d36d 100644 --- a/typescript/packages/jumble/src/main.tsx +++ b/typescript/packages/jumble/src/main.tsx @@ -20,6 +20,7 @@ import GenerateJSONView from "@/views/utility/GenerateJSONView.tsx"; import SpellbookIndexView from "@/views/spellbook/SpellbookIndexView.tsx"; import SpellbookDetailView from "@/views/spellbook/SpellbookDetailView.tsx"; import SpellbookLaunchView from "./views/spellbook/SpellbookLaunchView.tsx"; +import { ActionManagerProvider } from "./contexts/ActionManagerContext.tsx"; setupIframe(); @@ -27,52 +28,54 @@ createRoot(document.getElementById("root")!).render( - - - - - {/* Redirect root to common-knowledge */} - } /> + + + + + + {/* Redirect root to common-knowledge */} + } /> - - - - } - > - } /> - } /> - } /> - + + + + } + > + } /> + } /> + } /> + - {/* Spellbook routes */} - } /> - } /> - - - - } - /> + {/* Spellbook routes */} + } /> + } /> + + + + } + /> - {/* internal tools / experimental routes */} - } /> + {/* internal tools / experimental routes */} + } /> - {/* Photoflow routes preserved */} - } /> - } /> - } - /> - - - - + {/* Photoflow routes preserved */} + } /> + } /> + } + /> + + + + + , diff --git a/typescript/packages/jumble/src/views/CharmDetailView.tsx b/typescript/packages/jumble/src/views/CharmDetailView.tsx index 81fa580ac..db7812c2d 100644 --- a/typescript/packages/jumble/src/views/CharmDetailView.tsx +++ b/typescript/packages/jumble/src/views/CharmDetailView.tsx @@ -1,5 +1,13 @@ import { saveNewRecipeVersion, IFrameRecipe, Charm, getIframeRecipe } from "@commontools/charm"; -import React, { useEffect, useState, useCallback, useRef } from "react"; +import React, { + useEffect, + useState, + useCallback, + useRef, + memo, + createContext, + useContext, +} from "react"; import { useParams, useLocation, useNavigate } from "react-router-dom"; import { useCharmManager } from "@/contexts/CharmManagerContext.tsx"; import { LoadingSpinner } from "@/components/Loader.tsx"; @@ -10,17 +18,13 @@ import { CharmRenderer } from "@/components/CharmRunner.tsx"; import { iterateCharm } from "@/utils/charm-operations.ts"; import { charmId } from "@/utils/charms.ts"; import { DitheredCube } from "@/components/DitherCube.tsx"; -import { VariantTray } from "@/components/VariantTray.tsx"; import { generateCharmSuggestions, type CharmSuggestion, } from "@/utils/prompt-library/charm-suggestions.ts"; import { Cell } from "@commontools/runner"; -type Tab = "iterate" | "code" | "data"; -interface IterationTabProps { - charm: Cell; -} +type Tab = "iterate" | "code" | "data"; const variantModels = [ "anthropic:claude-3-5-sonnet-latest", @@ -29,99 +33,178 @@ const variantModels = [ "google:gemini-2.0-pro", ] as const; -const IterationTab: React.FC = ({ charm }) => { - const { replicaName } = useParams(); - const navigate = useNavigate(); - const { charmManager } = useCharmManager(); +// Memoized CharmRenderer to prevent unnecessary re-renders +const MemoizedCharmRenderer = memo(CharmRenderer); + +// =================== Context for Shared State =================== +interface IterationContextType { + iterationInput: string; + setIterationInput: (input: string) => void; + selectedModel: string; + setSelectedModel: (model: string) => void; + showVariants: boolean; + setShowVariants: (show: boolean) => void; + loading: boolean; + variants: Cell[]; + selectedVariant: Cell | null; + setSelectedVariant: (variant: Cell | null) => void; + expectedVariantCount: number; + handleIterate: () => void; + handleCancelVariants: () => void; +} - const [iterationInput, setIterationInput] = useState(""); - const [selectedModel, setSelectedModel] = useState("anthropic:claude-3-7-sonnet-latest"); - const [showVariants, setShowVariants] = useState(false); - const [loading, setLoading] = useState(false); - const [variants, setVariants] = useState[]>([]); - const [selectedVariant, setSelectedVariant] = useState | null>(null); - const [suggestions, setSuggestions] = useState([]); - const [loadingSuggestions, setLoadingSuggestions] = useState(false); - const [expectedVariantCount, setExpectedVariantCount] = useState(0); - const [pendingSuggestion, setPendingSuggestion] = useState(null); +const IterationContext = createContext(null); - const handleIterate = useCallback(async () => { - const handleVariants = async () => { - setLoading(true); - setVariants([]); - setSelectedVariant(charm); +const useIterationContext = () => { + const context = useContext(IterationContext); + if (!context) { + throw new Error("useIterationContext must be used within an IterationProvider"); + } + return context; +}; - try { - const variantPromises = variantModels.map((model) => - iterateCharm(charmManager, charmId(charm)!, replicaName!, iterationInput, false, model), - ); +// =================== Custom Hooks =================== + +// Hook for managing bottom sheet functionality +function useBottomSheet(initialHeight = 420) { + const [sheetHeight, setSheetHeight] = useState(initialHeight); + const [isResizing, setIsResizing] = useState(false); + const resizeStartY = useRef(null); + const startHeight = useRef(null); + + const handleResizeStart = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + resizeStartY.current = e.clientY; + startHeight.current = sheetHeight; + setIsResizing(true); + + // Add a layer over the entire document to capture events + const overlay = document.createElement("div"); + overlay.id = "resize-overlay"; + overlay.style.position = "fixed"; + overlay.style.top = "0"; + overlay.style.left = "0"; + overlay.style.width = "100%"; + overlay.style.height = "100%"; + overlay.style.zIndex = "9999"; + overlay.style.cursor = "ns-resize"; + document.body.appendChild(overlay); + + const handleResizeMove = (e: MouseEvent) => { + if (resizeStartY.current !== null && startHeight.current !== null) { + const diff = resizeStartY.current - e.clientY; + const newHeight = Math.max( + 150, + Math.min(window.innerHeight * 0.8, startHeight.current + diff), + ); + setSheetHeight(newHeight); + } + }; - // Instead of waiting for all promises, handle them as they complete - let first = true; - variantPromises.forEach(async (promise) => { - try { - const path = await promise; - if (path) { - const id = path.split("/").pop()!; - const newCharm = await charmManager.get(id); - if (newCharm) { - setVariants((prev) => [...prev, newCharm]); - // Set the first completed variant as selected if none selected - if (first) { - setSelectedVariant(newCharm); - first = false; - } - } - } - } catch (error) { - console.error("Variant generation error:", error); - } - }); - } catch (error) { - console.error("Variants error:", error); - } finally { - setLoading(false); - } - }; + const handleResizeEnd = () => { + resizeStartY.current = null; + startHeight.current = null; + setIsResizing(false); - if (showVariants) { - setExpectedVariantCount(variantModels.length); - setVariants([]); - handleVariants(); - } else { - if (!iterationInput) return; - setLoading(true); - try { - const newPath = await iterateCharm( - charmManager, - charmId(charm)!, - replicaName!, - iterationInput, - false, - selectedModel, - ); - if (newPath) { - navigate(`${newPath}/detail#iterate`); + // Remove overlay + const overlay = document.getElementById("resize-overlay"); + if (overlay) { + document.body.removeChild(overlay); } - } catch (error) { - console.error("Iteration error:", error); - } finally { - setLoading(false); + + document.removeEventListener("mousemove", handleResizeMove); + document.removeEventListener("mouseup", handleResizeEnd); + }; + + document.addEventListener("mousemove", handleResizeMove); + document.addEventListener("mouseup", handleResizeEnd); + }, + [sheetHeight], + ); + + const handleTouchResizeStart = useCallback( + (e: React.TouchEvent) => { + e.preventDefault(); + if (e.touches.length === 1) { + resizeStartY.current = e.touches[0].clientY; + startHeight.current = sheetHeight; + setIsResizing(true); } - } - }, [showVariants, iterationInput, selectedModel, charmManager, charm, replicaName, navigate]); + const handleTouchMove = (e: TouchEvent) => { + if ( + resizeStartY.current !== null && + startHeight.current !== null && + e.touches.length === 1 + ) { + const diff = resizeStartY.current - e.touches[0].clientY; + const newHeight = Math.max( + 150, + Math.min(window.innerHeight * 0.8, startHeight.current + diff), + ); + setSheetHeight(newHeight); + } + }; + + const handleTouchEnd = () => { + resizeStartY.current = null; + startHeight.current = null; + setIsResizing(false); + document.removeEventListener("touchmove", handleTouchMove); + document.removeEventListener("touchend", handleTouchEnd); + }; + + document.addEventListener("touchmove", handleTouchMove, { passive: false }); + document.addEventListener("touchend", handleTouchEnd); + }, + [sheetHeight], + ); + + return { + sheetHeight, + isResizing, + handleResizeStart, + handleTouchResizeStart, + }; +} + +// Hook for tab management +function useTabNavigation() { + const location = useLocation(); + const navigate = useNavigate(); + + const [activeTab, setActiveTab] = useState((location.hash.slice(1) as Tab) || "iterate"); + + useEffect(() => { + setActiveTab((location.hash.slice(1) as Tab) || "iterate"); + }, [location.hash]); + + const handleTabChange = useCallback( + (tab: Tab) => { + navigate(`${location.pathname}#${tab}`); + setActiveTab(tab); + }, + [location.pathname, navigate], + ); + + return { activeTab, handleTabChange }; +} + +// Hook for managing suggestions +function useSuggestions(charm: Cell | null) { + const [suggestions, setSuggestions] = useState([]); + const [loadingSuggestions, setLoadingSuggestions] = useState(false); const suggestionsLoadedRef = useRef(false); useEffect(() => { - console.log("Loading suggestions for charm:", charmId(charm)); - if (suggestionsLoadedRef.current) return; + if (suggestionsLoadedRef.current || !charm) return; const loadSuggestions = async () => { setLoadingSuggestions(true); const iframeRecipe = getIframeRecipe(charm); if (!iframeRecipe) { - console.error("No iframe recipe found in charm, what should we do?"); + console.error("No iframe recipe found in charm"); return; } try { @@ -142,243 +225,344 @@ const IterationTab: React.FC = ({ charm }) => { loadSuggestions(); }, [charm]); + return { + suggestions, + loadingSuggestions, + }; +} + +// Hook for code editing +function useCodeEditor(charm: Cell | null, iframeRecipe: IFrameRecipe | null) { + const { charmManager } = useCharmManager(); + const [workingSrc, setWorkingSrc] = useState(undefined); + useEffect(() => { - if (pendingSuggestion) { - handleIterate(); - setPendingSuggestion(null); + if (charm && iframeRecipe) { + setWorkingSrc(iframeRecipe.src); } - }, [pendingSuggestion, handleIterate]); + }, [iframeRecipe, charm]); - const handleSuggestion = (suggestion: CharmSuggestion) => { - setIterationInput(suggestion.prompt); - setShowVariants(true); - setPendingSuggestion(suggestion); - }; + const hasUnsavedChanges = workingSrc !== iframeRecipe?.src; - const handleCancelVariants = () => { - setVariants([]); - setSelectedVariant(null); - setExpectedVariantCount(0); + const saveChanges = useCallback(() => { + if (workingSrc && iframeRecipe && charm) { + saveNewRecipeVersion(charmManager, charm, workingSrc, iframeRecipe.spec); + } + }, [workingSrc, iframeRecipe, charm, charmManager]); + + return { + workingSrc, + setWorkingSrc, + hasUnsavedChanges, + saveChanges, }; +} + +// =================== Components =================== + +// Improved Variants Component with proper previews +const Variants = () => { + const { + variants, + expectedVariantCount, + selectedVariant, + setSelectedVariant, + handleCancelVariants, + } = useIterationContext(); + + const { charmId: paramCharmId } = useParams(); + const { currentFocus: charm } = useCharm(paramCharmId); + + if (variants.length === 0 && expectedVariantCount === 0) return null; return ( -
- {/* Left Sidebar */} -
-
-

Iterate

-
-