mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-31 07:27:51 +00:00
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:
1 parent
9c01f1387a
commit
c204f05fdb
19 files changed
+704
-478
No files matched your search
@@ -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" />
|
||||
}
|
||||
|
||||
+11
-10
@@ -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'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've made in
|
||||
this session and restore the scene to the version the
|
||||
author shared. This cannot be undone.
|
||||
</p>
|
||||
),
|
||||
confirmButtonProps: {
|
||||
label: "Reset",
|
||||
|
||||
Reference in new issue
Block a user