From 14ad3d387b75853b41e71cb22bac57908bc189c8 Mon Sep 17 00:00:00 2001 From: Tanish Manku Date: Wed, 13 May 2026 15:11:18 -0700 Subject: [PATCH] pagescene: route Save through Save-copy + confirm modal for visitors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A visitor of someone else's scene now sees the File menu's primary "Save scene" item relabeled to "Save copy". Click → opens the existing Save-as-copy dialog (name input + Save button) which forks the scene to the visitor's own account via the existing saveAsCopy code path. Anon visitors still get the signup CTA on click instead of the dialog. The standalone "Save scene as copy" entry is hidden when visiting so the two paths don't read as redundant for owners. canSave is loosened to always-true for visitors so forking an unmodified scene ("favorite to my account") works as well. Co-Authored-By: Claude Opus 4.7 --- .../ControlsTopButtons/ControlsTopButtons.tsx | 154 +++++++++++------- 1 file changed, 94 insertions(+), 60 deletions(-) diff --git a/frontend/libs/components/pagescene/src/lib/comps/ControlsTopButtons/ControlsTopButtons.tsx b/frontend/libs/components/pagescene/src/lib/comps/ControlsTopButtons/ControlsTopButtons.tsx index f422d6d2c3..99b25d2d3d 100644 --- a/frontend/libs/components/pagescene/src/lib/comps/ControlsTopButtons/ControlsTopButtons.tsx +++ b/frontend/libs/components/pagescene/src/lib/comps/ControlsTopButtons/ControlsTopButtons.tsx @@ -12,7 +12,7 @@ import { Modal } from "@storyteller/ui-modal"; import { twMerge } from "tailwind-merge"; import { EngineContext } from "../../contexts/EngineContext/EngineContext"; -import { usePageSceneStore } from "../../PageSceneStore"; +import { usePageSceneStore, useIsVisitingOthersScene } from "../../PageSceneStore"; import { CameraAspectRatio, ToastTypes } from "../../enums"; import { getSceneGenerationMetaData } from "../../sceneMetadata"; import { LoadUserScenes } from "./LoadUserScenes"; @@ -27,6 +27,7 @@ export const ControlsTopButtons = () => { const sceneMeta = usePageSceneStore((s) => s.sceneMeta); const currentUserToken = usePageSceneStore((s) => s.currentUserToken); const outlinerShowing = usePageSceneStore((s) => s.outlinerShowing); + const isVisitingOthersScene = useIsVisitingOthersScene(); const [sceneTitleInput, setSceneTitleInput] = useState( sceneMeta.title || "", @@ -129,10 +130,50 @@ export const ControlsTopButtons = () => { usePageSceneStore.getState().setOutlinerShowing(!outlinerShowing); }; - const canSave = - sceneMeta.isModified && - (sceneMeta.ownerToken === undefined || - sceneMeta.ownerToken === currentUserToken); + // Visitors of someone else's scene can save too — the action just + // routes through Save-as-copy (fork to their own account) instead of + // overwriting the owner's copy. Forking even an unmodified scene is + // valid ("favorite to my account"), so visitors get Save enabled + // unconditionally; owners still need an unsaved edit to save. + const canSave = isVisitingOthersScene || sceneMeta.isModified; + + // The Save-as-copy "(1)" rename hint runs onDialogOpen for both the + // visitor's primary "Save copy" item AND the owner's standalone + // "Save scene as copy" item, so it lives in a single closure. + const bumpCopyCountInTitle = () => { + const copyCountStr = sceneTitleInput.substring( + sceneTitleInput.lastIndexOf("(") + 1, + sceneTitleInput.length - 1, + ); + if (isNumberString(copyCountStr)) { + const newCopyCountStr = String(Number(copyCountStr) + 1); + setSceneTitleInput( + sceneTitleInput.replace(copyCountStr, newCopyCountStr), + ); + } else { + setSceneTitleInput(sceneTitleInput + " (1)"); + } + }; + + const saveAsCopyDialogProps = { + title: isVisitingOthersScene + ? "Save a copy to your account" + : "Save Scene as Copy", + content: ( + + ), + confirmButtonProps: { + label: "Save", + disabled: sceneTitleInput === "", + onClick: handleButtonSaveAsCopy, + }, + closeButtonProps: { label: "Cancel" }, + showClose: true, + }; return (
@@ -190,66 +231,59 @@ export const ControlsTopButtons = () => { onClick: () => editor?.adapter.promptSignup?.("load"), }), }, + // Primary Save item. For visitors of someone else's scene + // it relabels to "Save copy" and reuses the Save-as-copy + // dialog (forks the scene to the visitor's account). Anon + // skips the dialog entirely and goes straight to the + // signup CTA, so we wire onClick instead of dialogProps in + // that branch. { disabled: !canSave, - label: "Save scene", + label: isVisitingOthersScene ? "Save copy" : "Save scene", description: "Ctrl+S", - ...(sceneMeta.token - ? { onClick: handleButtonSave } - : { - dialogProps: { - title: "Save Scene", - content: ( -

- Save scene to {sceneMeta.title}? -

- ), - confirmButtonProps: { - label: "Save", - onClick: handleButtonSave, - }, - closeButtonProps: { label: "Cancel" }, - showClose: true, - }, - }), + ...(!currentUserToken + ? { + onClick: () => editor?.adapter.promptSignup?.("save"), + } + : isVisitingOthersScene + ? { + onDialogOpen: bumpCopyCountInTitle, + dialogProps: saveAsCopyDialogProps, + } + : sceneMeta.token + ? { onClick: handleButtonSave } + : { + dialogProps: { + title: "Save Scene", + content: ( +

+ Save scene to {sceneMeta.title}? +

+ ), + confirmButtonProps: { + label: "Save", + onClick: handleButtonSave, + }, + closeButtonProps: { label: "Cancel" }, + showClose: true, + }, + }), divider: true, }, - { - disabled: !sceneMeta.isModified || !sceneMeta.token, - label: "Save scene as copy", - description: "Ctrl+Shift+S", - onDialogOpen: () => { - const copyCountStr = sceneTitleInput.substring( - sceneTitleInput.lastIndexOf("(") + 1, - sceneTitleInput.length - 1, - ); - if (isNumberString(copyCountStr)) { - const newCopyCountStr = String(Number(copyCountStr) + 1); - setSceneTitleInput( - sceneTitleInput.replace(copyCountStr, newCopyCountStr), - ); - } else { - setSceneTitleInput(sceneTitleInput + " (1)"); - } - }, - dialogProps: { - title: "Save Scene as Copy", - content: ( - - ), - confirmButtonProps: { - label: "Save", - disabled: sceneTitleInput === "", - onClick: handleButtonSaveAsCopy, - }, - closeButtonProps: { label: "Cancel" }, - showClose: true, - }, - }, + // Standalone "Save scene as copy" — owners only. Visitors + // already get this behavior from the primary Save item; + // showing both would be redundant. + ...(isVisitingOthersScene + ? [] + : [ + { + disabled: !sceneMeta.isModified || !sceneMeta.token, + label: "Save scene as copy", + description: "Ctrl+Shift+S", + onDialogOpen: bumpCopyCountInTitle, + dialogProps: saveAsCopyDialogProps, + }, + ]), ]} />