| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| import React, { useState } from 'react'; |
| import { Loader2, Scissors, Palette, CircleDot, X } from 'lucide-react'; |
| import { processBackground, BACKGROUND_ACTIONS } from '../enhance/backgroundApi'; |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export function BackgroundTools({ backendUrl, apiKey, imageUrl, onResult, onError, disabled = false, compact = false, }) { |
| const [loading, setLoading] = useState(null); |
| const [showPromptInput, setShowPromptInput] = useState(false); |
| const [bgPrompt, setBgPrompt] = useState(''); |
| const [blurStrength, setBlurStrength] = useState(15); |
| |
| const ENABLED_ACTIONS = BACKGROUND_ACTIONS.filter(a => a.id === 'replace'); |
| const handleAction = async (action, prompt) => { |
| if (!imageUrl || loading || disabled) |
| return; |
| |
| if (action === 'replace' && !prompt) { |
| setShowPromptInput(true); |
| return; |
| } |
| setLoading(action); |
| setShowPromptInput(false); |
| try { |
| const result = await processBackground({ |
| backendUrl, |
| apiKey, |
| imageUrl, |
| action, |
| prompt, |
| blurStrength: action === 'blur' ? blurStrength : undefined, |
| }); |
| const resultUrl = result?.media?.images?.[0]; |
| if (resultUrl) { |
| onResult(resultUrl, action, result.has_alpha ?? false); |
| } |
| else { |
| onError('Operation completed but no image was returned.'); |
| } |
| } |
| catch (e) { |
| onError(e instanceof Error ? e.message : 'Background operation failed'); |
| } |
| finally { |
| setLoading(null); |
| setBgPrompt(''); |
| } |
| }; |
| const getIcon = (action) => { |
| switch (action) { |
| case 'remove': |
| return <Scissors size={compact ? 14 : 16}/>; |
| case 'replace': |
| return <Palette size={compact ? 14 : 16}/>; |
| case 'blur': |
| return <CircleDot size={compact ? 14 : 16}/>; |
| } |
| }; |
| const isDisabled = !imageUrl || disabled; |
| if (compact) { |
| return (<div className="flex gap-1.5"> |
| {ENABLED_ACTIONS.map((actionInfo) => (<button key={actionInfo.id} onClick={() => handleAction(actionInfo.id)} disabled={isDisabled || loading !== null} title={`${actionInfo.label}: ${actionInfo.description}`} className={` |
| p-2 rounded-lg transition-all |
| ${loading === actionInfo.id |
| ? 'bg-blue-500/60 text-white animate-pulse' |
| : isDisabled || loading !== null |
| ? 'bg-white/5 text-white/30 cursor-not-allowed' |
| : 'bg-white/10 text-white/70 hover:bg-blue-500/30 hover:text-blue-300'} |
| `}> |
| {loading === actionInfo.id ? (<Loader2 size={14} className="animate-spin"/>) : (getIcon(actionInfo.id))} |
| </button>))} |
| </div>); |
| } |
| return (<div className="space-y-3"> |
| <div className="text-xs uppercase tracking-wider text-white/40 font-semibold flex items-center gap-2"> |
| <Scissors size={14}/> |
| Background |
| </div> |
| |
| <div className="grid grid-cols-1 gap-2"> |
| {ENABLED_ACTIONS.map((actionInfo) => (<button key={actionInfo.id} onClick={() => handleAction(actionInfo.id)} disabled={isDisabled || loading !== null} className={` |
| flex items-center gap-3 p-3 rounded-xl border transition-all text-left |
| ${loading === actionInfo.id |
| ? 'bg-blue-500/20 border-blue-500/40 text-blue-300' |
| : isDisabled || loading !== null |
| ? 'bg-white/5 border-white/5 text-white/30 cursor-not-allowed' |
| : 'bg-white/5 border-white/10 text-white/80 hover:bg-blue-500/10 hover:border-blue-500/30 hover:text-blue-200'} |
| `}> |
| <div className={` |
| w-9 h-9 rounded-lg flex items-center justify-center |
| ${loading === actionInfo.id |
| ? 'bg-blue-500/30' |
| : 'bg-white/10'} |
| `}> |
| {loading === actionInfo.id ? (<Loader2 size={18} className="animate-spin text-blue-400"/>) : (getIcon(actionInfo.id))} |
| </div> |
| <div className="flex-1 min-w-0"> |
| <div className="text-sm font-medium">{actionInfo.label}</div> |
| <div className="text-[10px] text-white/40 truncate"> |
| {actionInfo.description} |
| </div> |
| </div> |
| </button>))} |
| </div> |
| |
| {/* Prompt Input for Replace Action */} |
| {showPromptInput && (<div className="space-y-2 p-3 rounded-xl bg-blue-500/10 border border-blue-500/30 animate-in fade-in slide-in-from-top-2"> |
| <div className="flex items-center justify-between"> |
| <span className="text-xs text-blue-300 font-medium">Describe new background</span> |
| <button onClick={() => { |
| setShowPromptInput(false); |
| setBgPrompt(''); |
| }} className="text-white/40 hover:text-white"> |
| <X size={14}/> |
| </button> |
| </div> |
| <input type="text" value={bgPrompt} onChange={(e) => setBgPrompt(e.target.value)} placeholder="e.g., sunset beach, city skyline, forest..." className="w-full rounded-lg bg-black/40 border border-white/10 px-3 py-2 text-sm text-white placeholder:text-white/30 focus:border-blue-500/50 focus:outline-none" autoFocus onKeyDown={(e) => { |
| if (e.key === 'Enter' && bgPrompt.trim()) { |
| handleAction('replace', bgPrompt.trim()); |
| } |
| if (e.key === 'Escape') { |
| setShowPromptInput(false); |
| setBgPrompt(''); |
| } |
| }}/> |
| <button onClick={() => handleAction('replace', bgPrompt.trim())} disabled={!bgPrompt.trim() || loading !== null} className="w-full py-2 px-4 rounded-lg bg-blue-500 text-white hover:bg-blue-400 disabled:bg-white/10 disabled:text-white/30 disabled:cursor-not-allowed transition-all text-sm font-medium"> |
| {loading === 'replace' ? (<span className="flex items-center justify-center gap-2"> |
| <Loader2 size={14} className="animate-spin"/> |
| Generating... |
| </span>) : ('Generate Background')} |
| </button> |
| </div>)} |
| |
| <p className="text-[10px] text-white/30 leading-relaxed"> |
| Replace the background with AI-generated content. Results are added to your version history. |
| </p> |
| </div>); |
| } |
| export default BackgroundTools; |
|
|