diff --git a/frontend/apps/artcraft-webapp/src/app/app.tsx b/frontend/apps/artcraft-webapp/src/app/app.tsx index e9deea17d5..7499188d4b 100644 --- a/frontend/apps/artcraft-webapp/src/app/app.tsx +++ b/frontend/apps/artcraft-webapp/src/app/app.tsx @@ -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() { } /> } /> } /> + } /> + } /> } /> } /> diff --git a/frontend/apps/artcraft-webapp/src/components/sidebar/app-sidebar.tsx b/frontend/apps/artcraft-webapp/src/components/sidebar/app-sidebar.tsx index 2d6bac2e08..7860c478a1 100644 --- a/frontend/apps/artcraft-webapp/src/components/sidebar/app-sidebar.tsx +++ b/frontend/apps/artcraft-webapp/src/components/sidebar/app-sidebar.tsx @@ -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", diff --git a/frontend/apps/artcraft-webapp/src/pages/pagescene/index.ts b/frontend/apps/artcraft-webapp/src/pages/pagescene/index.ts new file mode 100644 index 0000000000..9f5c577702 --- /dev/null +++ b/frontend/apps/artcraft-webapp/src/pages/pagescene/index.ts @@ -0,0 +1 @@ +export { default } from "./pagescene"; diff --git a/frontend/apps/artcraft-webapp/src/pages/pagescene/pagescene.tsx b/frontend/apps/artcraft-webapp/src/pages/pagescene/pagescene.tsx new file mode 100644 index 0000000000..3a31c67652 --- /dev/null +++ b/frontend/apps/artcraft-webapp/src/pages/pagescene/pagescene.tsx @@ -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(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 ( +
+ +
+ ); +} diff --git a/frontend/apps/artcraft-webapp/src/pages/pagescene/web-adapter.tsx b/frontend/apps/artcraft-webapp/src/pages/pagescene/web-adapter.tsx new file mode 100644 index 0000000000..45e28f4e50 --- /dev/null +++ b/frontend/apps/artcraft-webapp/src/pages/pagescene/web-adapter.tsx @@ -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 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 => { + 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 => { + 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( + () => ({ + 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 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) => ( + + ), + renderImageUploader: (props) => ( + + ), + renderSplatUploader: (props) => ( + + ), + + 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], + ); +}; diff --git a/frontend/apps/artcraft-webapp/src/styles.css b/frontend/apps/artcraft-webapp/src/styles.css index eba4e31d23..5fdc065999 100644 --- a/frontend/apps/artcraft-webapp/src/styles.css +++ b/frontend/apps/artcraft-webapp/src/styles.css @@ -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; diff --git a/frontend/apps/artcraft-webapp/tailwind.config.js b/frontend/apps/artcraft-webapp/tailwind.config.js index 318499180e..f52b14cee2 100644 --- a/frontend/apps/artcraft-webapp/tailwind.config.js +++ b/frontend/apps/artcraft-webapp/tailwind.config.js @@ -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", diff --git a/frontend/apps/artcraft-webapp/tsconfig.app.json b/frontend/apps/artcraft-webapp/tsconfig.app.json index df40133c33..01dfbdd56f 100644 --- a/frontend/apps/artcraft-webapp/tsconfig.app.json +++ b/frontend/apps/artcraft-webapp/tsconfig.app.json @@ -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" }, diff --git a/frontend/apps/artcraft-webapp/tsconfig.json b/frontend/apps/artcraft-webapp/tsconfig.json index 6ee6d86129..9e7a2045bd 100644 --- a/frontend/apps/artcraft-webapp/tsconfig.json +++ b/frontend/apps/artcraft-webapp/tsconfig.json @@ -2,6 +2,12 @@ "files": [], "include": [], "references": [ + { + "path": "../../libs/components/upload-modal" + }, + { + "path": "../../libs/components/pagescene" + }, { "path": "../../libs/components/slider-v2" }, diff --git a/frontend/apps/artcraft/tsconfig.json b/frontend/apps/artcraft/tsconfig.json index f61ca26ae3..50f7f82710 100644 --- a/frontend/apps/artcraft/tsconfig.json +++ b/frontend/apps/artcraft/tsconfig.json @@ -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" }, diff --git a/frontend/libs/components/pagedraw/tsconfig.json b/frontend/libs/components/pagedraw/tsconfig.json index c8649167a3..ef0394b892 100644 --- a/frontend/libs/components/pagedraw/tsconfig.json +++ b/frontend/libs/components/pagedraw/tsconfig.json @@ -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" },