File size: 7,729 Bytes
43c6b76 942e9df ac490ae 43c6b76 ac490ae 43c6b76 ac490ae 069d37b ac490ae 069d37b 942e9df 235b830 942e9df 43c6b76 942e9df 43c6b76 ac490ae 069d37b 942e9df 43c6b76 235b830 43c6b76 942e9df 43c6b76 942e9df 43c6b76 942e9df 43c6b76 942e9df 43c6b76 ac490ae 43c6b76 942e9df 43c6b76 069d37b 235b830 ac490ae 235b830 069d37b 235b830 ac490ae 235b830 069d37b 43c6b76 942e9df 43c6b76 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 | import { useEffect, useState } from "react";
import { addScene, type Project, type Scene } from "../api/client";
import { formatAiEditCreditsDisplay } from "../lib/formatAiEditCredits";
/** Adding a scene costs this many AI-edit credits (mirrors the backend). */
export const ADD_SCENE_CREDIT_COST = 5;
export interface AddSceneModalProps {
open: boolean;
onClose: () => void;
project: Project;
/** Total AI-edit budget available (allowance + purchased pool). */
creditsRemaining: number;
/**
* Whether the owner has enough AI-edit budget for one add-scene action.
*/
canAfford: boolean;
/**
* True when the viewer is a collaborator on a shared project (spending the
* OWNER's credit pool). A collaborator can't fix an exhausted pool by upgrading
* their own plan.
*/
isCollaborator?: boolean;
/** The scene the new one will be inserted AFTER; null = append at the end. */
anchorScene?: Scene | null;
/** Called after the add-scene job is enqueued (parent starts polling). */
onAdded: (position?: number) => void;
/** Navigate to the billing page from the inline "Upgrade now" link (free viewers). */
onUpgradeNow: () => void;
/** Surface an API failure (e.g. the 403 credit / 409 busy error) in the global "Oops" modal. */
onError: (err: unknown) => void;
}
/**
* Modal to generate and insert a new AI scene. The user describes the scene; it's
* inserted right after `anchorScene` (or appended when none). On submit the backend
* ENQUEUES a background generation job and this closes immediately — the parent shows
* a placeholder row and polls for completion.
*/
export default function AddSceneModal({
open,
onClose,
project,
creditsRemaining,
canAfford,
isCollaborator = false,
anchorScene,
onAdded,
onError,
onUpgradeNow,
}: AddSceneModalProps) {
const [prompt, setPrompt] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Insert position: right after the anchor row, else append (undefined).
const insertPosition = anchorScene ? anchorScene.order + 1 : undefined;
// Reset the form whenever the modal (re)opens.
useEffect(() => {
if (open) {
setPrompt("");
setError(null);
setLoading(false);
}
}, [open]);
if (!open) return null;
const handleClose = () => {
if (loading) return;
onClose();
};
const handleSubmit = async () => {
const trimmed = prompt.trim();
if (!trimmed) {
setError("Please describe the scene you want to add.");
return;
}
if (!canAfford) return;
setLoading(true);
setError(null);
try {
await addScene(project.id, trimmed, insertPosition);
// Job enqueued — hand the target position to the parent (for the placeholder)
// and close. Generation continues in the background.
onAdded(insertPosition);
onClose();
} catch (err: unknown) {
// Surface API failures (403 credits / 409 busy) in the global "Oops" modal.
// Close first so the error modal isn't hidden behind this dialog.
onClose();
onError(err);
} finally {
setLoading(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={handleClose}
aria-hidden
/>
<div
className="relative bg-white rounded-2xl shadow-2xl max-w-md w-full flex flex-col overflow-hidden"
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-labelledby="add-scene-title"
>
<div className="p-4 border-b border-gray-200 flex-shrink-0">
<h3 id="add-scene-title" className="text-lg font-semibold text-gray-900">
Add a scene
</h3>
<p className="text-xs text-gray-500 mt-1">
Describe the scene — it's generated from your prompt and the rest of the video.
</p>
<p className="text-xs text-gray-400 mt-1">
Costs{" "}
<span className="font-medium text-gray-600">
{ADD_SCENE_CREDIT_COST} AI edit credits
</span>
{isCollaborator ? (
<>
{" "}
from the project owner's balance (
{formatAiEditCreditsDisplay(creditsRemaining)} remaining).
</>
) : (
<>
{" "}
· {formatAiEditCreditsDisplay(creditsRemaining)} remaining
{!canAfford && (
<>
{" "}
·{" "}
<button
type="button"
onClick={onUpgradeNow}
className="text-purple-600 hover:text-purple-700 underline font-medium"
>
Upgrade for more
</button>
</>
)}
</>
)}
</p>
</div>
<div className="p-6 flex flex-col gap-4">
{error && (
<p className="w-full text-xs text-red-600 bg-red-50 border border-red-200 rounded-lg px-3 py-2">
{error}
</p>
)}
<div>
<label htmlFor="add-scene-prompt" className="block text-xs font-medium text-gray-700 mb-1.5">
What should this scene be about?
</label>
<textarea
id="add-scene-prompt"
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
rows={4}
autoFocus
placeholder="e.g. A summary of the key takeaways with an upbeat closing line."
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500 resize-none"
/>
</div>
{/* Position is auto-determined from where the user clicked "Add scene". */}
<p className="text-xs text-gray-500">
{anchorScene
? <>The new scene will be added below scene {anchorScene.order}.</>
: <>The new scene will be added at the end.</>}
</p>
{!canAfford && (
<div className="rounded-lg bg-red-50 border border-red-200 px-3 py-2.5">
{isCollaborator ? (
<p className="text-xs font-semibold text-red-700">
The project owner is out of AI edit credits — ask them to upgrade or buy more.
</p>
) : (
<p className="text-xs font-semibold text-red-700">
You're out of AI edit credits — upgrade or buy a video for +20 credits.
</p>
)}
</div>
)}
</div>
<div className="p-4 border-t border-gray-200 flex items-center justify-end gap-2">
<button
type="button"
onClick={handleClose}
disabled={loading}
className="px-4 py-2 text-sm font-medium text-gray-700 border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50"
>
Cancel
</button>
<button
type="button"
onClick={() => void handleSubmit()}
disabled={loading || !prompt.trim() || !canAfford}
className="px-4 py-2 text-sm font-medium bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? "Adding…" : "Add scene"}
</button>
</div>
</div>
</div>
);
}
|