blog2video-test / frontend /src /components /GenerateSceneImageModal.tsx
Muhammad-Mehdi's picture
lite plan added
ac490ae
Raw
History Blame Contribute Delete
8.73 kB
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&apos;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>
);
}