| import { useEffect, useState } from "react"; |
| import { addScene, type Project, type Scene } from "../api/client"; |
| import { formatAiEditCreditsDisplay } from "../lib/formatAiEditCredits"; |
|
|
| |
| export const ADD_SCENE_CREDIT_COST = 5; |
|
|
| export interface AddSceneModalProps { |
| open: boolean; |
| onClose: () => void; |
| project: Project; |
| |
| creditsRemaining: number; |
| |
| |
| |
| canAfford: boolean; |
| |
| |
| |
| |
| |
| isCollaborator?: boolean; |
| |
| anchorScene?: Scene | null; |
| |
| onAdded: (position?: number) => void; |
| |
| onUpgradeNow: () => void; |
| |
| onError: (err: unknown) => void; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| 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); |
|
|
| |
| const insertPosition = anchorScene ? anchorScene.order + 1 : undefined; |
|
|
| |
| 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); |
| |
| |
| onAdded(insertPosition); |
| onClose(); |
| } catch (err: unknown) { |
| |
| |
| 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> |
| ); |
| } |
|
|