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 <noreply@anthropic.com>
This commit is contained in:
BFlatandClaude Opus 4.7 authored and GitHub committed 2026-05-22 14:05:19 +07:00
1 parent 9c01f1387a
commit c204f05fdb
19 files changed
+704 -478

No files matched your search

+10 -1
View File
@@ -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 (
<div
className="flex flex-1 flex-col min-w-0 h-svh overflow-hidden"
style={{ "--ac-sidebar-offset": sidebarOffset } as React.CSSProperties}
>
<TopBar />
{!hideTopBar && <TopBar />}
<SidebarInset className="flex-1 min-h-0 overflow-y-auto bg-[#121212]">
<Outlet />
</SidebarInset>
@@ -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<string | null>(null);
const audioPlayerRef = useRef<HTMLAudioElement | null>(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({
<div className="space-y-2">
<div className="flex items-center gap-2 text-xs font-medium text-white/60">
<FontAwesomeIcon icon={faImage} />
<span>Reference Images</span>
<span>Reference Media</span>
</div>
<div className="grid grid-cols-5 gap-2">
{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 (
<button
key={contextImage.media_token}
type="button"
onClick={() => handleAudioToggle(contextImage.media_token, contextImage.media_links.cdn_url)}
className="relative aspect-square overflow-hidden rounded-lg border border-white/10 hover:border-white/40 transition-colors flex items-center justify-center bg-white/5"
>
<FontAwesomeIcon
icon={isPlayingThis ? faPause : faPlay}
className="text-white/70 text-lg"
/>
</button>
);
}
return (
<a
key={contextImage.media_token}
@@ -277,6 +331,14 @@ export function LightboxDetails({
alt={`Reference ${index + 1}`}
className="h-full w-full object-cover"
/>
{isVideoRef && (
<div className="absolute inset-0 flex items-center justify-center">
<FontAwesomeIcon
icon={faVideo}
className="text-white/80 text-sm drop-shadow-lg"
/>
</div>
)}
</a>
);
})}
@@ -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;
}
@@ -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 (
<div className="flex min-w-0 items-center justify-center gap-1.5">
<div className="flex min-w-0 items-center justify-start gap-1.5">
{!isEditing && (
<div className="flex min-w-0 items-center gap-2.5">
<span className="text-nowrap text-sm opacity-60">{PAGE_NAME}</span>
<span className="text-sm opacity-60">/</span>
{canRename ? (
<button
type="button"
onClick={startEdit}
title="Rename scene"
aria-label="Rename scene"
className="-ml-1 flex min-w-0 items-center rounded-md px-2 py-1 text-sm font-semibold text-white transition-all hover:cursor-text hover:bg-white/[0.08]"
className="flex min-w-0 max-w-[280px] items-center rounded-lg border border-transparent px-3 py-1.5 text-sm font-semibold text-white transition-all hover:cursor-text hover:bg-white/[0.08]"
>
<span className="truncate">{title}</span>
<FontAwesomeIcon
@@ -134,7 +131,7 @@ export function ActiveSceneTitle() {
/>
</button>
) : (
<div className="-ml-1 truncate rounded-md px-2 py-1 text-sm font-semibold text-white">
<div className="max-w-[280px] truncate rounded-lg border border-transparent px-3 py-1.5 text-sm font-semibold text-white/80">
{title}
</div>
)}
@@ -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",
)}
/>
)}
@@ -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<number | null> {
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<boolean> {
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<number | null>(null);
const [hasPaidPlan, setHasPaidPlan] = useState<boolean | null>(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 (
<div className={twMerge("flex items-center gap-2 shrink-0", className)}>
{!authChecked ? null : user ? (
<>
<Link
to="/pricing"
className="hidden lg:flex h-8 items-center gap-1.5 px-3 rounded-lg text-sm font-medium text-white/85 hover:bg-white/[0.04] transition-colors"
>
<FontAwesomeIcon icon={faGem} className="text-[11px]" />
Pricing
</Link>
{credits !== null && (
<CreditsChip
credits={credits}
onBuyCredits={() => setCreditsModalOpen(true)}
onUpgrade={() => navigate("/pricing")}
/>
)}
{hasPaidPlan === false && (
<Button
variant="primary"
icon={faGem}
onClick={() => navigate("/pricing")}
className="h-8 px-3 text-sm font-semibold rounded-lg"
>
Upgrade
</Button>
)}
<TaskQueue />
<Menu as="div" className="relative ml-1">
<MenuButton className="flex h-8 w-8 rounded-full overflow-hidden focus:outline-none focus:ring-2 focus:ring-primary/40 ring-offset-2 ring-offset-[#121212]">
<span className="sr-only">Open user menu</span>
<img
className="h-full w-full object-cover"
src={`https://www.gravatar.com/avatar/${user.email_gravatar_hash}?d=mp`}
alt=""
/>
</MenuButton>
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<MenuItems
modal={false}
className="absolute right-0 z-50 mt-2 w-48 origin-top-right rounded-xl bg-[#1a1a1a] border border-white/[0.08] shadow-xl focus:outline-none overflow-hidden"
>
<div className="px-4 py-3 border-b border-white/[0.06]">
<p className="text-sm font-medium text-white truncate">
{user.display_name || user.username}
</p>
</div>
<MenuItem>
{({ active }) => (
<button
onClick={() => setSettingsOpen(true)}
className={twMerge(
active ? "bg-white/[0.04]" : "",
"flex w-full items-center gap-2 px-4 py-2 text-sm text-white/70 transition-colors",
)}
>
<FontAwesomeIcon
icon={faCog}
className="text-[11px] text-white/50"
/>
Settings
</button>
)}
</MenuItem>
<MenuItem>
{({ active }) => (
<button
onClick={() => navigate("/support")}
className={twMerge(
active ? "bg-white/[0.04]" : "",
"flex w-full items-center gap-2 px-4 py-2 text-sm text-white/70 transition-colors",
)}
>
<FontAwesomeIcon
icon={faLifeRing}
className="text-[11px] text-white/50"
/>
Support
</button>
)}
</MenuItem>
{user.maybe_feature_flags?.includes(
USER_FEATURE_FLAGS.REFERRALS,
) && (
<MenuItem>
{({ active }) => (
<button
onClick={() => navigate("/referrals")}
className={twMerge(
active ? "bg-white/[0.04]" : "",
"flex w-full items-center gap-2 px-4 py-2 text-sm text-white/70 transition-colors",
)}
>
<FontAwesomeIcon
icon={faGift}
className="text-[11px] text-white/50"
/>
Referrals
</button>
)}
</MenuItem>
)}
<MenuItem>
{({ active }) => (
<a
href={SOCIAL_LINKS.DISCORD}
target="_blank"
rel="noopener noreferrer"
className={twMerge(
active ? "bg-white/[0.04]" : "",
"flex w-full items-center gap-2 px-4 py-2 text-sm text-white/70 transition-colors",
)}
>
<FontAwesomeIcon
icon={faDiscord}
className="text-[11px] text-white/50"
/>
Join Discord
</a>
)}
</MenuItem>
<MenuItem>
{({ active }) => (
<button
onClick={handleLogout}
className={twMerge(
active ? "bg-red-500/10" : "",
"block w-full text-left px-4 py-2 text-sm text-red-400 hover:text-red-300 transition-colors",
)}
>
Sign out
</button>
)}
</MenuItem>
</MenuItems>
</Transition>
</Menu>
</>
) : (
<>
<Link
to="/pricing"
className="hidden lg:flex h-8 items-center gap-1.5 px-3 rounded-lg text-sm font-medium text-white/85 hover:bg-white/[0.04] transition-colors"
>
<FontAwesomeIcon icon={faGem} className="text-[11px]" />
Pricing
</Link>
<Link
to="/login"
className="h-8 flex items-center px-3 rounded-lg text-sm font-medium text-white/80 hover:text-white hover:bg-white/[0.04] transition-all"
>
Login
</Link>
<Link
to="/signup"
className="h-8 flex items-center gap-1.5 px-3.5 rounded-full text-sm font-semibold text-black bg-white hover:bg-white/90 transition-all shadow-sm"
>
Sign up
</Link>
</>
)}
<CreditsModal
isOpen={creditsModalOpen}
onClose={() => setCreditsModalOpen(false)}
/>
<SettingsModal
isOpen={settingsOpen}
onClose={() => setSettingsOpen(false)}
/>
</div>
);
}
function CreditsChip({
credits,
onBuyCredits,
onUpgrade,
}: {
credits: number;
onBuyCredits: () => void;
onUpgrade: () => void;
}) {
return (
<PopoverMenu
position="bottom"
align="end"
triggerIcon={
<FontAwesomeIcon icon={faCoins} className="text-primary text-[11px]" />
}
triggerLabel={
<span className="whitespace-nowrap text-sm font-medium">
{credits.toLocaleString()}
</span>
}
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) => (
<div className="w-72 max-w-[calc(100vw-24px)] p-3 text-white">
<div className="mb-2 flex items-center justify-between">
<span className="text-sm font-medium text-white/70">
Your credit balance
</span>
<button
className="text-sm font-medium text-primary hover:text-primary-300 transition-colors"
onClick={() => {
close();
onBuyCredits();
}}
>
Buy credits
</button>
</div>
<div className="flex items-center gap-2 text-3xl font-semibold text-white tracking-tight">
<FontAwesomeIcon icon={faCoins} className="text-xl text-primary" />
{credits.toLocaleString()}
</div>
<div className="mt-3 flex gap-2">
<Button
variant="primary"
className="h-9 grow"
onClick={() => {
close();
onUpgrade();
}}
icon={faGem}
>
Upgrade
</Button>
</div>
</div>
)}
</PopoverMenu>
);
}
@@ -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<number | null> {
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<boolean> {
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 (
<PopoverMenu
position="bottom"
align="end"
triggerIcon={
<FontAwesomeIcon icon={faCoins} className="text-primary text-[11px]" />
}
triggerLabel={
<span className="whitespace-nowrap text-sm font-medium">
{credits.toLocaleString()}
</span>
}
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) => (
<div className="w-72 max-w-[calc(100vw-24px)] p-3 text-white">
<div className="mb-2 flex items-center justify-between">
<span className="text-sm font-medium text-white/70">
Your credit balance
</span>
<button
className="text-sm font-medium text-primary hover:text-primary-300 transition-colors"
onClick={() => {
close();
onBuyCredits();
}}
>
Buy credits
</button>
</div>
<div className="flex items-center gap-2 text-3xl font-semibold text-white tracking-tight">
<FontAwesomeIcon icon={faCoins} className="text-xl text-primary" />
{credits.toLocaleString()}
</div>
<div className="mt-3 flex gap-2">
<Button
variant="primary"
className="h-9 grow"
onClick={() => {
close();
onUpgrade();
}}
icon={faGem}
>
Upgrade
</Button>
</div>
</div>
)}
</PopoverMenu>
);
}
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<number | null>(null);
const [hasPaidPlan, setHasPaidPlan] = useState<boolean | null>(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 (
<header className="sticky top-0 z-20 relative flex items-center gap-3 border-b border-white/[0.06] bg-[#121212]/80 backdrop-blur-md px-3 pb-4 pt-3 sm:pt-6">
@@ -176,203 +26,8 @@ export function TopBar() {
</div>
</div>
{/* 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. */}
<div className="pointer-events-none absolute inset-x-0 inset-y-0 flex items-center justify-center pb-4 pt-3 sm:pt-6">
<div className="pointer-events-auto">
<ActiveSceneTitle />
</div>
</div>
{/* Right: credits / upgrade / library / avatar */}
<div className="ml-auto flex items-center gap-2 shrink-0">
{!authChecked ? null : user ? (
<>
<Link
to="/pricing"
className="hidden lg:flex h-8 items-center gap-1.5 px-3 rounded-lg text-sm font-medium text-white/85 hover:bg-white/[0.04] transition-colors"
>
<FontAwesomeIcon icon={faGem} className="text-[11px]" />
Pricing
</Link>
{credits !== null && (
<CreditsChip
credits={credits}
onBuyCredits={() => setCreditsModalOpen(true)}
onUpgrade={() => navigate("/pricing")}
/>
)}
{hasPaidPlan === false && (
<Button
variant="primary"
icon={faGem}
onClick={() => navigate("/pricing")}
className="h-8 px-3 text-sm font-semibold rounded-lg"
>
Upgrade
</Button>
)}
<TaskQueue />
<Menu as="div" className="relative ml-1">
<MenuButton className="flex h-8 w-8 rounded-full overflow-hidden focus:outline-none focus:ring-2 focus:ring-primary/40 ring-offset-2 ring-offset-[#121212]">
<span className="sr-only">Open user menu</span>
<img
className="h-full w-full object-cover"
src={`https://www.gravatar.com/avatar/${user.email_gravatar_hash}?d=mp`}
alt=""
/>
</MenuButton>
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<MenuItems
modal={false}
className="absolute right-0 z-50 mt-2 w-48 origin-top-right rounded-xl bg-[#1a1a1a] border border-white/[0.08] shadow-xl focus:outline-none overflow-hidden"
>
<div className="px-4 py-3 border-b border-white/[0.06]">
<p className="text-sm font-medium text-white truncate">
{user.display_name || user.username}
</p>
</div>
<MenuItem>
{({ active }) => (
<button
onClick={() => setSettingsOpen(true)}
className={twMerge(
active ? "bg-white/[0.04]" : "",
"flex w-full items-center gap-2 px-4 py-2 text-sm text-white/70 transition-colors",
)}
>
<FontAwesomeIcon
icon={faCog}
className="text-[11px] text-white/50"
/>
Settings
</button>
)}
</MenuItem>
<MenuItem>
{({ active }) => (
<button
onClick={() => navigate("/support")}
className={twMerge(
active ? "bg-white/[0.04]" : "",
"flex w-full items-center gap-2 px-4 py-2 text-sm text-white/70 transition-colors",
)}
>
<FontAwesomeIcon
icon={faLifeRing}
className="text-[11px] text-white/50"
/>
Support
</button>
)}
</MenuItem>
{user.maybe_feature_flags?.includes(
USER_FEATURE_FLAGS.REFERRALS,
) && (
<MenuItem>
{({ active }) => (
<button
onClick={() => navigate("/referrals")}
className={twMerge(
active ? "bg-white/[0.04]" : "",
"flex w-full items-center gap-2 px-4 py-2 text-sm text-white/70 transition-colors",
)}
>
<FontAwesomeIcon
icon={faGift}
className="text-[11px] text-white/50"
/>
Referrals
</button>
)}
</MenuItem>
)}
<MenuItem>
{({ active }) => (
<a
href={SOCIAL_LINKS.DISCORD}
target="_blank"
rel="noopener noreferrer"
className={twMerge(
active ? "bg-white/[0.04]" : "",
"flex w-full items-center gap-2 px-4 py-2 text-sm text-white/70 transition-colors",
)}
>
<FontAwesomeIcon
icon={faDiscord}
className="text-[11px] text-white/50"
/>
Join Discord
</a>
)}
</MenuItem>
<MenuItem>
{({ active }) => (
<button
onClick={handleLogout}
className={twMerge(
active ? "bg-red-500/10" : "",
"block w-full text-left px-4 py-2 text-sm text-red-400 hover:text-red-300 transition-colors",
)}
>
Sign out
</button>
)}
</MenuItem>
</MenuItems>
</Transition>
</Menu>
</>
) : (
<>
<Link
to="/pricing"
className="hidden lg:flex h-8 items-center gap-1.5 px-3 rounded-lg text-sm font-medium text-white/85 hover:bg-white/[0.04] transition-colors"
>
<FontAwesomeIcon icon={faGem} className="text-[11px]" />
Pricing
</Link>
<Link
to="/login"
className="h-8 flex items-center px-3 rounded-lg text-sm font-medium text-white/80 hover:text-white hover:bg-white/[0.04] transition-all"
>
Login
</Link>
<Link
to="/signup"
className="h-8 flex items-center gap-1.5 px-3.5 rounded-full text-sm font-semibold text-black bg-white hover:bg-white/90 transition-all shadow-sm"
>
Sign up
</Link>
</>
)}
</div>
<CreditsModal
isOpen={creditsModalOpen}
onClose={() => setCreditsModalOpen(false)}
/>
<SettingsModal
isOpen={settingsOpen}
onClose={() => setSettingsOpen(false)}
/>
{/* Right: credits / upgrade / task queue / avatar */}
<TopBarActions className="ml-auto" />
</header>
);
}
@@ -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<CreateImageState>((set, get) => ({
batches: [],
ui: { ...DEFAULT_UI },
referenceImages: [],
pendingRecreate: null,
export const useCreateImageStore = create<CreateImageState>()(
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<ImageUiState> } | null)?.ui;
return { ...current, ui: { ...current.ui, ...(persistedUi ?? {}) } };
},
},
),
);
@@ -159,9 +159,22 @@ export const useCreateVideoStore = create<CreateVideoState>()(
}),
{
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<CreateVideoState>;
return {
...current,
...p, // restores persisted pending batches
ui: { ...current.ui, ...((p.ui as Partial<VideoUiState>) ?? {}) },
};
},
},
),
);
@@ -174,7 +174,7 @@ export function DemoOutputOverlay({
}
return (
<div className="pointer-events-none absolute right-2 top-2 z-30 w-[30%] min-w-[260px] max-w-lg animate-in fade-in slide-in-from-right-8 duration-500">
<div className="pointer-events-none absolute right-2 top-16 z-30 w-[30%] min-w-[260px] max-w-lg animate-in fade-in slide-in-from-right-8 duration-500">
{body}
</div>
);
@@ -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"
>
<FontAwesomeIcon icon={faImage} className="h-3 w-3 text-primary" />
Show output
@@ -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={<ActiveSceneTitle />}
topBarEndSlot={<TopBarActions />}
promptboxAboveStackSlot={
<OtherScenesOverlay
currentSceneToken={sceneToken}
@@ -5,7 +5,10 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Button } from "@storyteller/ui-button";
import { UsersApi } from "@storyteller/api";
import Seo from "../../components/seo";
import { PricingTable, PricingPromoBanner } from "@storyteller/ui-pricing-table";
import {
PricingTable,
PricingPromoBanner,
} from "@storyteller/ui-pricing-table";
import { CreditsModal } from "../../components/credits-modal";
const SeedanceBanner = () => (
@@ -120,9 +123,9 @@ const Pricing = () => {
<div className="max-w-6xl mx-auto">
<PricingPromoBanner className="mb-10 sm:mb-12" />
<div className="text-center mb-14" data-reveal>
<span className="inline-block text-sm font-semibold uppercase tracking-[0.18em] text-primary mb-3">
{/* <span className="inline-block text-sm font-semibold uppercase tracking-[0.18em] text-primary mb-3">
Plans
</span>
</span> */}
<h1 className="text-4xl sm:text-5xl md:text-6xl tracking-[-0.035em] font-medium leading-[1.02] mb-5">
Invest in <span className="font-serif-italic">yourself</span>.
</h1>
@@ -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"}
@@ -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,
@@ -48,12 +48,14 @@ export const ButtonDropdown = ({
}: ButtonDropdownProps) => {
const [isOpen, setIsOpen] = useState(false);
const [selectedOptionIndex, setSelectedOptionIndex] = useState<number | null>(
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",
)}
>
<div>
@@ -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)}
>
@@ -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<UserInfo | null>(null);
const [shareCopied, setShareCopied] = useState<boolean>(false);
const shareCopiedTimeoutRef = useRef<number | null>(null);
const [playingAudioToken, setPlayingAudioToken] = useState<string | null>(null);
const audioPlayerRef = useRef<HTMLAudioElement | null>(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 && (
<div className="space-y-1.5">
<div className="text-sm font-medium text-base-fg/90">
Reference Images
Reference Media
</div>
<div className="grid grid-cols-5 gap-1.5 w-fit">
{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 (
<Tooltip
@@ -890,19 +927,21 @@ export function LightboxModal({
className="p-1"
content={
<div className="flex flex-col gap-1.5 min-w-[100px]">
<button
className="text-xs text-left text-base-fg/80 hover:text-base-fg transition-colors py-1 px-1 rounded hover:bg-white/5"
onClick={(e) => {
e.stopPropagation();
setRefPreviewUrl(fullSize);
}}
>
<FontAwesomeIcon
icon={faMagnifyingGlass}
className="mr-1.5"
/>
Preview image
</button>
{!isAudio && !isVideoRef && (
<button
className="text-xs text-left text-base-fg/80 hover:text-base-fg transition-colors py-1 px-1 rounded hover:bg-white/5"
onClick={(e) => {
e.stopPropagation();
setRefPreviewUrl(fullSize);
}}
>
<FontAwesomeIcon
icon={faMagnifyingGlass}
className="mr-1.5"
/>
Preview image
</button>
)}
{onNavigateToMedia && (
<button
className="text-xs text-left text-base-fg/80 hover:text-base-fg transition-colors py-1 px-1 rounded hover:bg-white/5"
@@ -925,13 +964,38 @@ export function LightboxModal({
>
<div
className="glass relative aspect-square overflow-hidden rounded-lg w-12 border-2 border-white/30 hover:border-white/80 transition-all group cursor-pointer"
onClick={() => setRefPreviewUrl(fullSize)}
onClick={() => {
if (isAudio) {
handleAudioToggle(contextImage.media_token, contextImage.media_links.cdn_url);
} else if (!isVideoRef) {
setRefPreviewUrl(fullSize);
}
}}
>
<img
src={thumbnail}
alt={`Reference image ${index + 1}`}
className="h-full w-full object-cover"
/>
{isAudio ? (
<div className="h-full w-full flex items-center justify-center bg-white/5">
<FontAwesomeIcon
icon={isPlayingThis ? faPause : faPlay}
className="text-base-fg/70 text-lg"
/>
</div>
) : (
<>
<img
src={thumbnail}
alt={`Reference ${index + 1}`}
className="h-full w-full object-cover"
/>
{isVideoRef && (
<div className="absolute inset-0 flex items-center justify-center">
<FontAwesomeIcon
icon={faVideo}
className="text-white/80 text-sm drop-shadow-lg"
/>
</div>
)}
</>
)}
</div>
</Tooltip>
);
@@ -49,6 +49,15 @@ export interface Stage3DProps {
* "See other demo scenes" affordance so the button stacks above
* the prompt input instead of floating loose over the canvas. */
promptboxAboveStackSlot?: React.ReactNode;
/** Optional content rendered in the top toolbar's left cluster, right
* after the File/Outliner/Shortcuts buttons. Tauri leaves this unset;
* the webapp uses it for the editable scene title. */
topBarStartSlot?: React.ReactNode;
/** Optional content rendered in the top toolbar's right cluster
* (before the anonymous hint chip). Tauri leaves this unset; the
* webapp uses it to host the relocated nav actions (pricing, credits,
* task queue, profile) since its global header is hidden here. */
topBarEndSlot?: React.ReactNode;
}
export const Stage3D = ({
@@ -61,6 +70,8 @@ export const Stage3D = ({
showHelpMenu = true,
modelSelectorPlacement = "bottom-left",
promptboxAboveStackSlot,
topBarStartSlot,
topBarEndSlot,
}: Stage3DProps) => {
// Engine's remountEngine() gate reads is3DPageMounted. With Stage3D
// mounting only when the host's tab/route puts us on screen, the
@@ -86,6 +97,8 @@ export const Stage3D = ({
showHelpMenu={showHelpMenu}
modelSelectorPlacement={modelSelectorPlacement}
promptboxAboveStackSlot={promptboxAboveStackSlot}
topBarStartSlot={topBarStartSlot}
topBarEndSlot={topBarEndSlot}
/>
<DragComponent />
<PrecisionSelector />
@@ -89,6 +89,14 @@ export interface Stage3DBodyProps {
* "See other demo scenes" affordance so the button stacks above
* the prompt input instead of floating loose over the canvas. */
promptboxAboveStackSlot?: React.ReactNode;
/** Optional content rendered in the top toolbar's left cluster, right
* after the File/Outliner/Shortcuts buttons. Used by the webapp for
* the editable scene title; Tauri leaves it unset. */
topBarStartSlot?: React.ReactNode;
/** Optional content rendered in the top toolbar's right cluster, before
* the anonymous hint chip. Used by the webapp to host the relocated
* nav actions; Tauri leaves it unset. */
topBarEndSlot?: React.ReactNode;
}
export const Stage3DBody = ({
@@ -97,6 +105,8 @@ export const Stage3DBody = ({
showHelpMenu = true,
modelSelectorPlacement = "bottom-left",
promptboxAboveStackSlot,
topBarStartSlot,
topBarEndSlot,
}: Stage3DBodyProps = {}) => {
const camAspect = usePageSceneStore((s) => s.cameraAspectRatio);
const outlinerShowing = usePageSceneStore((s) => s.outlinerShowing);
@@ -431,9 +441,15 @@ export const Stage3DBody = ({
onClick={handleOverlayClick}
>
<div className="grid grid-cols-3 gap-4">
<ControlsTopButtons />
<div className="flex flex-col items-start gap-2">
<ControlsTopButtons />
{topBarStartSlot && (
<div className="pl-3">{topBarStartSlot}</div>
)}
</div>
<Controls3D showImageTo3DButton={showImageTo3DButton} />
<div className="flex items-start justify-end gap-2 pr-2 pt-2">
<div className="flex items-start justify-end gap-2 pr-3 pt-3">
{topBarEndSlot}
<AnonHintChip />
</div>
</div>
@@ -474,7 +490,7 @@ export const Stage3DBody = ({
uploadImage={
editor
? (((arg: Parameters<typeof editor.adapter.uploadImage>[0]) =>
editor.adapter.uploadImage(arg)) as never)
editor.adapter.uploadImage(arg)) as never)
: undefined
}
handleCameraSelect={handleCameraSelect}
@@ -25,10 +25,7 @@ import { twMerge } from "tailwind-merge";
import { EngineContext } from "../../contexts/EngineContext/EngineContext";
import { setTransformMode } from "../../actions";
import {
usePageSceneStore,
type TransformMode,
} from "../../PageSceneStore";
import { usePageSceneStore, type TransformMode } from "../../PageSceneStore";
import { AssetModal } from "../AssetMenu";
export interface Controls3DProps {
@@ -168,8 +165,8 @@ export const Controls3D = ({
return (
<>
<div className="flex justify-center pt-2">
<div className="glass rounded-2xl p-1.5 text-white shadow-xl">
<div className="flex justify-center pt-3">
<div className="glass rounded-2xl p-1.5 text-white shadow-xl h-fit">
<div className="flex items-center justify-center gap-1.5">
<div className="flex items-center gap-1.5">
<div className="relative">
@@ -252,10 +249,11 @@ export const Controls3D = ({
]}
onPanelAction={handleAddAssetAction}
showIconsInList
buttonClassName={`h-9 w-9 rounded-xl text-lg ${showEmptySceneTooltip
? "bg-primary/90 hover:bg-primary/70"
: "border-transparent bg-primary/90 hover:bg-primary/70"
}`}
buttonClassName={`h-9 w-9 rounded-xl text-lg ${
showEmptySceneTooltip
? "bg-primary/90 hover:bg-primary/70"
: "border-transparent bg-primary/90 hover:bg-primary/70"
}`}
triggerIcon={
<FontAwesomeIcon icon={faPlus} className="text-xl" />
}
@@ -12,7 +12,10 @@ import { Modal } from "@storyteller/ui-modal";
import { twMerge } from "tailwind-merge";
import { EngineContext } from "../../contexts/EngineContext/EngineContext";
import { usePageSceneStore, useIsVisitingOthersScene } from "../../PageSceneStore";
import {
usePageSceneStore,
useIsVisitingOthersScene,
} from "../../PageSceneStore";
import { CameraAspectRatio, ToastTypes } from "../../enums";
import { getSceneGenerationMetaData } from "../../sceneMetadata";
import { LoadUserScenes } from "./LoadUserScenes";
@@ -240,7 +243,7 @@ export const ControlsTopButtons = () => {
};
return (
<div className="flex flex-col gap-2 pl-2 pt-2">
<div className="flex flex-col gap-2 pl-3 pt-3">
<div className="flex gap-1.5">
<ButtonDropdown
label="File"
@@ -256,8 +259,7 @@ export const ControlsTopButtons = () => {
// to the inline confirm dialog.
...(editor?.adapter.onRequestNewSceneSelector
? {
onClick: () =>
editor.adapter.onRequestNewSceneSelector?.(),
onClick: () => editor.adapter.onRequestNewSceneSelector?.(),
}
: {
dialogProps: {
@@ -359,12 +361,11 @@ export const ControlsTopButtons = () => {
dialogProps: {
title: "Reset scene to original?",
content: (
<h4>
This will discard every change you&apos;ve made
in this session and restore the scene to the
version the author shared. This cannot be
undone.
</h4>
<p>
This will discard every change you&apos;ve made in
this session and restore the scene to the version the
author shared. This cannot be undone.
</p>
),
confirmButtonProps: {
label: "Reset",