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
65 changes: 65 additions & 0 deletions typescript/packages/jumble/src/components/ActionBar.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="fixed bottom-2 right-2 z-50 flex flex-row gap-2">
{availableActions.map((action) => {
// For NavLink actions
if (action.id.startsWith("link:") && action.to) {
return (
<NavLink
key={action.id}
to={action.to}
className={`
flex items-center justify-center w-12 h-12
border-2 border-black shadow-[2px_2px_0px_0px_rgba(0,0,0,0.5)]
hover:translate-y-[-2px] hover:shadow-[2px_2px_0px_0px_rgba(0,0,0,0.7)]
transition-[border,box-shadow,transform] duration-100 ease-in-out
bg-white cursor-pointer relative group
touch-action-manipulation tap-highlight-color-transparent
`}
style={{
touchAction: "manipulation",
WebkitTapHighlightColor: "transparent",
}}
>
{action.icon}
<div className="absolute top-[-40px] left-1/2 -translate-x-1/2 bg-gray-800 text-white px-2 py-1 rounded text-sm opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none">
{action.label}
</div>
</NavLink>
);
}

// For regular button actions
return (
<animated.button
key={action.id}
className={`
flex items-center justify-center w-12 h-12
border-2 border-black shadow-[2px_2px_0px_0px_rgba(0,0,0,0.5)]
hover:translate-y-[-2px] hover:shadow-[2px_2px_0px_0px_rgba(0,0,0,0.7)]
transition-[border,box-shadow,transform] duration-100 ease-in-out
bg-white cursor-pointer relative group
touch-action-manipulation tap-highlight-color-transparent
`}
style={{
touchAction: "manipulation",
WebkitTapHighlightColor: "transparent",
}}
onClick={action.onClick}
>
{action.icon}
<div className="absolute top-[-40px] left-1/2 -translate-x-1/2 bg-gray-800 text-white px-2 py-1 rounded text-sm opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none">
{action.label}
</div>
</animated.button>
);
})}
</div>
);
}
2 changes: 1 addition & 1 deletion typescript/packages/jumble/src/components/CharmRunner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ export function CharmRenderer({ charm, className = "" }: CharmRendererProps) {
</div>
</div>
) : null}
<div className={className} ref={containerRef}></div>
<div className={className + " overflow-clip"} ref={containerRef}></div>
</>
);
}
Expand Down
114 changes: 114 additions & 0 deletions typescript/packages/jumble/src/components/Publish.tsx
Original file line number Diff line number Diff line change
@@ -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<string | undefined>();
const [currentCharmName, setCurrentCharmName] = useState<string | null>(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 (
<ShareDialog
isOpen={isShareDialogOpen}
onClose={() => 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 (
// <button
// onClick={handleClick}
// className="w-10 h-10 flex items-center justify-center rounded-lg transition-colors relative group bg-transparent text-black hover:bg-gray-200 z-10 cursor-pointer"
// >
// <LuShare2 size={16} />
// <div className="absolute top-10 left-1/2 -translate-x-1/2 bg-gray-800 text-white px-2 py-1 rounded text-sm opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none">
// Publish
// </div>
// </button>
// );
// }
104 changes: 2 additions & 102 deletions typescript/packages/jumble/src/components/ShellHeader.tsx
Original file line number Diff line number Diff line change
@@ -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<string | null>(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 (
<header className="flex bg-gray-50 items-center justify-between border-b-2 p-2">
<div className="header-start flex items-center gap-2">
Expand Down Expand Up @@ -110,32 +43,7 @@ export function ShellHeader({
</div>
</div>
<User />
{charmId && (
<>
<NavLink
to={togglePath}
className={`w-10 h-10 flex items-center justify-center rounded-lg transition-colors relative group z-10 ${
isDetailActive
? "bg-gray-300 hover:bg-gray-400 text-black"
: "bg-transparent text-black hover:bg-gray-200"
}`}
>
<LuPencil size={16} />
<div className="absolute top-10 left-1/2 -translate-x-1/2 bg-gray-800 text-white px-2 py-1 rounded text-sm opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none">
Edit
</div>
</NavLink>
<button
onClick={() => setIsShareDialogOpen(true)}
className="w-10 h-10 flex items-center justify-center rounded-lg transition-colors relative group bg-transparent text-black hover:bg-gray-200 z-10 cursor-pointer"
>
<LuShare2 size={16} />
<div className="absolute top-10 left-1/2 -translate-x-1/2 bg-gray-800 text-white px-2 py-1 rounded text-sm opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none">
Publish
</div>
</button>
</>
)}

<NavLink
to="/spellbook"
className="brand flex items-center gap-2 opacity-30 hover:opacity-100 transition-opacity duration-200 relative group cursor-pointer z-10"
Expand All @@ -146,14 +54,6 @@ export function ShellHeader({
</div>
</NavLink>
</div>

<ShareDialog
isOpen={isShareDialogOpen}
onClose={() => setIsShareDialogOpen(false)}
onSubmit={handleShare}
defaultTitle={charmName || ""}
isPublishing={isPublishing}
/>
</header>
);
}
Expand Down
6 changes: 3 additions & 3 deletions typescript/packages/jumble/src/components/User.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export function User() {
if (!user) {
return;
}
let did = user.verifier().did();
const did = user.verifier().did();
if (!ignore) {
setDid(did);
}
Expand All @@ -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)}`;
Expand Down
Loading
Loading