webapp: mount pagescene at /pagescene with sidebar-aware viewport

Adds /pagescene (and /pagescene/:sceneToken) under the webapp's public
chrome (inside ProtectedLayout, outside RequireAuth) so anonymous users
can view scenes too. Adapter implementation mirrors the website's, with
two webapp-specific tweaks: imports the local useSession / showToast /
invalidateSession helpers, and getViewportSize returns the wrapper's
live ResizeObserver-measured rect so DnD hit-testing matches the visible
canvas (not window.innerWidth).

Sidebar handling: the lib's position-fixed UI is scoped to a wrapper
div with transform: translateZ(0), which creates a new containing block.
The wrapper renders inside SidebarInset's flex flow, so the editor sits
naturally to the right of the sidebar and below the topbar; expanded-
sidebar behavior is deferred (currently shrinks the page further per the
flex layout).

Sidebar nav gets an "Edit 3D" entry between Image and Video. Tailwind
config gets brand-primary/secondary/tertiary, action color, red/green/
blue DEFAULTs; styles.css adds .bg-ui-panel-gradient, .prevent-select,
.dot-typing — same gap-fills landed on the website.

No new endpoints. tsconfig churn from nx sync.
This commit is contained in:
Tanish Manku committed 2026-05-12 09:18:13 -07:00
1 parent 61c1aa720d
commit 2178afd736
11 files changed
+520 -30

No files matched your search

@@ -15,6 +15,7 @@ import { ToastContainer } from "../components/toast/toast";
import CreateImage from "../pages/create-image";
import CreateVideo from "../pages/create-video";
import CreateVFX from "../pages/create-vfx";
import PageScene from "../pages/pagescene";
import Pricing from "../pages/pricing";
import Support from "../pages/support/support";
import Login from "../pages/login";
@@ -120,6 +121,8 @@ export function App() {
<Route path="/create-image" element={<CreateImage />} />
<Route path="/create-video" element={<CreateVideo />} />
<Route path="/background-change" element={<CreateVFX />} />
<Route path="/pagescene" element={<PageScene />} />
<Route path="/pagescene/:sceneToken" element={<PageScene />} />
<Route path="/support" element={<Support />} />
<Route path="/pricing" element={<Pricing />} />
@@ -4,6 +4,7 @@ import {
faHouse,
faVideo,
faImage,
faCube,
faWandMagicSparkles,
faGrid2,
faGraduationCap,
@@ -41,6 +42,7 @@ const PRIMARY_ITEMS: NavItem[] = [{ label: "Home", href: "/", icon: faHouse }];
const CREATE_ITEMS: NavItem[] = [
{ label: "Image", href: "/create-image", icon: faImage },
{ label: "Edit 3D", href: "/pagescene", icon: faCube },
{ label: "Video", href: "/create-video", icon: faVideo },
{
label: "BG Change",
@@ -0,0 +1 @@
export { default } from "./pagescene";
@@ -0,0 +1,80 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import {
Stage3D,
usePageSceneStore,
} from "@storyteller/ui-pagescene";
import { useSession } from "../../lib/session";
import { useWebAppPageSceneAdapter } from "./web-adapter";
// Stage3D renders its UI with `position: fixed` so its children
// (toolbar, prompt editor, history stack, gallery panel) overlay each
// other within the editor viewport. Inside the webapp's
// SidebarProvider/SidebarInset layout, those `fixed` elements would
// escape to the browser viewport and overlap the sidebar/topbar. The
// wrapper below applies `transform: translateZ(0)` to create a new
// containing block for fixed-positioned descendants, scoping the lib's
// UI to the wrapper's box — which is sized by the flex parent
// (SidebarInset gives us the area below the topbar and right of the
// sidebar).
//
// As a side effect this also gives us a clean rect to feed the adapter's
// `getViewportSize` so DnD hit-testing matches the visible canvas.
export default function PageScene() {
const { sceneToken } = useParams<{ sceneToken?: string }>();
const { user } = useSession();
const navigate = useNavigate();
// Mirror current user token into lib store so ownership-based gating
// (read-only mode, save button visibility) reacts to auth changes.
useEffect(() => {
usePageSceneStore.getState().setCurrentUserToken(user?.user_token);
}, [user?.user_token]);
const navigateToImageTo3D = useCallback(() => {
navigate("/create-image");
}, [navigate]);
// Track the wrapper's rendered size so the adapter's getViewportSize
// returns the actual usable area (not window dimensions, which would
// include sidebar + topbar).
const wrapperRef = useRef<HTMLDivElement | null>(null);
const sizeRef = useRef({ width: window.innerWidth, height: window.innerHeight });
const [, forceRender] = useState(0);
useEffect(() => {
const node = wrapperRef.current;
if (!node) return;
const observer = new ResizeObserver((entries) => {
const entry = entries[0];
if (!entry) return;
const { width, height } = entry.contentRect;
sizeRef.current = { width, height };
// Nudge a re-render so any consumer that reads sizeRef during render
// sees the new values; the adapter itself reads through the ref.
forceRender((n) => (n + 1) % 1024);
});
observer.observe(node);
return () => observer.disconnect();
}, []);
const getViewportSize = useCallback(() => sizeRef.current, []);
const adapter = useWebAppPageSceneAdapter({
userToken: user?.user_token,
initialSceneToken: sceneToken,
navigateToImageTo3D,
getViewportSize,
});
return (
<div
ref={wrapperRef}
className="relative h-full w-full"
style={{ transform: "translateZ(0)" }}
>
<Stage3D adapter={adapter} sceneToken={sceneToken} />
</div>
);
}
@@ -0,0 +1,264 @@
// PageSceneAdapter implementation for the artcraft webapp.
//
// Mirrors the artcraft-website's adapter but plugs into the webapp's
// local session + toast helpers. Built once per <PageScene> mount via
// useMemo, keyed on `userToken` + `initialSceneToken` so signing in
// mid-session re-arms ownership-based UI gating without unmounting the
// editor.
//
// Anonymous-friendly: when there's no signed-in user, getCurrentUserToken
// returns undefined; the lib's read-only gating keeps Save hidden.
import { useMemo } from "react";
import {
GetCdnOrigin,
MediaFilesApi,
MediaUploadApi,
StorytellerApiHostStore,
UploadImageMedia,
UsersApi,
} from "@storyteller/api";
import type { PageSceneAdapter } from "@storyteller/ui-pagescene";
import { ToastTypes } from "@storyteller/ui-pagescene";
import {
UploadModal3D,
UploadModalImage,
UploadModalSplat,
UploaderStates,
} from "@storyteller/ui-upload-modal";
import { showToast } from "../../components/toast/toast";
import { invalidateSession } from "../../lib/session";
const apiHost = () =>
StorytellerApiHostStore.getInstance().getApiSchemeAndHost();
// ─── Scene file IO ─────────────────────────────────────────────────────────
const saveSceneViaApi = async (
saveJson: string,
sceneTitle: string,
sceneToken: string | undefined,
sceneThumbnail: Blob | undefined,
): Promise<string> => {
const blob = new Blob([saveJson], { type: "application/json" });
const uploadApi = new MediaUploadApi();
const uuid = crypto.randomUUID();
const fileName = `${sceneTitle}.glb`;
const uploadResp = sceneToken
? await uploadApi.UploadSavedScene({ blob, fileName, uuid, mediaToken: sceneToken })
: await uploadApi.UploadNewScene({
blob,
fileName,
uuid,
maybe_title: sceneTitle,
});
if (!uploadResp.success || !uploadResp.data) {
showToast("error", uploadResp.errorMessage ?? "Failed to save scene");
return "";
}
const newToken = uploadResp.data;
if (sceneThumbnail) {
try {
const coverResp = await uploadApi.UploadImage({
blob: sceneThumbnail,
fileName: "render.png",
uuid: crypto.randomUUID(),
maybe_title: "Screenshot",
});
if (coverResp.success && coverResp.data) {
await new MediaFilesApi().UpdateCoverImage({
mediaFileToken: newToken,
imageToken: coverResp.data,
});
}
} catch {
// Cover image is best-effort — the scene is still saved.
}
}
return newToken;
};
const loadSceneViaApi = async (token: string): Promise<unknown> => {
const mediaApi = new MediaFilesApi();
const meta = await mediaApi.GetMediaFileByToken({ mediaFileToken: token });
const cdnUrl = meta.data?.media_links?.cdn_url;
if (!cdnUrl) throw new Error("Scene CDN URL missing");
const fileResp = await fetch(cdnUrl);
if (!fileResp.ok) throw new Error(`Scene fetch HTTP ${fileResp.status}`);
const text = await fileResp.text();
return JSON.parse(text);
};
// ─── Adapter ───────────────────────────────────────────────────────────────
export interface WebAppPageSceneAdapterOptions {
userToken: string | undefined;
initialSceneToken: string | undefined;
navigateToImageTo3D: () => void;
// Wrapper size — kept in a ref so the closure sees live values without
// rebuilding the adapter on every resize.
getViewportSize: () => { width: number; height: number };
}
export const useWebAppPageSceneAdapter = (
options: WebAppPageSceneAdapterOptions,
): PageSceneAdapter => {
const { userToken, initialSceneToken, navigateToImageTo3D, getViewportSize } = options;
return useMemo<PageSceneAdapter>(
() => ({
enqueueGeneration: async () => ({ status: "fail" }),
saveScene: ({ saveJson, sceneTitle, sceneToken, sceneThumbnail }) =>
saveSceneViaApi(saveJson, sceneTitle, sceneToken, sceneThumbnail),
loadScene: loadSceneViaApi,
fetchAsset: (url: string) => fetch(url, { mode: "cors" }),
getCdnOrigin: () => GetCdnOrigin(),
getApiSchemeAndHost: apiHost,
getCurrentUserToken: () => userToken,
getCdnUrl: (bucketPath, _width, _quality) =>
`${GetCdnOrigin()}${bucketPath}`,
// The wrapper around <Stage3D> creates a fixed-positioning containing
// block sized via the webapp's SidebarInset flex layout. Return its
// live size so the lib's DnD hit-testing matches what the user sees,
// not raw `window.innerWidth/innerHeight`.
getViewportSize,
listUserMediaFiles: async (query) => {
if (!userToken) {
return { success: false, data: [], pagination: undefined };
}
const api = new MediaFilesApi();
const response = await api.ListUserMediaFiles({
username: userToken,
page_size: query.pageSize,
page_index: query.pageIndex,
// Lib's FilterEngineCategories has a SPLAT variant that the API
// enum doesn't yet — cast through unknown is safe at runtime
// since the wire format is the same string.
filter_engine_categories:
query.filterEngineCategories as unknown as never,
filter_media_type: query.filterMediaTypes as unknown as never,
});
return {
success: response.success,
data: response.data as never,
pagination: response.pagination,
errorMessage: response.errorMessage,
};
},
listFeaturedMediaFiles: async (query) => {
const api = new MediaFilesApi();
const response = await api.ListFeaturedMediaFiles({
page_size: query.pageSize,
cursor: query.cursor,
filter_engine_categories:
query.filterEngineCategories as unknown as never,
filter_media_type: query.filterMediaTypes as unknown as never,
});
return {
success: response.success,
data: response.data as never,
pagination: response.pagination,
errorMessage: response.errorMessage,
};
},
showToast: (level: ToastTypes, message: string) => {
showToast(level === ToastTypes.ERROR ? "error" : "success", message);
},
getMediaUrlByToken: async (token) => {
const api = new MediaFilesApi();
const response = await api.GetMediaFileByToken({ mediaFileToken: token });
return response.data?.media_links?.cdn_url ?? "";
},
renderAssetBrowser: () => null,
renderSceneLoader: () => null,
renderAssetUploader: (props) => (
<UploadModal3D
isOpen={props.isOpen}
onClose={props.onClose}
onSuccess={props.onSuccess}
title={props.title}
titleIcon={props.titleIcon}
/>
),
renderImageUploader: (props) => (
<UploadModalImage
isOpen={props.isOpen}
onClose={props.onClose}
onSuccess={props.onSuccess}
title={props.title}
titleIcon={props.titleIcon}
/>
),
renderSplatUploader: (props) => (
<UploadModalSplat
isOpen={props.isOpen}
onClose={props.onClose}
onSuccess={props.onSuccess}
title={props.title}
titleIcon={props.titleIcon}
/>
),
uploadImage: UploadImageMedia,
uploadPlaneFromMediaToken: async ({ title, mediaToken, progressCallback }) => {
progressCallback({ status: UploaderStates.uploadingAsset });
const mediaApi = new MediaFilesApi();
const fileResp = await mediaApi.GetMediaFileByToken({ mediaFileToken: mediaToken });
if (!fileResp.success || !fileResp.data) {
progressCallback({
status: UploaderStates.assetError,
errorMessage:
fileResp.errorMessage ?? "Could not fetch media file by token.",
});
return;
}
if (title && fileResp.data.maybe_title !== title) {
const renameResp = await mediaApi.RenameMediaFileByToken({
mediaToken,
name: title,
});
if (!renameResp.success) {
progressCallback({
status: UploaderStates.assetError,
errorMessage:
renameResp.errorMessage ?? "Could not rename media file.",
});
return;
}
}
progressCallback({ status: UploaderStates.success });
},
navigateToImageTo3D,
performLogout: async () => {
try {
await new UsersApi().Logout();
} catch {
// Best-effort logout — clear local state regardless.
}
invalidateSession();
window.location.href = "/";
},
initialSceneToken,
}),
[userToken, initialSceneToken, navigateToImageTo3D, getViewportSize],
);
};
@@ -98,6 +98,84 @@
filter: invert(1);
}
/* Gradient panel surface used by pagescene + lib components. */
.bg-ui-panel-gradient {
background:
radial-gradient(60.68% 60.68% at 50% 0%,
rgba(255, 255, 255, 0.08) 0%,
rgba(0, 0, 0, 0) 70%),
#1f1f1f;
background-color: #1f1f1f;
}
.prevent-select {
-webkit-user-select: none;
-ms-user-select: none;
user-select: none;
pointer-events: none;
}
/* three-dots loading spinners — used inside upload-modal + pagescene. */
.dot-typing {
position: relative;
left: -9999px;
width: 10px;
height: 10px;
border-radius: 5px;
background-color: #ffffff30;
color: #ffffff30;
box-shadow:
9984px 0 0 0 #ffffff30,
9999px 0 0 0 #ffffff30,
10014px 0 0 0 #ffffff30;
animation: dot-typing 1.5s infinite linear;
}
@keyframes dot-typing {
0% {
box-shadow:
9984px 0 0 0 #ffffff30,
9999px 0 0 0 #ffffff30,
10014px 0 0 0 #ffffff30;
}
16.667% {
box-shadow:
9984px -10px 0 0 #ffffff30,
9999px 0 0 0 #ffffff30,
10014px 0 0 0 #ffffff30;
}
33.333% {
box-shadow:
9984px 0 0 0 #ffffff30,
9999px 0 0 0 #ffffff30,
10014px 0 0 0 #ffffff30;
}
50% {
box-shadow:
9984px 0 0 0 #ffffff30,
9999px -10px 0 0 #ffffff30,
10014px 0 0 0 #ffffff30;
}
66.667% {
box-shadow:
9984px 0 0 0 #ffffff30,
9999px 0 0 0 #ffffff30,
10014px 0 0 0 #ffffff30;
}
83.333% {
box-shadow:
9984px 0 0 0 #ffffff30,
9999px 0 0 0 #ffffff30,
10014px -10px 0 0 #ffffff30;
}
100% {
box-shadow:
9984px 0 0 0 #ffffff30,
9999px 0 0 0 #ffffff30,
10014px 0 0 0 #ffffff30;
}
}
.glass {
@apply relative border border-white/10 shadow-lg backdrop-blur-xl transition-colors duration-300;
@apply bg-ui-controls/85 !important;
@@ -37,10 +37,10 @@ module.exports = {
white: colors.white,
gray: colors.gray,
black: colors.black,
red: colors.red,
blue: colors.blue,
red: { ...colors.red, DEFAULT: "#D33242" },
blue: { ...colors.blue, DEFAULT: "#2E70FF" },
orange: colors.orange,
green: colors.green,
green: { ...colors.green, DEFAULT: "#308752" },
emerald: colors.emerald,
teal: colors.teal,
yellow: colors.yellow,
@@ -75,6 +75,62 @@ module.exports = {
900: "#64373f",
950: "#361b1e",
},
"brand-primary": {
DEFAULT: "#2d81ff",
50: "#eef6ff",
100: "#d9ebff",
200: "#bcdcff",
300: "#8ec7ff",
400: "#59a7ff",
500: "#2d81ff",
600: "#1b63f5",
700: "#144ee1",
800: "#173fb6",
900: "#19398f",
950: "#142457",
},
"brand-secondary": {
DEFAULT: "#3E3E41",
50: "#f6f6f6",
100: "#e7e7e7",
200: "#d1d1d1",
300: "#b0b0b0",
400: "#888888",
500: "#6d6d6d",
600: "#5d5d5d",
700: "#4f4f4f",
800: "#454545",
900: "#3d3d3d",
950: "#3E3E41",
},
"brand-tertiary": {
DEFAULT: "#1cb6be",
50: "#eefdfc",
100: "#d4f9f8",
200: "#aef3f3",
300: "#62e4e6",
400: "#38d3d8",
500: "#1cb6be",
600: "#1a94a0",
700: "#1c7682",
800: "#1f616b",
900: "#1e505b",
950: "#0e353e",
},
action: {
DEFAULT: "#3E3E41",
50: "#f6f6f6",
100: "#e7e7e7",
200: "#d1d1d1",
300: "#b0b0b0",
400: "#888888",
500: "#6d6d6d",
600: "#5d5d5d",
700: "#4f4f4f",
800: "#454545",
900: "#3d3d3d",
950: "#3E3E41",
},
secondary: {
DEFAULT: "#3E3E41",
50: "#f6f6f6",
@@ -37,6 +37,12 @@
"src/components/footer"
],
"references": [
{
"path": "../../libs/components/upload-modal/tsconfig.lib.json"
},
{
"path": "../../libs/components/pagescene/tsconfig.lib.json"
},
{
"path": "../../libs/components/slider-v2/tsconfig.lib.json"
},
@@ -2,6 +2,12 @@
"files": [],
"include": [],
"references": [
{
"path": "../../libs/components/upload-modal"
},
{
"path": "../../libs/components/pagescene"
},
{
"path": "../../libs/components/slider-v2"
},
+7 -10
View File
@@ -31,6 +31,9 @@
{
"path": "../../libs/components/badge"
},
{
"path": "../../libs/components/vfx"
},
{
"path": "../../libs/components/slider-v2"
},
@@ -55,9 +58,6 @@
{
"path": "../../libs/components/promptbox"
},
{
"path": "../../libs/components/vfx"
},
{
"path": "../../libs/components/menu-icon-selector"
},
@@ -97,12 +97,6 @@
{
"path": "../../libs/components/gravatar"
},
{
"path": "../../libs/components/input"
},
{
"path": "../../libs/components/upload-modal"
},
{
"path": "../../libs/components/modal"
},
@@ -113,7 +107,7 @@
"path": "../../libs/components/file-uploader"
},
{
"path": "../../libs/components/list-dropdown"
"path": "../../libs/components/input"
},
{
"path": "../../libs/components/select"
@@ -148,6 +142,9 @@
{
"path": "../../libs/components/button"
},
{
"path": "../../libs/components/upload-modal"
},
{
"path": "../../libs/tauri-api"
},
+14 -17
View File
@@ -5,45 +5,42 @@
{
"path": "../slider-v2"
},
{
"path": "../../api"
},
{
"path": "../promptbox"
},
{
"path": "../../api-enums"
},
{
"path": "../../model-list"
},
{
"path": "../action-reminder-modal"
},
{
"path": "../tooltip"
},
{
"path": "../modal"
},
{
"path": "../model-selector"
},
{
"path": "../pricing-modal"
},
{
"path": "../help-menu"
},
{
"path": "../model-selector"
},
{
"path": "../../model-list"
},
{
"path": "../../common"
},
{
"path": "../../api"
"path": "../action-reminder-modal"
},
{
"path": "../gallery-modal"
"path": "../tooltip"
},
{
"path": "../button"
},
{
"path": "../modal"
},
{
"path": "./tsconfig.lib.json"
},