From c204f05fdb7eca94b269acff85eb2ea53c0a717b Mon Sep 17 00:00:00 2001 From: BFlat Date: Fri, 22 May 2026 14:05:19 +0700 Subject: [PATCH] webapp: improvements may 22 (#1548) - remove small plan text on pricing page - persist prompt/settings across reloads, add frontend lightbox references media (audio/video) - remove topbar for edit 3d page, reclaim vertical space and relocate scene title rename/right topbar actions --------- Co-authored-by: Claude Opus 4.7 --- frontend/apps/artcraft-webapp/src/app/app.tsx | 11 +- .../components/lightbox/LightboxDetails.tsx | 66 +++- .../src/components/lightbox/shared.ts | 9 +- .../components/topbar/ActiveSceneTitle.tsx | 14 +- .../src/components/topbar/TopBarActions.tsx | 346 +++++++++++++++++ .../src/components/topbar/topbar.tsx | 353 +----------------- .../pages/create-image/create-image-store.ts | 143 +++---- .../pages/create-video/create-video-store.ts | 13 + .../pages/pagescene/demo-output-overlay.tsx | 4 +- .../src/pages/pagescene/pagescene.tsx | 4 + .../src/pages/pricing/pricing.tsx | 9 +- .../apps/artcraft-webapp/tsconfig.tsbuildinfo | 1 + .../common/src/lib/utils/thumbnail-utils.ts | 19 + .../src/lib/button-dropdown.tsx | 12 +- .../lightbox-modal/src/lib/lightbox-modal.tsx | 104 +++++- .../components/pagescene/src/lib/Stage3D.tsx | 13 + .../pagescene/src/lib/Stage3DBody.tsx | 22 +- .../src/lib/comps/Controls3D/Controls3D.tsx | 18 +- .../ControlsTopButtons/ControlsTopButtons.tsx | 21 +- 19 files changed, 704 insertions(+), 478 deletions(-) create mode 100644 frontend/apps/artcraft-webapp/src/components/topbar/TopBarActions.tsx create mode 100644 frontend/apps/artcraft-webapp/tsconfig.tsbuildinfo diff --git a/frontend/apps/artcraft-webapp/src/app/app.tsx b/frontend/apps/artcraft-webapp/src/app/app.tsx index 1148cb1c40..d2651c9390 100644 --- a/frontend/apps/artcraft-webapp/src/app/app.tsx +++ b/frontend/apps/artcraft-webapp/src/app/app.tsx @@ -74,6 +74,7 @@ function RequireAuth() { function ProtectedContent() { const { state, isMobile } = useSidebar(); + const { pathname } = useLocation(); // Effective horizontal space taken by the sidebar — used by fixed-positioned // page chrome (e.g. promptboxes) to center within the content area. const sidebarOffset = isMobile @@ -82,12 +83,20 @@ function ProtectedContent() { ? "var(--sidebar-width)" : "calc(var(--sidebar-width-icon) + 1.5rem)"; + // The Edit 3D editor hosts the header's actions (pricing/credits/task + // queue/profile) inside its own canvas toolbar to reclaim vertical + // space, so the global header is hidden there — desktop only, since the + // mobile route shows a gate that still needs the header's nav chrome. + const hideTopBar = + !isMobile && + (pathname === "/edit-3d" || pathname.startsWith("/edit-3d/")); + return (
- + {!hideTopBar && } diff --git a/frontend/apps/artcraft-webapp/src/components/lightbox/LightboxDetails.tsx b/frontend/apps/artcraft-webapp/src/components/lightbox/LightboxDetails.tsx index f1993bee27..df679d8606 100644 --- a/frontend/apps/artcraft-webapp/src/components/lightbox/LightboxDetails.tsx +++ b/frontend/apps/artcraft-webapp/src/components/lightbox/LightboxDetails.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import dayjs from "dayjs"; import { Button } from "@storyteller/ui-button"; import { toast } from "../toast/toast"; @@ -13,11 +13,14 @@ import { faCopy, faImage, faLink, + faPause, faPencil, + faPlay, faSpinnerThird, faTrashCan, faUser, faVideo, + faWaveformLines, faXmark, } from "@fortawesome/pro-solid-svg-icons"; import { @@ -93,6 +96,36 @@ export function LightboxDetails({ const promptCopy = useCopyFeedback(); const shareCopy = useCopyFeedback(); const [isDownloading, setIsDownloading] = useState(false); + const [playingAudioToken, setPlayingAudioToken] = useState(null); + const audioPlayerRef = useRef(null); + + const stopAudio = useCallback(() => { + if (audioPlayerRef.current) { + audioPlayerRef.current.pause(); + audioPlayerRef.current = null; + } + setPlayingAudioToken(null); + }, []); + + const handleAudioToggle = useCallback( + (token: string, url: string) => { + if (playingAudioToken === token) { + stopAudio(); + } else { + stopAudio(); + const audio = new Audio(url); + audio.volume = 0.7; + audio.onended = () => { + setPlayingAudioToken(null); + audioPlayerRef.current = null; + }; + audioPlayerRef.current = audio; + audio.play(); + setPlayingAudioToken(token); + } + }, + [playingAudioToken, stopAudio], + ); const [isPromptExpanded, setIsPromptExpanded] = useState(false); const [isPromptClamped, setIsPromptClamped] = useState(false); @@ -101,6 +134,7 @@ export function LightboxDetails({ useEffect(() => { setIsPromptExpanded(false); setIsDownloading(false); + stopAudio(); }, [mediaToken]); useEffect(() => { @@ -256,14 +290,34 @@ export function LightboxDetails({
- Reference Images + Reference Media
{promptData.contextImages.map((contextImage, index) => { + const isAudio = contextImage.semantic === "audioref"; + const isVideoRef = contextImage.semantic === "vid_ref"; const { thumbnail } = getContextImageThumbnail( contextImage, { size: THUMBNAIL_SIZES.SMALL }, ); + const isPlayingThis = playingAudioToken === contextImage.media_token; + + if (isAudio) { + return ( + + ); + } + return ( + {isVideoRef && ( +
+ +
+ )}
); })} diff --git a/frontend/apps/artcraft-webapp/src/components/lightbox/shared.ts b/frontend/apps/artcraft-webapp/src/components/lightbox/shared.ts index 5c8385ca51..8627f0d548 100644 --- a/frontend/apps/artcraft-webapp/src/components/lightbox/shared.ts +++ b/frontend/apps/artcraft-webapp/src/components/lightbox/shared.ts @@ -20,7 +20,14 @@ export const is3DModelUrl = (url: string): boolean => MODEL_3D_EXTENSIONS.some((ext) => url.toLowerCase().includes(ext)); export interface ContextImage { - media_links: { cdn_url: string; maybe_thumbnail_template: string | null }; + media_links: { + cdn_url: string; + maybe_thumbnail_template: string | null; + maybe_video_previews: { + still: string; + still_thumbnail_template: string; + } | null; + }; media_token: string; semantic: string; } diff --git a/frontend/apps/artcraft-webapp/src/components/topbar/ActiveSceneTitle.tsx b/frontend/apps/artcraft-webapp/src/components/topbar/ActiveSceneTitle.tsx index e0ef7244e9..53bc860f95 100644 --- a/frontend/apps/artcraft-webapp/src/components/topbar/ActiveSceneTitle.tsx +++ b/frontend/apps/artcraft-webapp/src/components/topbar/ActiveSceneTitle.tsx @@ -26,7 +26,6 @@ import { showToast } from "../toast/toast"; const PAGESCENE_ROUTE_PREFIX = "/edit-3d"; const DEFAULT_TITLE = "Untitled Scene"; -const PAGE_NAME = "Edit Scene"; export function ActiveSceneTitle() { const { pathname } = useLocation(); @@ -114,18 +113,16 @@ export function ActiveSceneTitle() { }; return ( -
+
{!isEditing && (
- {PAGE_NAME} - / {canRename ? ( ) : ( -
+
{title}
)} @@ -162,10 +159,9 @@ export function ActiveSceneTitle() { disabled={isSubmitting} maxLength={120} className={twMerge( - "h-8 w-[420px] max-w-full rounded-md border border-white/[0.12] bg-white/[0.04] px-2 text-center text-sm font-semibold text-white outline-none focus:outline focus:outline-2 focus:outline-brand-primary", + "h-[34px] w-fit max-w-full rounded-lg border border-white/[0.12] bg-white/[0.04] px-3 text-sm font-semibold text-white outline-none focus:outline focus:outline-2 focus:outline-brand-primary", isSubmitting && "outline outline-2 outline-brand-secondary", - !isValid && - "border-danger focus:outline-danger focus:outline-2", + !isValid && "border-danger focus:outline-danger focus:outline-2", )} /> )} diff --git a/frontend/apps/artcraft-webapp/src/components/topbar/TopBarActions.tsx b/frontend/apps/artcraft-webapp/src/components/topbar/TopBarActions.tsx new file mode 100644 index 0000000000..cf49aef21b --- /dev/null +++ b/frontend/apps/artcraft-webapp/src/components/topbar/TopBarActions.tsx @@ -0,0 +1,346 @@ +// Right-side header actions: pricing, credit balance, upgrade CTA, task +// queue, and the profile dropdown (plus the credits/settings modals they +// open). Extracted from TopBar so the Edit 3D page can host the same +// cluster inside the 3D editor toolbar (its global header is hidden there +// to reclaim vertical space) without duplicating the logic. + +import { Fragment, useEffect, useState } from "react"; +import { Link, useNavigate } from "react-router-dom"; +import { + Menu, + MenuButton, + MenuItem, + MenuItems, + Transition, +} from "@headlessui/react"; +import { twMerge } from "tailwind-merge"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { + faCoins, + faGem, + faCog, + faLifeRing, + faGift, +} from "@fortawesome/pro-solid-svg-icons"; +import { faDiscord } from "@fortawesome/free-brands-svg-icons"; +import { Button } from "@storyteller/ui-button"; +import { PopoverMenu } from "@storyteller/ui-popover"; +import { + BillingApi, + CreditsApi, + USER_FEATURE_FLAGS, + UsersApi, +} from "@storyteller/api"; +import { invalidateSession, useSession } from "../../lib/session"; +import { SOCIAL_LINKS } from "../../config/links"; +import { CreditsModal } from "../credits-modal"; +import { SettingsModal } from "../settings-modal/SettingsModal"; +import { TaskQueue } from "./task-queue"; + +async function fetchCredits(): Promise { + try { + const response = await new CreditsApi().GetSessionCredits(); + if (response.success && response.data) { + return response.data.sumTotalCredits; + } + return null; + } catch { + return null; + } +} + +async function fetchHasPaidPlan(): Promise { + try { + const response = await new BillingApi().ListActiveSubscriptions(); + if (response.success && response.data?.active_subscriptions) { + return response.data.active_subscriptions.some( + (sub) => sub.namespace === "artcraft", + ); + } + return false; + } catch { + return false; + } +} + +export function TopBarActions({ className }: { className?: string }) { + const navigate = useNavigate(); + const { user, authChecked } = useSession(); + const [credits, setCredits] = useState(null); + const [hasPaidPlan, setHasPaidPlan] = useState(null); + const [creditsModalOpen, setCreditsModalOpen] = useState(false); + const [settingsOpen, setSettingsOpen] = useState(false); + + useEffect(() => { + if (user) { + fetchCredits().then(setCredits); + fetchHasPaidPlan().then(setHasPaidPlan); + } else { + setCredits(null); + setHasPaidPlan(null); + } + }, [user]); + + useEffect(() => { + const handler = () => { + fetchCredits().then(setCredits); + }; + window.addEventListener("credits-change", handler); + return () => window.removeEventListener("credits-change", handler); + }, []); + + const handleLogout = async () => { + await new UsersApi().Logout(); + invalidateSession(); + window.location.href = "/"; + }; + + return ( +
+ {!authChecked ? null : user ? ( + <> + + + Pricing + + + {credits !== null && ( + setCreditsModalOpen(true)} + onUpgrade={() => navigate("/pricing")} + /> + )} + + {hasPaidPlan === false && ( + + )} + + + + + + Open user menu + + + + +
+

+ {user.display_name || user.username} +

+
+ + {({ active }) => ( + + )} + + + {({ active }) => ( + + )} + + {user.maybe_feature_flags?.includes( + USER_FEATURE_FLAGS.REFERRALS, + ) && ( + + {({ active }) => ( + + )} + + )} + + {({ active }) => ( + + + Join Discord + + )} + + + {({ active }) => ( + + )} + +
+
+
+ + ) : ( + <> + + + Pricing + + + Login + + + Sign up + + + )} + + setCreditsModalOpen(false)} + /> + setSettingsOpen(false)} + /> +
+ ); +} + +function CreditsChip({ + credits, + onBuyCredits, + onUpgrade, +}: { + credits: number; + onBuyCredits: () => void; + onUpgrade: () => void; +}) { + return ( + + } + triggerLabel={ + + {credits.toLocaleString()} + + } + buttonClassName="h-8 px-3 ps-2.5 bg-white/[0.04] hover:bg-white/[0.08] border border-white/[0.08] shadow-none text-white/80 rounded-lg gap-1.5" + panelClassName="mt-2 bg-[#1a1a1a] border border-white/[0.08] text-white rounded-xl" + > + {(close) => ( +
+
+ + Your credit balance + + +
+
+ + {credits.toLocaleString()} +
+
+ +
+
+ )} +
+ ); +} diff --git a/frontend/apps/artcraft-webapp/src/components/topbar/topbar.tsx b/frontend/apps/artcraft-webapp/src/components/topbar/topbar.tsx index f40df065bb..e977ebf595 100644 --- a/frontend/apps/artcraft-webapp/src/components/topbar/topbar.tsx +++ b/frontend/apps/artcraft-webapp/src/components/topbar/topbar.tsx @@ -1,161 +1,11 @@ -import { Fragment, useEffect, useState } from "react"; -import { Link, useNavigate } from "react-router-dom"; -import { - Menu, - MenuButton, - MenuItem, - MenuItems, - Transition, -} from "@headlessui/react"; -import { twMerge } from "tailwind-merge"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { - faCoins, - faGem, - faCog, - faLifeRing, - faGift, -} from "@fortawesome/pro-solid-svg-icons"; -import { faDiscord } from "@fortawesome/free-brands-svg-icons"; -import { Button } from "@storyteller/ui-button"; -import { PopoverMenu } from "@storyteller/ui-popover"; -import { - BillingApi, - CreditsApi, - USER_FEATURE_FLAGS, - UsersApi, -} from "@storyteller/api"; -import { invalidateSession, useSession } from "../../lib/session"; -import { SOCIAL_LINKS } from "../../config/links"; -import { CreditsModal } from "../credits-modal"; -import { SettingsModal } from "../settings-modal/SettingsModal"; -import { TaskQueue } from "./task-queue"; +import { Link } from "react-router-dom"; import { SidebarTrigger, useSidebar } from "../ui/sidebar"; import { Breadcrumbs } from "./breadcrumbs"; -import { ActiveSceneTitle } from "./ActiveSceneTitle"; - -async function fetchCredits(): Promise { - try { - const response = await new CreditsApi().GetSessionCredits(); - if (response.success && response.data) { - return response.data.sumTotalCredits; - } - return null; - } catch { - return null; - } -} - -async function fetchHasPaidPlan(): Promise { - try { - const response = await new BillingApi().ListActiveSubscriptions(); - if (response.success && response.data?.active_subscriptions) { - return response.data.active_subscriptions.some( - (sub) => sub.namespace === "artcraft", - ); - } - return false; - } catch { - return false; - } -} - -function CreditsChip({ - credits, - onBuyCredits, - onUpgrade, -}: { - credits: number; - onBuyCredits: () => void; - onUpgrade: () => void; -}) { - return ( - - } - triggerLabel={ - - {credits.toLocaleString()} - - } - buttonClassName="h-8 px-3 ps-2.5 bg-white/[0.04] hover:bg-white/[0.08] border border-white/[0.08] shadow-none text-white/80 rounded-lg gap-1.5" - panelClassName="mt-2 bg-[#1a1a1a] border border-white/[0.08] text-white rounded-xl" - > - {(close) => ( -
-
- - Your credit balance - - -
-
- - {credits.toLocaleString()} -
-
- -
-
- )} -
- ); -} +import { TopBarActions } from "./TopBarActions"; export function TopBar() { - const navigate = useNavigate(); - const { user, authChecked } = useSession(); const { state, isMobile } = useSidebar(); const showTopbarLogo = isMobile || state === "collapsed"; - const [credits, setCredits] = useState(null); - const [hasPaidPlan, setHasPaidPlan] = useState(null); - const [creditsModalOpen, setCreditsModalOpen] = useState(false); - const [settingsOpen, setSettingsOpen] = useState(false); - - useEffect(() => { - if (user) { - fetchCredits().then(setCredits); - fetchHasPaidPlan().then(setHasPaidPlan); - } else { - setCredits(null); - setHasPaidPlan(null); - } - }, [user]); - - useEffect(() => { - const handler = () => { - fetchCredits().then(setCredits); - }; - window.addEventListener("credits-change", handler); - return () => window.removeEventListener("credits-change", handler); - }, []); - - const handleLogout = async () => { - await new UsersApi().Logout(); - invalidateSession(); - window.location.href = "/"; - }; return (
@@ -176,203 +26,8 @@ export function TopBar() {
- {/* Middle: route-scoped chrome (scene title on /edit-3d, empty - elsewhere). Absolutely centered to the topbar (which already - spans only the canvas area, since it sits beside the sidebar) - so the title aligns horizontally with the Controls3D bar - regardless of the left/right widths. pointer-events:none on - the overlay lets clicks pass through to whatever's beneath; - the inner wrapper re-enables pointer-events for the title. */} -
-
- -
-
- - {/* Right: credits / upgrade / library / avatar */} -
- {!authChecked ? null : user ? ( - <> - - - Pricing - - - {credits !== null && ( - setCreditsModalOpen(true)} - onUpgrade={() => navigate("/pricing")} - /> - )} - - {hasPaidPlan === false && ( - - )} - - - - - - Open user menu - - - - -
-

- {user.display_name || user.username} -

-
- - {({ active }) => ( - - )} - - - {({ active }) => ( - - )} - - {user.maybe_feature_flags?.includes( - USER_FEATURE_FLAGS.REFERRALS, - ) && ( - - {({ active }) => ( - - )} - - )} - - {({ active }) => ( - - - Join Discord - - )} - - - {({ active }) => ( - - )} - -
-
-
- - ) : ( - <> - - - Pricing - - - Login - - - Sign up - - - )} -
- - setCreditsModalOpen(false)} - /> - setSettingsOpen(false)} - /> + {/* Right: credits / upgrade / task queue / avatar */} + ); } diff --git a/frontend/apps/artcraft-webapp/src/pages/create-image/create-image-store.ts b/frontend/apps/artcraft-webapp/src/pages/create-image/create-image-store.ts index 5504757cec..0c29f87ba0 100644 --- a/frontend/apps/artcraft-webapp/src/pages/create-image/create-image-store.ts +++ b/frontend/apps/artcraft-webapp/src/pages/create-image/create-image-store.ts @@ -1,4 +1,5 @@ import { create } from "zustand"; +import { persist } from "zustand/middleware"; import type { RecreatePayload } from "../../lib/recreate"; import type { RefImage } from "../../components/prompt-box"; @@ -60,77 +61,93 @@ const DEFAULT_UI: ImageUiState = { quality: undefined, }; -export const useCreateImageStore = create((set, get) => ({ - batches: [], - ui: { ...DEFAULT_UI }, - referenceImages: [], - pendingRecreate: null, +export const useCreateImageStore = create()( + persist( + (set, get) => ({ + batches: [], + ui: { ...DEFAULT_UI }, + referenceImages: [], + pendingRecreate: null, - setUi: (patch) => - set((s) => ({ ui: { ...s.ui, ...patch } })), + setUi: (patch) => + set((s) => ({ ui: { ...s.ui, ...patch } })), - setReferenceImages: (images) => set({ referenceImages: images }), + setReferenceImages: (images) => set({ referenceImages: images }), - setPendingRecreate: (payload) => set({ pendingRecreate: payload }), + setPendingRecreate: (payload) => set({ pendingRecreate: payload }), - consumePendingRecreate: () => { - const payload = get().pendingRecreate; - if (payload) set({ pendingRecreate: null }); - return payload; - }, + consumePendingRecreate: () => { + const payload = get().pendingRecreate; + if (payload) set({ pendingRecreate: null }); + return payload; + }, - startBatch: (prompt, requestedCount, modelLabel) => { - const id = crypto.randomUUID(); - const batch: ImageBatch = { - id, - prompt, - status: "pending", - images: [], - createdAt: Date.now(), - requestedCount, - modelLabel, - }; - set((s) => ({ batches: [...s.batches, batch] })); - return id; - }, + startBatch: (prompt, requestedCount, modelLabel) => { + const id = crypto.randomUUID(); + const batch: ImageBatch = { + id, + prompt, + status: "pending", + images: [], + createdAt: Date.now(), + requestedCount, + modelLabel, + }; + set((s) => ({ batches: [...s.batches, batch] })); + return id; + }, - setBatchJobToken: (batchId, jobToken) => { - set((s) => ({ - batches: s.batches.map((b) => - b.id === batchId ? { ...b, jobToken } : b, - ), - })); - }, + setBatchJobToken: (batchId, jobToken) => { + set((s) => ({ + batches: s.batches.map((b) => + b.id === batchId ? { ...b, jobToken } : b, + ), + })); + }, - completeBatch: (batchId, images) => { - set((s) => ({ - batches: s.batches.map((b) => - b.id === batchId - ? { ...b, status: "complete" as const, images: images.slice(0, 4) } - : b, - ), - })); - }, + completeBatch: (batchId, images) => { + set((s) => ({ + batches: s.batches.map((b) => + b.id === batchId + ? { ...b, status: "complete" as const, images: images.slice(0, 4) } + : b, + ), + })); + }, - failBatch: (batchId, reason) => { - set((s) => ({ - batches: s.batches.map((b) => - b.id === batchId - ? { ...b, status: "failed" as const, failureReason: reason } - : b, - ), - })); - }, + failBatch: (batchId, reason) => { + set((s) => ({ + batches: s.batches.map((b) => + b.id === batchId + ? { ...b, status: "failed" as const, failureReason: reason } + : b, + ), + })); + }, - dismissBatch: (id) => { - set((s) => ({ batches: s.batches.filter((b) => b.id !== id) })); - }, + dismissBatch: (id) => { + set((s) => ({ batches: s.batches.filter((b) => b.id !== id) })); + }, - clearCompleted: () => { - set((s) => ({ - batches: s.batches.filter((b) => b.status !== "complete"), - })); - }, + clearCompleted: () => { + set((s) => ({ + batches: s.batches.filter((b) => b.status !== "complete"), + })); + }, - reset: () => set({ batches: [] }), -})); + reset: () => set({ batches: [] }), + }), + { + name: "artcraft-image-prompt", + // Persist prompt + lightweight generation settings so a full page reload + // (e.g. returning from a credit top-up) keeps the user's draft. + // Reference images are excluded: File handles aren't serializable and + // uploaded images are base64 data URLs that can exceed the localStorage quota. + partialize: (state) => ({ ui: state.ui }), + merge: (persisted, current) => { + const persistedUi = (persisted as { ui?: Partial } | null)?.ui; + return { ...current, ui: { ...current.ui, ...(persistedUi ?? {}) } }; + }, + }, + ), +); diff --git a/frontend/apps/artcraft-webapp/src/pages/create-video/create-video-store.ts b/frontend/apps/artcraft-webapp/src/pages/create-video/create-video-store.ts index 5ec0388887..f0721ebf4e 100644 --- a/frontend/apps/artcraft-webapp/src/pages/create-video/create-video-store.ts +++ b/frontend/apps/artcraft-webapp/src/pages/create-video/create-video-store.ts @@ -159,9 +159,22 @@ export const useCreateVideoStore = create()( }), { name: "artcraft-video-batches", + // Persist prompt + lightweight settings alongside pending batches so a + // full page reload (e.g. returning from a credit top-up) keeps the + // user's draft. Reference media (refs) is excluded for the same reason + // as the image store: File handles + blob URLs don't survive serialization. partialize: (state) => ({ batches: state.batches.filter((b) => b.status === "pending"), + ui: state.ui, }), + merge: (persisted, current) => { + const p = (persisted ?? {}) as Partial; + return { + ...current, + ...p, // restores persisted pending batches + ui: { ...current.ui, ...((p.ui as Partial) ?? {}) }, + }; + }, }, ), ); diff --git a/frontend/apps/artcraft-webapp/src/pages/pagescene/demo-output-overlay.tsx b/frontend/apps/artcraft-webapp/src/pages/pagescene/demo-output-overlay.tsx index 6c27c25497..e5b674ab41 100644 --- a/frontend/apps/artcraft-webapp/src/pages/pagescene/demo-output-overlay.tsx +++ b/frontend/apps/artcraft-webapp/src/pages/pagescene/demo-output-overlay.tsx @@ -174,7 +174,7 @@ export function DemoOutputOverlay({ } return ( -
+
{body}
); @@ -216,7 +216,7 @@ function ShowOutputPill({ onClick }: { onClick: () => void }) { type="button" onClick={onClick} aria-label="Show rendered output" - className="pointer-events-auto absolute right-2 top-2 z-30 flex items-center gap-2 rounded-xl border border-ui-controls-border bg-ui-controls px-3 py-1.5 text-sm font-medium text-base-fg shadow-xl transition-colors duration-150 hover:bg-ui-controls/80 animate-in fade-in slide-in-from-right-4" + className="pointer-events-auto absolute right-2 top-16 z-30 flex items-center gap-2 rounded-xl border border-ui-controls-border bg-ui-controls px-3 py-1.5 text-sm font-medium text-base-fg shadow-xl transition-colors duration-150 hover:bg-ui-controls/80 animate-in fade-in slide-in-from-right-4" > Show output diff --git a/frontend/apps/artcraft-webapp/src/pages/pagescene/pagescene.tsx b/frontend/apps/artcraft-webapp/src/pages/pagescene/pagescene.tsx index 7867a95851..68a4f66f6a 100644 --- a/frontend/apps/artcraft-webapp/src/pages/pagescene/pagescene.tsx +++ b/frontend/apps/artcraft-webapp/src/pages/pagescene/pagescene.tsx @@ -18,6 +18,8 @@ import { useSession } from "../../lib/session"; import { useStage3DCostEstimate } from "../../lib/cost-estimate-api"; import { useSidebar } from "../../components/ui/sidebar"; import { useSignupCta } from "../../components/signup-cta-modal"; +import { ActiveSceneTitle } from "../../components/topbar/ActiveSceneTitle"; +import { TopBarActions } from "../../components/topbar/TopBarActions"; import Seo from "../../components/seo"; import { DemoOutputOverlay } from "./demo-output-overlay"; import { OtherScenesOverlay } from "./other-scenes-overlay"; @@ -298,6 +300,8 @@ function PageSceneEditor() { showImageTo3DButton={false} showHelpMenu={false} modelSelectorPlacement="prompt-box" + topBarStartSlot={} + topBarEndSlot={} promptboxAboveStackSlot={ ( @@ -120,9 +123,9 @@ const Pricing = () => {
- + {/* Plans - + */}

Invest in yourself.

diff --git a/frontend/apps/artcraft-webapp/tsconfig.tsbuildinfo b/frontend/apps/artcraft-webapp/tsconfig.tsbuildinfo new file mode 100644 index 0000000000..d16cf16732 --- /dev/null +++ b/frontend/apps/artcraft-webapp/tsconfig.tsbuildinfo @@ -0,0 +1 @@ +{"fileNames":[],"fileInfos":[],"root":[],"options":{"composite":true,"declarationMap":true,"emitDeclarationOnly":true,"importHelpers":true,"module":99,"noEmitOnError":true,"noFallthroughCasesInSwitch":true,"noImplicitOverride":true,"noImplicitReturns":true,"noUnusedLocals":true,"skipLibCheck":true,"strict":true,"target":9},"version":"5.8.3"} \ No newline at end of file diff --git a/frontend/libs/common/src/lib/utils/thumbnail-utils.ts b/frontend/libs/common/src/lib/utils/thumbnail-utils.ts index 94f7d17121..ae688e88fb 100644 --- a/frontend/libs/common/src/lib/utils/thumbnail-utils.ts +++ b/frontend/libs/common/src/lib/utils/thumbnail-utils.ts @@ -83,6 +83,10 @@ export function getContextImageThumbnail( media_links: { maybe_thumbnail_template?: string | null; cdn_url: string; + maybe_video_previews?: { + still: string; + still_thumbnail_template: string; + } | null; }; }, options: MediaThumbnailOptions = {}, @@ -90,6 +94,21 @@ export function getContextImageThumbnail( const { size = THUMBNAIL_SIZES.SMALL } = options; const fullSizeWidth = THUMBNAIL_SIZES.EXTRA_LARGE; + // For video refs, prefer the still-frame preview over the raw video CDN URL. + if ( + !contextImage.media_links.maybe_thumbnail_template && + contextImage.media_links.maybe_video_previews + ) { + const vp = contextImage.media_links.maybe_video_previews; + const thumbnail = vp.still_thumbnail_template + ? getThumbnailUrl(vp.still_thumbnail_template, { width: size }) || vp.still + : vp.still; + const fullSize = vp.still_thumbnail_template + ? getThumbnailUrl(vp.still_thumbnail_template, { width: fullSizeWidth }) || vp.still + : vp.still; + return { thumbnail, fullSize }; + } + const thumbnail = contextImage.media_links.maybe_thumbnail_template ? getThumbnailUrl(contextImage.media_links.maybe_thumbnail_template, { width: size, diff --git a/frontend/libs/components/button-dropdown/src/lib/button-dropdown.tsx b/frontend/libs/components/button-dropdown/src/lib/button-dropdown.tsx index dc489afb33..acb3a723b0 100644 --- a/frontend/libs/components/button-dropdown/src/lib/button-dropdown.tsx +++ b/frontend/libs/components/button-dropdown/src/lib/button-dropdown.tsx @@ -48,12 +48,14 @@ export const ButtonDropdown = ({ }: ButtonDropdownProps) => { const [isOpen, setIsOpen] = useState(false); const [selectedOptionIndex, setSelectedOptionIndex] = useState( - null + null, ); const closeModal = () => { setIsOpen(false); - options[selectedOptionIndex!].dialogProps?.onClose?.(); + if (selectedOptionIndex !== null) { + options[selectedOptionIndex]?.dialogProps?.onClose?.(); + } }; const handleOptionClick = (index: number) => { @@ -72,7 +74,7 @@ export const ButtonDropdown = ({ const currentDialogProps = selectedOptionIndex !== null - ? options[selectedOptionIndex].dialogProps + ? options[selectedOptionIndex]?.dialogProps : null; return ( @@ -102,7 +104,7 @@ export const ButtonDropdown = ({ static className={twMerge( "absolute z-20 mt-1 w-max divide-y divide-gray-100 overflow-hidden rounded-lg bg-brand-secondary py-1.5 shadow-xl focus:outline-none", - align === "left" ? "left-0" : "right-0" + align === "left" ? "left-0" : "right-0", )} >
@@ -122,7 +124,7 @@ export const ButtonDropdown = ({ ? "pointer-events-none opacity-40" : "", "group flex w-full items-center py-1.5 pl-7 pr-4 text-sm", - option.className + option.className, )} onClick={() => handleOptionClick(index)} > diff --git a/frontend/libs/components/lightbox-modal/src/lib/lightbox-modal.tsx b/frontend/libs/components/lightbox-modal/src/lib/lightbox-modal.tsx index 3948832abe..5cb66f9d6b 100644 --- a/frontend/libs/components/lightbox-modal/src/lib/lightbox-modal.tsx +++ b/frontend/libs/components/lightbox-modal/src/lib/lightbox-modal.tsx @@ -10,11 +10,14 @@ import { faDownToLine, faGlobe, faMagnifyingGlass, + faPause, faPencil, + faPlay, faTrashCan, faVideo, faWandMagicSparkles, faArrowRotateRight, + faWaveformLines, } from "@fortawesome/pro-solid-svg-icons"; import { MediaFileDelete } from "@storyteller/tauri-api"; import { LoadingSpinner } from "@storyteller/ui-loading-spinner"; @@ -168,6 +171,36 @@ export function LightboxModal({ const [creator, setCreator] = useState(null); const [shareCopied, setShareCopied] = useState(false); const shareCopiedTimeoutRef = useRef(null); + const [playingAudioToken, setPlayingAudioToken] = useState(null); + const audioPlayerRef = useRef(null); + + const stopAudio = useCallback(() => { + if (audioPlayerRef.current) { + audioPlayerRef.current.pause(); + audioPlayerRef.current = null; + } + setPlayingAudioToken(null); + }, []); + + const handleAudioToggle = useCallback( + (token: string, url: string) => { + if (playingAudioToken === token) { + stopAudio(); + } else { + stopAudio(); + const audio = new Audio(url); + audio.volume = 0.7; + audio.onended = () => { + setPlayingAudioToken(null); + audioPlayerRef.current = null; + }; + audioPlayerRef.current = audio; + audio.play(); + setPlayingAudioToken(token); + } + }, + [playingAudioToken, stopAudio], + ); // Stable API instances const mediaFilesApi = useMemo(() => new MediaFilesApi(), []); @@ -182,6 +215,7 @@ export function LightboxModal({ setMediaHeight(null); setShareCopied(false); setIsPromptExpanded(false); + stopAudio(); if (shareCopiedTimeoutRef.current) { window.clearTimeout(shareCopiedTimeoutRef.current); shareCopiedTimeoutRef.current = null; @@ -870,14 +904,17 @@ export function LightboxModal({ {contextImages && contextImages.length > 0 && (
- Reference Images + Reference Media
{contextImages.map((contextImage, index) => { + const isAudio = contextImage.semantic === "audioref"; + const isVideoRef = contextImage.semantic === "vid_ref"; const { thumbnail, fullSize } = getContextImageThumbnail(contextImage, { size: THUMBNAIL_SIZES.SMALL, }); + const isPlayingThis = playingAudioToken === contextImage.media_token; return ( - + {!isAudio && !isVideoRef && ( + + )} {onNavigateToMedia && (