Spaces:
Running on CPU Upgrade
Running on CPU Upgrade
| import { useEffect, useMemo, useState } from "react"; | |
| import type { Project, Scene } from "../api/client"; | |
| import { generateSceneImage } from "../api/client"; | |
| import { formatAiEditCreditsDisplay } from "../lib/formatAiEditCredits"; | |
| // AI image generation costs this many AI-edit credits per image (all plans). | |
| // Kept in sync with the backend GENERATE_IMAGE_CREDIT_COST. | |
| export const AI_IMAGE_CREDIT_COST = 3; | |
| export interface GenerateSceneImageModalProps { | |
| open: boolean; | |
| onClose: () => void; | |
| scene: Scene; | |
| project: Project; | |
| /** | |
| * Whether the owner has enough AI-edit budget for one generation (monthly | |
| * allowance + purchased pool). | |
| */ | |
| canGenerate: boolean; | |
| /** Credits one generation costs (shown in the modal for every plan). */ | |
| creditCost: number; | |
| /** | |
| * True when the viewer is a collaborator on a shared project (spending the OWNER's | |
| * credit pool). They can't fix an exhausted pool by upgrading their own plan. | |
| */ | |
| isCollaborator?: boolean; | |
| /** Total AI-edit budget available (allowance + purchased pool). */ | |
| creditsRemaining: number; | |
| /** True when the blocker is the OWNER's exhausted access, not the viewer's own. */ | |
| ownerBlocked?: boolean; | |
| onUpgrade: () => void; | |
| /** Navigate to the billing page from the inline "Upgrade now" link. */ | |
| onUpgradeNow: () => void; | |
| /** Shown instead of the self-upgrade prompt when `ownerBlocked`. */ | |
| onOwnerBlocked?: () => void; | |
| onImageReady: (imageBase64: string, refinedPrompt: string) => void; | |
| onGenerateStart?: () => void; | |
| onGenerateError?: (message: string) => void; | |
| } | |
| export default function GenerateSceneImageModal({ | |
| open, | |
| onClose, | |
| scene, | |
| project, | |
| canGenerate, | |
| creditCost, | |
| isCollaborator = false, | |
| creditsRemaining, | |
| ownerBlocked, | |
| onUpgrade, | |
| onUpgradeNow, | |
| onOwnerBlocked, | |
| onImageReady, | |
| onGenerateStart, | |
| onGenerateError, | |
| }: GenerateSceneImageModalProps) { | |
| const [imageDescription, setImageDescription] = useState(""); | |
| const [error, setError] = useState<string | null>(null); | |
| const [generating, setGenerating] = useState(false); | |
| const placeholderHint = useMemo(() => { | |
| const v = (scene.visual_description || "").trim(); | |
| if (!v) return "Describe the image you want (subject, style, mood, colors)…"; | |
| const first = v.split("\n").find((line) => line.trim())?.trim() ?? v; | |
| return first.length > 120 ? `${first.slice(0, 120)}…` : first; | |
| }, [scene.visual_description]); | |
| useEffect(() => { | |
| if (!open) return; | |
| setImageDescription(""); | |
| setError(null); | |
| setGenerating(false); | |
| }, [open, scene.id]); | |
| if (!open) return null; | |
| // A collaborator blocked by the owner's free plan can't act on an upgrade prompt. | |
| const promptForAccess = () => { | |
| if (ownerBlocked) onOwnerBlocked?.(); | |
| else onUpgrade(); | |
| }; | |
| const handleGenerate = async () => { | |
| const desc = imageDescription.trim(); | |
| if (desc.length < 3) { | |
| setError("Image description must be at least 3 characters."); | |
| return; | |
| } | |
| if (!canGenerate) { | |
| promptForAccess(); | |
| return; | |
| } | |
| // Keep the modal open and show a generating state so the user sees progress and | |
| // any error, instead of the modal vanishing with no feedback. | |
| setError(null); | |
| setGenerating(true); | |
| onGenerateStart?.(); | |
| try { | |
| const res = await generateSceneImage(project.id, scene.id, { | |
| image_description: desc, | |
| }); | |
| // Hand the result to the parent (which shows the keep/discard preview) and close. | |
| onImageReady(res.data.image_base64, res.data.refined_prompt); | |
| onClose(); | |
| } catch (err: unknown) { | |
| const status = | |
| err && typeof err === "object" && "response" in err | |
| ? (err as { response?: { status?: number } }).response?.status | |
| : 0; | |
| if (status === 403) { | |
| promptForAccess(); | |
| return; | |
| } | |
| const msg = | |
| err && typeof err === "object" && "response" in err | |
| ? (err as { response?: { data?: { detail?: string } } }).response?.data | |
| ?.detail | |
| : "Image generation failed"; | |
| setError(String(msg || "Image generation failed")); | |
| onGenerateError?.(String(msg || "Image generation failed")); | |
| } finally { | |
| setGenerating(false); | |
| } | |
| }; | |
| return ( | |
| <div className="fixed inset-0 z-[105] flex items-center justify-center p-4"> | |
| <div | |
| className="absolute inset-0 bg-black/50 backdrop-blur-sm" | |
| onClick={() => { if (!generating) onClose(); }} | |
| aria-hidden | |
| /> | |
| <div | |
| className="relative bg-white rounded-2xl shadow-2xl max-w-lg w-full max-h-[90vh] flex flex-col overflow-hidden" | |
| onClick={(e) => e.stopPropagation()} | |
| role="dialog" | |
| aria-labelledby="generate-scene-image-title" | |
| > | |
| <div className="p-4 border-b border-gray-200 flex-shrink-0"> | |
| <h3 id="generate-scene-image-title" className="text-lg font-semibold text-gray-900"> | |
| Generate image with AI | |
| </h3> | |
| <p className="text-xs text-gray-500 mt-1"> | |
| Describe the image you want. Your description is the main input. | |
| </p> | |
| <p className="text-xs text-gray-400 mt-1"> | |
| Costs{" "} | |
| <span className="font-medium text-gray-600"> | |
| {creditCost} AI edit credits | |
| </span> | |
| {isCollaborator ? ( | |
| <> | |
| {" "} | |
| from the project owner's balance ( | |
| {formatAiEditCreditsDisplay(creditsRemaining)} remaining). | |
| </> | |
| ) : ( | |
| <> | |
| {" "} | |
| · {formatAiEditCreditsDisplay(creditsRemaining)} remaining | |
| {!canGenerate && ( | |
| <> | |
| {" "} | |
| ·{" "} | |
| <button | |
| type="button" | |
| onClick={onUpgradeNow} | |
| className="text-purple-600 hover:text-purple-700 underline font-medium" | |
| > | |
| Upgrade for more | |
| </button> | |
| </> | |
| )} | |
| </> | |
| )} | |
| </p> | |
| </div> | |
| {generating ? ( | |
| <div className="flex-1 flex flex-col items-center justify-center gap-4 p-10 text-center min-h-0"> | |
| <div className="w-12 h-12 border-2 border-purple-200 border-t-purple-600 rounded-full animate-spin" /> | |
| <div> | |
| <p className="text-sm font-medium text-gray-800">Generating image…</p> | |
| <p className="mt-1 text-xs text-gray-500"> | |
| This can take up to a minute. Please keep this open. | |
| </p> | |
| </div> | |
| </div> | |
| ) : ( | |
| <div className="flex-1 overflow-y-auto p-4 space-y-4 min-h-0"> | |
| <div> | |
| <label | |
| htmlFor="scene-image-description" | |
| className="block text-[11px] font-medium text-gray-500 uppercase tracking-wider mb-1.5" | |
| > | |
| Image description <span className="text-red-500">*</span> | |
| </label> | |
| <textarea | |
| id="scene-image-description" | |
| value={imageDescription} | |
| onChange={(e) => setImageDescription(e.target.value)} | |
| placeholder={placeholderHint} | |
| rows={4} | |
| autoFocus | |
| className="w-full px-3 py-2 text-sm text-gray-800 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500 resize-y leading-relaxed" | |
| /> | |
| <p className="text-xs text-gray-500 mt-2"> | |
| The scene description will also be incorporated into your prompt to generate the | |
| image. | |
| </p> | |
| </div> | |
| {error ? ( | |
| <p className="text-xs text-red-600" role="alert"> | |
| {error} | |
| </p> | |
| ) : null} | |
| </div> | |
| )} | |
| {!generating && ( | |
| <div className="p-4 border-t border-gray-200 flex gap-2 flex-shrink-0"> | |
| <button | |
| type="button" | |
| onClick={onClose} | |
| className="flex-1 px-4 py-2 text-sm font-medium text-gray-700 border border-gray-300 rounded-lg hover:bg-gray-50" | |
| > | |
| Cancel | |
| </button> | |
| <button | |
| type="button" | |
| onClick={handleGenerate} | |
| disabled={imageDescription.trim().length < 3} | |
| className="flex-1 px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 disabled:opacity-50 disabled:cursor-not-allowed" | |
| > | |
| Generate | |
| </button> | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| ); | |
| } | |