pagescene: add view-only mode + anon signup CTA

- view-only mode (driven by ownerToken !== currentUserToken): gates
  engine mutators (add/delete/toggle/transform/color), gizmo attach,
  delete/undo/redo keybinds, drag-to-add; hides editor chrome
  (OnboardingHelper, Controls3D, ControlsTopButtons, PromptBox3D,
  ControlPanelSceneObject, Outliner row toggles, model/cost buttons).
- new optional adapter.promptSignup; webapp adapter wires to existing
  useSignupCta modal. Save / Save-as-copy / Generate / Upload buttons
  call it when currentUserToken is undefined, so anon visitors hit a
  signup modal instead of silent failures.
- PromptBox3D gains onBeforeSubmit?: () => boolean hook; Stage3DBody
  uses it to route anon Generate clicks through promptSignup, since
  the Generate flow bypasses adapter.enqueueGeneration today.
This commit is contained in:
Tanish Manku committed 2026-05-12 17:41:18 -07:00
1 parent fcb0ddec56
commit e120d009ae
21 files changed
+184 -66

No files matched your search

@@ -3,6 +3,7 @@ import { useNavigate, useParams } from "react-router-dom";
import { Stage3D, usePageSceneStore } from "@storyteller/ui-pagescene";
import { useSession } from "../../lib/session";
import { useSidebar } from "../../components/ui/sidebar";
import { useSignupCta } from "../../components/signup-cta-modal";
import { useWebAppPageSceneAdapter } from "./web-adapter";
// Stage3D fills its parent box; this wrapper is that box. It clamps
@@ -27,6 +28,7 @@ export default function PageScene() {
const { user } = useSession();
const navigate = useNavigate();
const { setOpen, isMobile } = useSidebar();
const { openSignupCta } = useSignupCta();
const didAutoCollapseRef = useRef(false);
useEffect(() => {
@@ -78,6 +80,7 @@ export default function PageScene() {
initialSceneToken: sceneToken,
navigateToImageTo3D,
getViewportSize,
promptSignup: openSignupCta,
});
return (
@@ -102,16 +102,27 @@ export interface WebAppPageSceneAdapterOptions {
// Wrapper size — kept in a ref so the closure sees live values without
// rebuilding the adapter on every resize.
getViewportSize: () => { width: number; height: number };
// Open the host's signup CTA modal. Called from inside the lib when an
// anonymous visitor clicks Save / Generate / Upload / My Library.
promptSignup: (reason?: string) => void;
}
export const useWebAppPageSceneAdapter = (
options: WebAppPageSceneAdapterOptions,
): PageSceneAdapter => {
const { userToken, initialSceneToken, navigateToImageTo3D, getViewportSize } = options;
const { userToken, initialSceneToken, navigateToImageTo3D, getViewportSize, promptSignup } = options;
return useMemo<PageSceneAdapter>(
() => ({
enqueueGeneration: async () => ({ status: "fail" }),
enqueueGeneration: async () => {
if (!userToken) {
promptSignup("generate");
return { status: "fail" };
}
// Generation through the webapp adapter isn't wired up yet —
// signed-in users still hit this path during the editor port.
return { status: "fail" };
},
saveScene: ({ saveJson, sceneTitle, sceneToken, sceneThumbnail }) =>
saveSceneViaApi(saveJson, sceneTitle, sceneToken, sceneThumbnail),
@@ -124,8 +135,17 @@ export const useWebAppPageSceneAdapter = (
getApiSchemeAndHost: apiHost,
getCurrentUserToken: () => userToken,
getCdnUrl: (bucketPath, _width, _quality) =>
`${GetCdnOrigin()}${bucketPath}`,
getCdnUrl: (bucketPath, width, quality) => {
const base = GetCdnOrigin();
const path = bucketPath?.startsWith("/")
? bucketPath
: `/${bucketPath ?? ""}`;
const params: string[] = [];
if (width) params.push(`width=${width}`);
if (quality) params.push(`quality=${quality}`);
if (params.length === 0) return `${base}${path}`;
return `${base}/cdn-cgi/image/${params.join(",")}${path}`;
},
// The wrapper around <Stage3D> creates a fixed-positioning containing
// block sized via the webapp's SidebarInset flex layout. Return its
@@ -247,6 +267,8 @@ export const useWebAppPageSceneAdapter = (
navigateToImageTo3D,
promptSignup,
performLogout: async () => {
try {
await new UsersApi().Logout();
@@ -259,6 +281,6 @@ export const useWebAppPageSceneAdapter = (
initialSceneToken,
}),
[userToken, initialSceneToken, navigateToImageTo3D, getViewportSize],
[userToken, initialSceneToken, navigateToImageTo3D, getViewportSize, promptSignup],
);
};
@@ -15,7 +15,7 @@ export type {
} from "./lib/adapter";
// Store
export { usePageSceneStore } from "./lib/PageSceneStore";
export { usePageSceneStore, useIsViewOnly, getIsViewOnly } from "./lib/PageSceneStore";
export type {
Camera,
FocalLengthDragging,
@@ -1,6 +1,6 @@
import React from "react";
import { MediaItem } from "../models";
import { usePageSceneStore } from "../PageSceneStore";
import { usePageSceneStore, getIsViewOnly } from "../PageSceneStore";
import { AssetType } from "../enums";
import type Editor from "../engine/editor";
import { pickDropPosition } from "../engine/pickDropPosition";
@@ -37,6 +37,7 @@ class DndAsset {
item: MediaItem,
editor: Editor | null,
) {
if (getIsViewOnly()) return;
if (event.button === 0) {
this.editor = editor;
const store = usePageSceneStore.getState();
@@ -490,3 +490,16 @@ export const usePageSceneStore = create<PageSceneState>((set, get) => ({
setEditorCanvasEl: (el) => set({ editorCanvasEl: el }),
setCamViewCanvasEl: (el) => set({ camViewCanvasEl: el }),
}));
// View-only mode: visitor is looking at someone else's scene (owner is
// known and isn't them). A blank scene has no owner so this returns
// false; the scene owner viewing their own scene returns false too.
const computeIsViewOnly = (s: PageSceneState): boolean =>
s.sceneMeta.ownerToken !== undefined &&
s.sceneMeta.ownerToken !== s.currentUserToken;
export const useIsViewOnly = (): boolean =>
usePageSceneStore(computeIsViewOnly);
export const getIsViewOnly = (): boolean =>
computeIsViewOnly(usePageSceneStore.getState());
@@ -52,7 +52,7 @@ import { useViewportSize } from "./hooks/useViewportSize";
import { GridVisibleChangedEvent } from "./engine/events/EngineEvent";
import { pickDropPosition } from "./engine/pickDropPosition";
import { AssetType, CameraAspectRatio } from "./enums";
import { usePageSceneStore } from "./PageSceneStore";
import { usePageSceneStore, useIsViewOnly } from "./PageSceneStore";
import type { MediaItem } from "./models/assets";
const PAGE_ID: ModelPage = ModelPage.Stage3D;
@@ -74,6 +74,7 @@ export const Stage3DBody = () => {
(s) => s.setIsPromptBoxFocused,
);
const gridVisible = usePageSceneStore((s) => s.gridVisible);
const isViewOnly = useIsViewOnly();
const addCamera = usePageSceneStore((s) => s.addCamera);
const updateCamera = usePageSceneStore((s) => s.updateCamera);
const deleteCamera = usePageSceneStore((s) => s.deleteCamera);
@@ -320,7 +321,7 @@ export const Stage3DBody = () => {
return (
<div className="h-full w-full">
<OnboardingHelper />
{!isViewOnly && <OnboardingHelper />}
<div className="relative flex h-full w-full">
<div
@@ -333,17 +334,19 @@ export const Stage3DBody = () => {
</SceneContainer>
<FocalLengthDisplay />
<PoseModeSelector />
{!isViewOnly && <PoseModeSelector />}
<div
className="absolute left-0 top-0 w-full"
onClick={handleOverlayClick}
>
<div className="grid grid-cols-3 gap-4">
<ControlsTopButtons />
<Controls3D />
{!isViewOnly && (
<div
className="absolute left-0 top-0 w-full"
onClick={handleOverlayClick}
>
<div className="grid grid-cols-3 gap-4">
<ControlsTopButtons />
<Controls3D />
</div>
</div>
</div>
)}
<div
className="absolute bottom-0 left-0"
@@ -358,10 +361,13 @@ export const Stage3DBody = () => {
<PreviewEngineCamera />
</div>
<ControlPanelSceneObject />
{!isViewOnly && <ControlPanelSceneObject />}
</div>
<PromptBox3D
{/* Future: <PreviewBox /> slot when isViewOnly — shows the
author's generated still next to the camera. */}
{!isViewOnly && <PromptBox3D
cameras={cameras}
cameraAspectRatio={camAspect}
disableHotkeyInput={disableHotkeyInput}
@@ -395,7 +401,16 @@ export const Stage3DBody = () => {
editor.positive_prompt = prompt;
}}
snapshotCurrentFrame={editor?.snapShotOfCurrentFrame.bind(editor)}
/>
onBeforeSubmit={() => {
const currentUserToken =
usePageSceneStore.getState().currentUserToken;
if (!currentUserToken && editor?.adapter.promptSignup) {
editor.adapter.promptSignup("generate");
return false;
}
return true;
}}
/>}
<LoadingDots
className="absolute left-0 top-0 z-50"
@@ -404,19 +419,21 @@ export const Stage3DBody = () => {
message={editorLoader.message}
/>
<div className="absolute bottom-6 left-6 z-20 flex items-center gap-3">
<ClassyModelSelector
items={STAGE_3D_PAGE_MODEL_LIST}
page={PAGE_ID}
panelTitle="Select Model"
panelClassName="min-w-[300px]"
buttonClassName="bg-transparent p-0 text-lg hover:bg-transparent text-white/80 hover:text-white"
showIconsInList
triggerLabel="Model"
/>
</div>
{!isViewOnly && (
<div className="absolute bottom-6 left-6 z-20 flex items-center gap-3">
<ClassyModelSelector
items={STAGE_3D_PAGE_MODEL_LIST}
page={PAGE_ID}
panelTitle="Select Model"
panelClassName="min-w-[300px]"
buttonClassName="bg-transparent p-0 text-lg hover:bg-transparent text-white/80 hover:text-white"
showIconsInList
triggerLabel="Model"
/>
</div>
)}
<div className="absolute bottom-6 right-6 z-20 flex items-center gap-2">
<CostCalculatorButton modelPage={PAGE_ID} />
{!isViewOnly && <CostCalculatorButton modelPage={PAGE_ID} />}
<HelpMenuButton />
</div>
</div>
@@ -3,12 +3,14 @@ import type Editor from "../engine/editor";
import { MediaItem } from "../models";
import { CreateAction } from "../engine/editor/actions/CreateAction";
import { ObjectAddedEvent } from "../engine/events/EngineEvent";
import { getIsViewOnly } from "../PageSceneStore";
export async function addCharacter(
editor: Editor,
item: MediaItem,
position?: THREE.Vector3,
): Promise<string | undefined> {
if (getIsViewOnly()) return undefined;
const obj = await editor.sceneManager?.create(
item.media_id,
item.name ?? "character",
@@ -3,12 +3,14 @@ import type Editor from "../engine/editor";
import { MediaItem } from "../models";
import { CreateAction } from "../engine/editor/actions/CreateAction";
import { ObjectAddedEvent } from "../engine/events/EngineEvent";
import { getIsViewOnly } from "../PageSceneStore";
export async function addObject(
editor: Editor,
item: MediaItem,
position?: THREE.Vector3,
): Promise<string | undefined> {
if (getIsViewOnly()) return undefined;
const obj = await editor.sceneManager?.create(
item.media_id,
item.name ?? "object",
@@ -3,12 +3,14 @@ import type Editor from "../engine/editor";
import { MediaItem } from "../models";
import { CreateAction } from "../engine/editor/actions/CreateAction";
import { ObjectAddedEvent } from "../engine/events/EngineEvent";
import { getIsViewOnly } from "../PageSceneStore";
export async function addShape(
editor: Editor,
item: MediaItem,
position?: THREE.Vector3,
): Promise<string | undefined> {
if (getIsViewOnly()) return undefined;
const obj = await editor.sceneManager?.create(
"Parim",
item.media_id,
@@ -1,5 +1,6 @@
import type Editor from "../engine/editor";
import { ColorAction } from "../engine/editor/actions/ColorAction";
import { getIsViewOnly } from "../PageSceneStore";
// One-shot apply: use when the change is atomic (e.g. a button click
// or programmatic setter). Records a single ColorAction.
@@ -14,6 +15,7 @@ export function setObjectColor(
uuid: string,
color: string,
): void {
if (getIsViewOnly()) return;
const obj = editor.activeScene.scene.getObjectByProperty("uuid", uuid);
if (!obj) return;
const before = (obj.userData.color as string) ?? "#ffffff";
@@ -36,6 +38,9 @@ export function beginColorSession(
editor: Editor,
uuid: string,
): ColorSession {
if (getIsViewOnly()) {
return { apply() {}, commit() {} };
}
const startObj = editor.activeScene.scene.getObjectByProperty(
"uuid",
uuid,
@@ -1,6 +1,7 @@
import type Editor from "../engine/editor";
import { LockAction } from "../engine/editor/actions/LockAction";
import { OutlinerItemLockToggledEvent } from "../engine/events/EngineEvent";
import { getIsViewOnly } from "../PageSceneStore";
// Toggle the locked state of an object: flips userData.locked, runs
// the gizmo attach/detach side effect, syncs the outliner row icon in
@@ -9,6 +10,7 @@ import { OutlinerItemLockToggledEvent } from "../engine/events/EngineEvent";
// View callers (Outliner) only need to provide editor + uuid; the
// action handles the engine + store + history orchestration.
export function toggleObjectLock(editor: Editor, uuid: string): void {
if (getIsViewOnly()) return;
const before = editor.selection.isObjectLocked(uuid);
editor.bus.emit(new OutlinerItemLockToggledEvent(uuid));
editor.selection.lockUnlockObject(uuid);
@@ -1,6 +1,7 @@
import type Editor from "../engine/editor";
import { VisibilityAction } from "../engine/editor/actions/VisibilityAction";
import { OutlinerItemVisibilityToggledEvent } from "../engine/events/EngineEvent";
import { getIsViewOnly } from "../PageSceneStore";
// Toggle the visibility of an object: flips obj.visible +
// userData.visible, syncs the outliner row icon in the Zustand store,
@@ -10,6 +11,7 @@ import { OutlinerItemVisibilityToggledEvent } from "../engine/events/EngineEvent
// helper reads the before-state from the live engine object so the
// view doesn't have to plumb it.
export function toggleObjectVisibility(editor: Editor, uuid: string): void {
if (getIsViewOnly()) return;
const obj = editor.activeScene.scene.getObjectByProperty("uuid", uuid);
if (!obj) return;
const before = obj.visible;
@@ -1,5 +1,6 @@
import type Editor from "../engine/editor";
import { TransformAction } from "../engine/editor/actions/TransformAction";
import { getIsViewOnly } from "../PageSceneStore";
// A transform "session" — opened on the first edit of a multi-input
// editing flow (e.g. Object Panel position/rotation/scale text fields),
@@ -24,6 +25,9 @@ export function beginTransformSession(
editor: Editor,
uuid: string,
): TransformSession {
if (getIsViewOnly()) {
return { commit() {} };
}
const action = new TransformAction(editor, uuid);
return {
commit() {
@@ -203,6 +203,12 @@ export interface PageSceneAdapter {
// callback. Tauri host: setLogoutStates. Web host: web auth flow.
performLogout(): void;
// Open the host's signup/login modal. Called when an anonymous
// visitor clicks a feature that requires an account (Save, Generate,
// Upload, "My Library"). Tauri host leaves it undefined — its user
// is always signed in. Webapp host wires this to its auth modal.
promptSignup?(reason?: string): void;
// Optional event hooks — telemetry, host-side modals, tab title sync.
onSelectionChange?(
sel: { uuid: string; assetType: string } | null,
@@ -40,6 +40,7 @@ export const Controls3D = () => {
setAssetModalVisibleDuringDrag,
selectedMode,
transformSpace,
currentUserToken,
} = usePageSceneStore(
useShallow((s) => ({
assetModalVisible: s.assetModalVisible,
@@ -47,6 +48,7 @@ export const Controls3D = () => {
setAssetModalVisibleDuringDrag: s.setAssetModalVisibleDuringDrag,
selectedMode: s.selectedMode,
transformSpace: s.transformSpace,
currentUserToken: s.currentUserToken,
})),
);
const [showEmptySceneTooltip, setShowEmptySceneTooltip] = useState(false);
@@ -102,6 +104,15 @@ export const Controls3D = () => {
};
const handleAddAssetAction = (action: string) => {
const requiresAuth =
action === "library" ||
action === "upload-3d" ||
action === "upload-image" ||
action === "upload-splat";
if (requiresAuth && !currentUserToken && editor?.adapter.promptSignup) {
editor.adapter.promptSignup(action);
return;
}
switch (action) {
case "presets":
handleOpenModal();
@@ -59,6 +59,10 @@ export const ControlsTopButtons = () => {
// No-op without an editor; the toast is informational only.
return;
}
if (!currentUserToken && editor.adapter.promptSignup) {
editor.adapter.promptSignup("save");
return;
}
const sceneGenerationMetadata = getSceneGenerationMetaData(editor);
const retSceneMediaToken = await editor.saveScene({
@@ -89,6 +93,10 @@ export const ControlsTopButtons = () => {
const handleButtonSaveAsCopy = useCallback(async () => {
if (!editor) return;
if (!currentUserToken && editor.adapter.promptSignup) {
editor.adapter.promptSignup("save");
return;
}
const sceneGenerationMetadata = getSceneGenerationMetaData(editor);
const retSceneMediaToken = await editor.saveScene({
sceneTitle: sceneTitleInput,
@@ -15,7 +15,7 @@ import { Transition } from "@headlessui/react";
import { twMerge } from "tailwind-merge";
import { useSignals } from "@preact/signals-react/runtime";
import { EngineContext } from "../../contexts/EngineContext";
import { OutlinerItem, usePageSceneStore } from "../../PageSceneStore";
import { OutlinerItem, usePageSceneStore, useIsViewOnly } from "../../PageSceneStore";
import { useViewportSize } from "../../hooks/useViewportSize";
import { CameraAspectRatio } from "../../enums";
import { toggleObjectLock } from "../../actions/toggleObjectLock";
@@ -26,6 +26,7 @@ const OutlinerRow = ({ item }: { item: OutlinerItem }) => {
const isSelected = usePageSceneStore(
(s) => s.outlinerSelectedItem?.id === item.id,
);
const isViewOnly = useIsViewOnly();
const editorEngine = useContext(EngineContext);
@@ -79,37 +80,39 @@ const OutlinerRow = ({ item }: { item: OutlinerItem }) => {
</div>
{item.name}
</span>
<div className="flex gap-3">
<button
onClick={handleToggleLock}
style={{
opacity: hovered || item.locked ? 1 : 0,
}}
>
<div className="w-3">
<FontAwesomeIcon
icon={item.locked ? faLock : faLockOpen}
className="opacity-80 transition-opacity duration-100 hover:opacity-100"
/>
</div>
</button>
<button
onClick={handleToggleVisibility}
style={{
opacity: hovered || !item.visible ? 1 : 0,
}}
>
<div className="w-4">
<FontAwesomeIcon
icon={item.visible ? faEye : faEyeSlash}
className={twMerge(
"opacity-80 transition-opacity duration-100 hover:opacity-100",
item.locked && "text-white/90",
)}
/>
</div>
</button>
</div>
{!isViewOnly && (
<div className="flex gap-3">
<button
onClick={handleToggleLock}
style={{
opacity: hovered || item.locked ? 1 : 0,
}}
>
<div className="w-3">
<FontAwesomeIcon
icon={item.locked ? faLock : faLockOpen}
className="opacity-80 transition-opacity duration-100 hover:opacity-100"
/>
</div>
</button>
<button
onClick={handleToggleVisibility}
style={{
opacity: hovered || !item.visible ? 1 : 0,
}}
>
<div className="w-4">
<FontAwesomeIcon
icon={item.visible ? faEye : faEyeSlash}
className={twMerge(
"opacity-80 transition-opacity duration-100 hover:opacity-100",
item.locked && "text-white/90",
)}
/>
</div>
</button>
</div>
)}
</div>
);
};
@@ -20,7 +20,7 @@ import { TransformAction } from "./editor/actions/TransformAction";
import Stats from "three/examples/jsm/libs/stats.module.js";
import { SparkRenderer } from "@sparkjsdev/spark";
import { usePageSceneStore } from "../PageSceneStore";
import { usePageSceneStore, getIsViewOnly } from "../PageSceneStore";
import { ensureAmmoLoaded } from "./ammoLoader";
import { EngineEventBus } from "./events/EngineEventBus";
import { EngineStoreBridge } from "./EngineStoreBridge";
@@ -780,6 +780,7 @@ class Editor {
}
deleteObject(uuid: string) {
if (getIsViewOnly()) return;
const obj = this.activeScene.scene.getObjectByProperty("uuid", uuid);
if (obj) this.history.record(new DeleteAction(this, obj));
this.mouse_controls?.clearFKVisuals();
@@ -9,6 +9,7 @@
import type * as THREE from "three";
import { TransformControls } from "../TransformControls.js";
import { getIsViewOnly } from "../../PageSceneStore";
export type TransformMode = "translate" | "rotate" | "scale";
export type TransformSpace = "world" | "local";
@@ -90,6 +91,7 @@ export class GizmoController {
}
attach(object: THREE.Object3D) {
if (getIsViewOnly()) return;
this.control?.attach(object);
}
@@ -21,6 +21,7 @@ import {
TransformSpaceChangedEvent,
} from "./events/EngineEvent";
import type { PoseMode, TransformSpace } from "../PageSceneStore";
import { getIsViewOnly } from "../PageSceneStore";
const EDITABLE_INPUT_TYPES = new Set([
"text",
@@ -344,6 +345,7 @@ export class MouseControls {
this.focus();
return;
} else if (event.key === "Backspace" || event.key === "Delete") {
if (getIsViewOnly()) return;
if (this.selected) {
this.selected.forEach((selected) => {
this.deleteObject(selected.uuid);
@@ -384,15 +386,18 @@ export class MouseControls {
this.deps.bus.emit(new SelectedModeChangedEvent("scale"));
return;
} else if (event.key === "k") {
if (getIsViewOnly()) return;
this.toggleFKMode();
return;
} else if (event.key === "b") {
if (getIsViewOnly()) return;
// Open asset modal
this.deps.bus.emit(new AssetModalVisibilityChangedEvent(true, true));
return;
}
if ((event.ctrlKey || event.metaKey) && !this.isProcessing) {
if (getIsViewOnly()) return;
const keyLower = event.key.toLowerCase();
if (keyLower === "z" && !event.shiftKey) {
// undo
@@ -74,6 +74,9 @@ interface PromptBox3DProps {
} | null)
| undefined;
credits?: number | null;
// Optional pre-submit gate. Returns false to abort the generation
// (e.g. host wants to open a signup modal for anon visitors).
onBeforeSubmit?: () => boolean;
}
export const PromptBox3D = ({
@@ -100,6 +103,7 @@ export const PromptBox3D = ({
selectedProvider,
snapshotCurrentFrame,
credits,
onBeforeSubmit,
}: PromptBox3DProps) => {
//const fileInputRef = useRef<HTMLInputElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
@@ -275,6 +279,9 @@ export const PromptBox3D = ({
const maxLen = selectedImageModel?.maxPromptLength ?? 1000;
const handleEnqueue = async () => {
if (onBeforeSubmit && !onBeforeSubmit()) {
return;
}
if (isFinite(maxLen) && prompt.length > maxLen) {
toast.error(`Prompt exceeds the ${maxLen} character limit for this model`);
return;