Refactor GTM Architect to v2: Python-driven architecture, 9-phase process, new DB and Docker setup

This commit is contained in:
2026-01-02 19:00:05 +00:00
parent a3dc012da8
commit b47a65eb83
300 changed files with 68128 additions and 4782 deletions

View File

@@ -0,0 +1,79 @@
import React, { useState, useEffect, useRef } from 'react';
import { useLogger, LogMessage, LogType } from '../contexts/LoggingContext';
const LOG_COLORS: Record<LogType, string> = {
info: 'text-gray-300',
success: 'text-green-400',
error: 'text-red-400',
warn: 'text-yellow-400',
};
const DebugConsole: React.FC = () => {
const { logs, clearLogs } = useLogger();
const [isOpen, setIsOpen] = useState(false);
const [copyStatus, setCopyStatus] = useState('Copy');
const logsEndRef = useRef<HTMLDivElement>(null);
const scrollToBottom = () => {
logsEndRef.current?.scrollIntoView({ behavior: 'smooth' });
};
useEffect(scrollToBottom, [logs]);
const handleCopy = () => {
const logText = logs.map(log => `[${log.timestamp}] [${log.type.toUpperCase()}] ${log.message}`).join('\n');
navigator.clipboard.writeText(logText).then(() => {
setCopyStatus('Copied!');
setTimeout(() => setCopyStatus('Copy'), 2000);
}, () => {
setCopyStatus('Failed!');
setTimeout(() => setCopyStatus('Copy'), 2000);
});
};
return (
<>
<button
onClick={() => setIsOpen(!isOpen)}
className="fixed bottom-4 right-4 bg-purple-700 hover:bg-purple-800 text-white rounded-full p-3 shadow-lg z-50 transition-transform hover:scale-110"
aria-label="Toggle Debug Console"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</button>
{isOpen && (
<div className="fixed bottom-0 left-0 right-0 h-1/3 bg-gray-900/95 backdrop-blur-sm border-t-2 border-purple-800 z-40 flex flex-col p-2 shadow-2xl">
<div className="flex items-center justify-between mb-2 px-2 flex-shrink-0">
<h3 className="font-teko text-2xl text-purple-300 tracking-wide">DEBUG CONSOLE</h3>
<div className="flex items-center gap-2">
<button onClick={handleCopy} className="text-xs bg-gray-700 hover:bg-gray-600 text-gray-300 px-3 py-1 rounded-md transition-colors">{copyStatus}</button>
<button onClick={clearLogs} className="text-xs bg-gray-700 hover:bg-gray-600 text-gray-300 px-3 py-1 rounded-md transition-colors">Clear</button>
<button onClick={() => setIsOpen(false)} className="text-gray-400 hover:text-white">&times;</button>
</div>
</div>
<div className="overflow-y-auto flex-grow bg-black/50 p-2 rounded-md font-mono text-sm">
{logs.length === 0 ? (
<p className="text-gray-500">No logs yet. Start using the app to see messages here.</p>
) : (
logs.map((log, index) => (
<div key={index} className="flex">
<span className="text-gray-500 mr-2 flex-shrink-0">{log.timestamp}</span>
<span className={`${LOG_COLORS[log.type]} whitespace-pre-wrap break-all`}>
<span className='font-bold mr-2'>[{log.type.toUpperCase()}]</span>
{log.message}
</span>
</div>
))
)}
<div ref={logsEndRef} />
</div>
</div>
)}
</>
);
};
export default DebugConsole;

View File

@@ -0,0 +1,103 @@
import React, { useState } from 'react';
import { GenerationResult } from '../types';
import { DownloadIcon } from './icons/DownloadIcon';
import { MagicIcon } from './icons/MagicIcon';
interface ImageResultProps {
result: GenerationResult | null;
onRefine: (refinementPrompt: string) => void;
masterPrompt: string;
isLoading: boolean;
loadingMessage: string;
onStartOver: () => void;
}
const ImageResult: React.FC<ImageResultProps> = ({ result, onRefine, masterPrompt, isLoading, loadingMessage, onStartOver }) => {
const [refinementPrompt, setRefinementPrompt] = useState('');
const handleRefineSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (refinementPrompt.trim()) {
onRefine(refinementPrompt);
setRefinementPrompt('');
}
};
if (!result) {
return (
<div className="text-center">
<p>Something went wrong. No image was generated.</p>
<button onClick={onStartOver} className="mt-4 bg-purple-600 hover:bg-purple-700 text-white font-bold py-2 px-4 rounded">Start Over</button>
</div>
);
}
return (
<div className="w-full flex flex-col lg:flex-row gap-8">
<div className="lg:w-2/3 relative">
<div className="aspect-w-16 aspect-h-9 bg-black rounded-lg overflow-hidden shadow-lg">
<img src={`data:image/png;base64,${result.currentImage}`} alt="Generated thumbnail" className="w-full h-full object-contain" />
</div>
{isLoading && (
<div className="absolute inset-0 bg-black/70 flex flex-col items-center justify-center text-center p-4 rounded-lg">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-purple-400 mb-4"></div>
<p className="text-lg text-purple-300">{loadingMessage}</p>
</div>
)}
</div>
<div className="lg:w-1/3 flex flex-col">
<h2 className="text-2xl font-bold text-gray-100 mb-4">Your Masterpiece</h2>
<div className="flex-grow space-y-6">
<form onSubmit={handleRefineSubmit}>
<label htmlFor="refinement-prompt" className="block text-lg font-semibold text-purple-300 mb-2">
Refine Your Image
</label>
<textarea
id="refinement-prompt"
value={refinementPrompt}
onChange={(e) => setRefinementPrompt(e.target.value)}
placeholder="e.g., 'Make the smile a little softer.' or 'Change the background to be more blurry.'"
className="w-full bg-gray-700 border border-gray-600 rounded-lg p-3 text-base h-24 focus:ring-2 focus:ring-purple-500 focus:border-purple-500 transition"
required
/>
<button
type="submit"
disabled={isLoading || !refinementPrompt}
className="w-full mt-3 bg-purple-600 hover:bg-purple-700 disabled:bg-gray-600 text-white font-bold py-2.5 px-4 rounded-lg transition-colors duration-300 flex items-center justify-center gap-2"
>
<MagicIcon className="w-5 h-5"/> Refine
</button>
</form>
<div>
<details className="bg-gray-700/50 rounded-lg">
<summary className="cursor-pointer text-purple-300 font-semibold p-3">View Master Prompt</summary>
<p className="p-3 pt-0 text-gray-400 text-sm">{masterPrompt}</p>
</details>
</div>
</div>
<div className="mt-8 space-y-3">
<a
href={`data:image/png;base64,${result.currentImage}`}
download="kpop-thumbnail.png"
className="w-full bg-green-600 hover:bg-green-700 text-white font-bold py-3 px-4 rounded-lg transition-colors duration-300 flex items-center justify-center gap-2 text-lg"
>
<DownloadIcon className="w-6 h-6" /> Download Image
</a>
<button
onClick={onStartOver}
className="w-full bg-gray-600 hover:bg-gray-500 text-white font-bold py-2 px-4 rounded-lg transition-colors duration-300"
>
Start Over
</button>
</div>
</div>
</div>
);
};
export default ImageResult;

View File

@@ -0,0 +1,257 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { UploadedImage } from '../types';
import { segmentSubject } from '../services/geminiService';
import { ArrowLeftIcon } from './icons/ArrowLeftIcon';
import { ArrowRightIcon } from './icons/ArrowRightIcon';
import { BrushIcon } from './icons/BrushIcon';
import { EraserIcon } from './icons/EraserIcon';
import { useLogger } from '../contexts/LoggingContext';
interface ImageSegmenterProps {
images: UploadedImage[];
onComplete: (images: UploadedImage[]) => void;
onBack: () => void;
}
type EditorMode = 'brush' | 'eraser';
const ImageSegmenter: React.FC<ImageSegmenterProps> = ({ images, onComplete, onBack }) => {
const [internalImages, setInternalImages] = useState<UploadedImage[]>(images);
const [loadingStates, setLoadingStates] = useState<Record<number, boolean>>({});
const [errorStates, setErrorStates] = useState<Record<number, string | null>>({});
const [activeIndex, setActiveIndex] = useState<number>(0);
const [mode, setMode] = useState<EditorMode>('brush');
const [brushSize, setBrushSize] = useState<number>(20);
const canvasRef = useRef<HTMLCanvasElement>(null);
const imageRef = useRef<HTMLImageElement | null>(null);
const maskRef = useRef<HTMLImageElement | null>(null);
const isDrawing = useRef<boolean>(false);
const { log } = useLogger();
const generateMask = useCallback(async (index: number) => {
setLoadingStates(prev => ({ ...prev, [index]: true }));
setErrorStates(prev => ({ ...prev, [index]: null }));
log('info', `Requesting segmentation mask for image ${index + 1} ("${internalImages[index].subjectDescription}").`);
try {
const maskBase64 = await segmentSubject(internalImages[index].file, internalImages[index].subjectDescription);
setInternalImages(prev => {
const updated = [...prev];
updated[index].maskDataUrl = `data:image/png;base64,${maskBase64}`;
return updated;
});
log('success', `Successfully received segmentation mask for image ${index + 1}.`);
} catch (e) {
const errorMessage = e instanceof Error ? e.message : 'Mask generation failed';
let displayError = "Failed";
if (errorMessage.includes("The AI returned a message")) {
displayError = "AI Response Error";
} else if (errorMessage.includes("No segmentation mask")) {
displayError = "No Mask Found";
}
setErrorStates(prev => ({ ...prev, [index]: displayError }));
log('error', `Failed to generate mask for image ${index + 1}: ${errorMessage}`);
} finally {
setLoadingStates(prev => ({ ...prev, [index]: false }));
}
}, [internalImages, log]);
useEffect(() => {
internalImages.forEach((image, index) => {
if (!image.maskDataUrl && !loadingStates[index] && !errorStates[index]) {
generateMask(index);
}
});
}, [internalImages, generateMask, loadingStates, errorStates]);
const draw = useCallback(() => {
const canvas = canvasRef.current;
const originalImage = imageRef.current;
const maskImage = maskRef.current;
if (!canvas || !originalImage || !maskImage) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
if (originalImage.naturalWidth === 0 || maskImage.naturalWidth === 0 || !originalImage.complete) {
return;
}
const { naturalWidth: w, naturalHeight: h } = originalImage;
if(canvas.width !== w) canvas.width = w;
if(canvas.height !== h) canvas.height = h;
ctx.clearRect(0, 0, w, h);
// Use a temporary canvas for the overlay so we don't mess up the main canvas's state
const overlayCanvas = document.createElement('canvas');
overlayCanvas.width = w;
overlayCanvas.height = h;
const overlayCtx = overlayCanvas.getContext('2d');
if (!overlayCtx) return;
// Fill the overlay with a semi-transparent black
overlayCtx.fillStyle = 'rgba(0, 0, 0, 0.6)';
overlayCtx.fillRect(0, 0, w, h);
// Use 'destination-out' to punch a hole in the overlay where the mask is white
overlayCtx.globalCompositeOperation = 'destination-out';
overlayCtx.drawImage(maskImage, 0, 0, w, h);
// Draw the original image on the main canvas
ctx.drawImage(originalImage, 0, 0, w, h);
// Draw the overlay (with the hole punched out) on top
ctx.drawImage(overlayCanvas, 0, 0);
}, []);
useEffect(() => {
const activeImage = internalImages[activeIndex];
if (activeImage?.previewUrl && activeImage?.maskDataUrl) {
const originalImage = new Image();
const maskImage = new Image();
imageRef.current = originalImage;
maskRef.current = maskImage;
originalImage.src = activeImage.previewUrl;
maskImage.src = activeImage.maskDataUrl;
const loadImages = Promise.all([
new Promise((res, rej) => { originalImage.onload = res; originalImage.onerror = rej; }),
new Promise((res, rej) => { maskImage.onload = res; maskImage.onerror = rej; })
]);
loadImages.then(() => {
draw();
}).catch(err => {
console.error("Error loading images for canvas: ", err);
log('error', `Canvas Error: Failed to load images for editor view. ${err}`);
});
}
}, [activeIndex, internalImages, draw, log]);
const handleCanvasInteraction = (e: React.MouseEvent<HTMLCanvasElement> | React.TouchEvent<HTMLCanvasElement>) => {
if (!isDrawing.current && e.type !== 'mousedown' && e.type !== 'touchstart') return;
const canvas = canvasRef.current;
if (!canvas || !maskRef.current) return;
const tempCanvas = document.createElement('canvas');
tempCanvas.width = maskRef.current.naturalWidth;
tempCanvas.height = maskRef.current.naturalHeight;
const tempCtx = tempCanvas.getContext('2d');
if (!tempCtx) return;
tempCtx.drawImage(maskRef.current, 0, 0);
const rect = canvas.getBoundingClientRect();
const scaleX = tempCanvas.width / rect.width;
const scaleY = tempCanvas.height / rect.height;
const getCoords = (evt: any) => {
if (evt.touches) {
return { x: evt.touches[0].clientX - rect.left, y: evt.touches[0].clientY - rect.top };
}
return { x: evt.clientX - rect.left, y: evt.clientY - rect.top };
}
const {x, y} = getCoords(e.nativeEvent);
tempCtx.fillStyle = mode === 'brush' ? '#FFFFFF' : '#000000';
tempCtx.beginPath();
tempCtx.arc(x * scaleX, y * scaleY, (brushSize/2) * scaleX, 0, 2 * Math.PI);
tempCtx.fill();
const newMaskUrl = tempCanvas.toDataURL();
maskRef.current.src = newMaskUrl;
maskRef.current.onload = () => {
draw();
// Update state debounced or on mouse up for performance
if (e.type === 'mouseup' || e.type === 'touchend') {
setInternalImages(prev => {
const updated = [...prev];
updated[activeIndex].maskDataUrl = newMaskUrl;
return updated;
});
}
};
};
const startDrawing = () => { isDrawing.current = true; };
const stopDrawing = (e: any) => { isDrawing.current = false; handleCanvasInteraction(e); };
const canProceed = internalImages.every(img => img.maskDataUrl);
return (
<div className="w-full">
<div className="text-center mb-6">
<h2 className="text-2xl font-bold text-gray-100">Review & Refine Subjects</h2>
<p className="text-gray-400">The AI has extracted the subjects. Use the tools to refine the selection if needed.</p>
</div>
<div className="flex flex-col lg:flex-row gap-8">
{/* Thumbnails */}
<div className="lg:w-1/4 flex lg:flex-col gap-2 overflow-x-auto lg:overflow-y-auto lg:max-h-[500px] p-2 bg-gray-900/50 rounded-lg">
{internalImages.map((image, index) => (
<button key={index} onClick={() => setActiveIndex(index)} className={`rounded-lg border-2 transition-all p-1 flex-shrink-0 ${activeIndex === index ? 'border-purple-500' : 'border-transparent hover:border-gray-600'}`}>
<div className="relative w-24 h-24">
{loadingStates[index] && <div className="absolute inset-0 bg-black/70 flex items-center justify-center rounded-md"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-400"></div></div>}
{errorStates[index] && <div className="absolute inset-0 bg-red-900/80 text-white text-xs text-center flex items-center justify-center p-1 rounded-md">{errorStates[index]}</div>}
{image.maskDataUrl && <img src={image.maskDataUrl} alt={`mask preview ${index}`} className="w-full h-full object-contain rounded-md bg-black" />}
{!image.maskDataUrl && !loadingStates[index] && !errorStates[index] && <div className="w-full h-full bg-gray-700 rounded-md flex items-center justify-center text-xs text-gray-400">Waiting...</div>}
</div>
</button>
))}
</div>
{/* Editor */}
<div className="lg:w-3/4 flex flex-col items-center">
<div className="w-full flex justify-center items-center mb-4 p-2 bg-gray-700/50 rounded-lg">
<div className="flex items-center gap-4">
<button onClick={() => setMode('brush')} className={`p-2 rounded-md transition-colors ${mode === 'brush' ? 'bg-purple-600' : 'bg-gray-600 hover:bg-gray-500'}`}><BrushIcon className="w-6 h-6"/></button>
<button onClick={() => setMode('eraser')} className={`p-2 rounded-md transition-colors ${mode === 'eraser' ? 'bg-purple-600' : 'bg-gray-600 hover:bg-gray-500'}`}><EraserIcon className="w-6 h-6"/></button>
<div className="flex items-center gap-2">
<label htmlFor="brushSize" className="text-sm">Size:</label>
<input type="range" id="brushSize" min="2" max="100" value={brushSize} onChange={e => setBrushSize(Number(e.target.value))} className="w-32 cursor-pointer"/>
</div>
</div>
</div>
<canvas
ref={canvasRef}
className="rounded-lg max-w-full h-auto"
onMouseDown={startDrawing}
onMouseUp={stopDrawing}
onMouseMove={handleCanvasInteraction}
onMouseLeave={stopDrawing}
onTouchStart={startDrawing}
onTouchEnd={stopDrawing}
onTouchMove={handleCanvasInteraction}
/>
</div>
</div>
<div className="flex flex-col sm:flex-row justify-between items-center gap-4 pt-8 mt-6 border-t border-gray-700">
<button
type="button"
onClick={onBack}
className="bg-gray-600 hover:bg-gray-500 text-white font-bold py-3 px-6 rounded-lg transition-colors duration-300 flex items-center gap-2 w-full sm:w-auto justify-center"
>
<ArrowLeftIcon className="w-5 h-5"/> Back
</button>
<button
onClick={() => onComplete(internalImages)}
disabled={!canProceed}
className="bg-purple-600 hover:bg-purple-700 disabled:bg-gray-600 disabled:cursor-not-allowed text-white font-bold py-3 px-8 rounded-lg transition-all duration-300 text-lg flex items-center gap-2 mx-auto w-full sm:w-auto justify-center"
>
Next: Customize Prompt <ArrowRightIcon className="w-5 h-5" />
</button>
</div>
</div>
);
};
export default ImageSegmenter;

View File

@@ -0,0 +1,113 @@
import React, { useState, useCallback } from 'react';
import { UploadedImage } from '../types';
import { UploadIcon } from './icons/UploadIcon';
import { ArrowRightIcon } from './icons/ArrowRightIcon';
interface ImageUploaderProps {
onImagesUploaded: (images: UploadedImage[]) => void;
}
const ImageUploader: React.FC<ImageUploaderProps> = ({ onImagesUploaded }) => {
const [images, setImages] = useState<UploadedImage[]>([]);
const [error, setError] = useState<string | null>(null);
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setError(null);
if (event.target.files) {
const files = Array.from(event.target.files);
if (files.length + images.length > 5) {
setError("You can upload a maximum of 5 images.");
return;
}
// Fix: Explicitly type `file` as `File` to resolve type inference issues.
const newImages: UploadedImage[] = files.map((file: File) => ({
file,
previewUrl: URL.createObjectURL(file),
subjectDescription: '',
}));
setImages(prev => [...prev, ...newImages]);
}
};
const handleDescriptionChange = (index: number, value: string) => {
setImages(prev => {
const updated = [...prev];
updated[index].subjectDescription = value;
return updated;
});
};
const removeImage = (indexToRemove: number) => {
setImages(prev => prev.filter((_, index) => index !== indexToRemove));
};
const handleSubmit = () => {
if (images.length < 2) {
setError('Please upload at least 2 images.');
return;
}
if (images.some(img => img.subjectDescription.trim() === '')) {
setError('Please describe the main subject in each image.');
return;
}
onImagesUploaded(images);
};
return (
<div className="w-full">
<div className="text-center mb-6">
<h2 className="text-2xl font-bold text-gray-100">Upload Your Source Images</h2>
<p className="text-gray-400">Upload 2 or more images. Then, briefly describe the person you want to feature from each.</p>
</div>
<div className="mb-6">
<label htmlFor="file-upload" className="relative cursor-pointer bg-gray-700 hover:bg-gray-600 text-purple-300 font-semibold py-3 px-5 rounded-lg border border-dashed border-gray-500 flex flex-col items-center justify-center transition-colors duration-300 h-48">
<UploadIcon className="w-12 h-12 mb-2 text-gray-400"/>
<span className="text-lg">Click to upload images</span>
<span className="text-sm text-gray-500">PNG, JPG, WEBP up to 10MB</span>
<input id="file-upload" name="file-upload" type="file" multiple accept="image/png, image/jpeg, image/webp" className="sr-only" onChange={handleFileChange} />
</label>
</div>
{images.length > 0 && (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 mb-6">
{images.map((image, index) => (
<div key={index} className="bg-gray-700/50 rounded-lg p-3 relative group">
<img src={image.previewUrl} alt={`preview ${index}`} className="w-full h-40 object-cover rounded-md mb-3" />
<textarea
value={image.subjectDescription}
onChange={(e) => handleDescriptionChange(index, e.target.value)}
placeholder={`e.g., The man with glasses`}
className="w-full bg-gray-800 border border-gray-600 rounded-md p-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500 transition"
rows={2}
/>
<button
onClick={() => removeImage(index)}
className="absolute top-1 right-1 bg-black/50 text-white rounded-full p-1 opacity-0 group-hover:opacity-100 transition-opacity"
aria-label="Remove image"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
</svg>
</button>
</div>
))}
</div>
)}
{error && <p className="text-red-400 text-center my-4">{error}</p>}
<div className="text-center mt-4">
<button
onClick={handleSubmit}
disabled={images.length < 2 || images.some(i => !i.subjectDescription)}
className="bg-purple-600 hover:bg-purple-700 disabled:bg-gray-600 disabled:cursor-not-allowed text-white font-bold py-3 px-8 rounded-lg transition-all duration-300 text-lg flex items-center gap-2 mx-auto"
>
Next: Customize Prompt <ArrowRightIcon className="w-5 h-5" />
</button>
</div>
</div>
);
};
export default ImageUploader;

View File

@@ -0,0 +1,151 @@
import React, { useState, useEffect } from 'react';
import { PROMPT_TEMPLATES } from '../constants';
import { UploadedImage } from '../types';
import { MagicIcon } from './icons/MagicIcon';
import { ArrowLeftIcon } from './icons/ArrowLeftIcon';
interface PromptCustomizerProps {
onPromptExpanded: (scenario: string, userInstruction: string) => void;
onFinalSubmit: (masterPrompt: string) => void;
isLoading: boolean;
loadingMessage: string;
uploadedImages: UploadedImage[];
onBack: () => void;
masterPrompt: string;
setMasterPrompt: (prompt: string) => void;
}
const PromptCustomizer: React.FC<PromptCustomizerProps> = ({
onPromptExpanded,
onFinalSubmit,
isLoading,
loadingMessage,
uploadedImages,
onBack,
masterPrompt,
setMasterPrompt
}) => {
const [selectedScenario, setSelectedScenario] = useState<string>(PROMPT_TEMPLATES[0].title);
const [userInstruction, setUserInstruction] = useState<string>('');
const [isPromptExpanded, setIsPromptExpanded] = useState(false);
useEffect(() => {
if (masterPrompt) {
setIsPromptExpanded(true);
}
}, [masterPrompt]);
const handleExpandPrompt = (e: React.FormEvent) => {
e.preventDefault();
if (selectedScenario && userInstruction) {
onPromptExpanded(selectedScenario, userInstruction);
}
};
const handleFinalSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (masterPrompt) {
onFinalSubmit(masterPrompt);
}
};
if (isLoading && !isPromptExpanded) {
return (
<div className="flex flex-col items-center justify-center text-center h-64">
<div className="animate-spin rounded-full h-16 w-16 border-b-2 border-purple-400 mb-4"></div>
<p className="text-xl text-purple-300">{loadingMessage}</p>
</div>
);
}
return (
<div className="w-full">
<div className="text-center mb-8">
<h2 className="text-2xl font-bold text-gray-100">{isPromptExpanded ? 'Review Your Master Prompt' : 'Describe Your Scene'}</h2>
<p className="text-gray-400">{isPromptExpanded ? 'Edit the AI-generated prompt below, then generate your image.' : 'Choose a starting scenario and describe your vision.'}</p>
</div>
<div className="flex flex-col lg:flex-row gap-8">
<div className="lg:w-1/3">
<h3 className="text-lg font-semibold text-purple-300 mb-3">Your Subjects</h3>
<div className="flex flex-wrap gap-2">
{uploadedImages.map((image, index) => (
<div key={index} className="flex items-center gap-2 bg-gray-700 p-2 rounded-lg">
<img src={image.maskDataUrl || image.previewUrl} alt={`subject ${index}`} className="w-10 h-10 rounded-md object-cover bg-black" />
<p className="text-sm text-gray-300 flex-1">{image.subjectDescription}</p>
</div>
))}
</div>
</div>
<div className="flex-grow lg:w-2/3 space-y-6">
{!isPromptExpanded ? (
<form onSubmit={handleExpandPrompt} className="space-y-6">
<div>
<label className="block text-lg font-semibold text-purple-300 mb-2">1. Choose a K-Pop Scenario</label>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{PROMPT_TEMPLATES.map(template => (
<button
key={template.id}
type="button"
onClick={() => setSelectedScenario(template.title)}
className={`p-4 rounded-lg text-left transition-all duration-200 border-2 ${selectedScenario === template.title ? 'bg-purple-800/50 border-purple-500' : 'bg-gray-700 border-gray-600 hover:border-purple-600'}`}
>
<p className="font-bold text-white">{template.title}</p>
<p className="text-sm text-gray-400">{template.description}</p>
</button>
))}
</div>
</div>
<div>
<label htmlFor="user-instruction" className="block text-lg font-semibold text-purple-300 mb-2">2. Describe Your Idea</label>
<textarea
id="user-instruction"
value={userInstruction}
onChange={(e) => setUserInstruction(e.target.value)}
placeholder="Example: The person from image 1 should stand behind the person from image 2, placing a hand on their shoulder. Use the background from image 2."
className="w-full bg-gray-800 border border-gray-600 rounded-lg p-3 text-base h-32 focus:ring-2 focus:ring-purple-500 focus:border-purple-500 transition"
required
/>
</div>
<div className="flex flex-col sm:flex-row justify-between items-center gap-4 pt-4">
<button type="button" onClick={onBack} className="bg-gray-600 hover:bg-gray-500 text-white font-bold py-3 px-6 rounded-lg transition-colors duration-300 flex items-center gap-2 w-full sm:w-auto justify-center">
<ArrowLeftIcon className="w-5 h-5"/> Back
</button>
<button type="submit" disabled={isLoading || !userInstruction || !selectedScenario} className="bg-purple-600 hover:bg-purple-700 disabled:bg-gray-600 text-white font-bold py-3 px-8 rounded-lg transition-all duration-300 flex items-center gap-2 w-full sm:w-auto justify-center">
Create Master Prompt <MagicIcon className="w-5 h-5"/>
</button>
</div>
</form>
) : (
<form onSubmit={handleFinalSubmit} className="space-y-6">
<div>
<label htmlFor="master-prompt" className="block text-lg font-semibold text-purple-300 mb-2">Master Prompt</label>
<textarea
id="master-prompt"
value={masterPrompt}
onChange={(e) => setMasterPrompt(e.target.value)}
className="w-full bg-gray-800 border border-gray-600 rounded-lg p-3 text-base h-48 focus:ring-2 focus:ring-purple-500 focus:border-purple-500 transition"
required
/>
</div>
<div className="flex flex-col sm:flex-row justify-between items-center gap-4 pt-4">
<button type="button" onClick={() => setIsPromptExpanded(false)} className="bg-gray-600 hover:bg-gray-500 text-white font-bold py-3 px-6 rounded-lg transition-colors duration-300 flex items-center gap-2 w-full sm:w-auto justify-center">
<ArrowLeftIcon className="w-5 h-5"/> Edit Scenario
</button>
<button type="submit" disabled={isLoading || !masterPrompt} className="bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-700 hover:to-pink-700 disabled:from-gray-600 disabled:to-gray-600 disabled:cursor-not-allowed text-white font-bold py-3 px-8 rounded-lg transition-all duration-300 text-lg flex items-center gap-2 w-full sm:w-auto justify-center">
{isLoading ? loadingMessage : 'Generate Thumbnail'}
{!isLoading && <MagicIcon className="w-6 h-6"/>}
</button>
</div>
</form>
)}
</div>
</div>
</div>
);
};
export default PromptCustomizer;

View File

@@ -0,0 +1,53 @@
import React from 'react';
import { AppStep } from '../types';
interface StepIndicatorProps {
currentStep: AppStep;
}
const steps = [
{ id: AppStep.Upload, title: 'Upload' },
{ id: AppStep.Segment, title: 'Segment Subjects'},
{ id: AppStep.Prompt, title: 'Create Prompt' },
{ id: AppStep.Result, title: 'Generate & Refine' },
];
const StepIndicator: React.FC<StepIndicatorProps> = ({ currentStep }) => {
return (
<div className="w-full">
<div className="flex items-center justify-between md:justify-center">
{steps.map((step, index) => {
const isActive = currentStep === step.id;
const isCompleted = currentStep > step.id;
return (
<React.Fragment key={step.id}>
<div className="flex items-center flex-col sm:flex-row text-center">
<div
className={`flex items-center justify-center w-10 h-10 rounded-full transition-all duration-300 ${
isActive ? 'bg-purple-600 scale-110' : isCompleted ? 'bg-green-600' : 'bg-gray-700'
}`}
>
<span className={`font-bold ${isActive || isCompleted ? 'text-white' : 'text-gray-400'}`}>
{isCompleted ? (
<svg xmlns="http://www.w.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
) : step.id}
</span>
</div>
<p className={`mt-2 sm:mt-0 sm:ml-3 font-semibold text-xs sm:text-sm ${isActive ? 'text-purple-300' : 'text-gray-500'}`}>{step.title}</p>
</div>
{index < steps.length - 1 && (
<div className={`flex-auto border-t-2 transition-colors duration-300 mx-2 sm:mx-4 w-4 sm:w-auto ${isCompleted ? 'border-green-600' : 'border-gray-700'}`}></div>
)}
</React.Fragment>
);
})}
</div>
</div>
);
};
export default StepIndicator;

View File

@@ -0,0 +1,8 @@
import React from 'react';
export const ArrowLeftIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" {...props}>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
);

View File

@@ -0,0 +1,8 @@
import React from 'react';
export const ArrowRightIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" {...props}>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14 5l7 7m0 0l-7 7m7-7H3" />
</svg>
);

View File

@@ -0,0 +1,8 @@
import React from 'react';
export const BrushIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} {...props}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.5L15.232 5.232z" />
</svg>
);

View File

@@ -0,0 +1,8 @@
import React from 'react';
export const DownloadIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" {...props}>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
);

View File

@@ -0,0 +1,9 @@
import React from 'react';
export const EraserIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2} {...props}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19.452 7.618a.875.875 0 00-1.238 0l-7.366 7.366a.875.875 0 000 1.238l7.366 7.366a.875.875 0 001.238 0l3.85-3.85a.875.875 0 000-1.238l-7.366-7.366-3.85-3.85zM4 20h10" />
<path strokeLinecap="round" strokeLinejoin="round" d="M4 20l15-15" />
</svg>
);

View File

@@ -0,0 +1,8 @@
import React from 'react';
export const MagicIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" {...props}>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6.25278C12 6.25278 15.011 3.24178 17.761 6.00078C20.511 8.75978 17.5 12.0008 17.5 12.0008M12 6.25278C12 6.25278 8.989 3.24178 6.239 6.00078C3.489 8.75978 6.5 12.0008 6.5 12.0008M12 6.25278V21.0008M17.5 12.0008L19.25 13.7508M6.5 12.0008L4.75 13.7508M12 21.0008H14.25M12 21.0008H9.75" />
</svg>
);

View File

@@ -0,0 +1,8 @@
import React from 'react';
export const SparklesIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" {...props}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456zM16.898 20.572L16.5 21.75l-.398-1.178a3.375 3.375 0 00-2.456-2.456L12.5 18l1.178-.398a3.375 3.375 0 002.456-2.456L16.5 14.25l.398 1.178a3.375 3.375 0 002.456 2.456L20.5 18l-1.178.398a3.375 3.375 0 00-2.456 2.456z" />
</svg>
);

View File

@@ -0,0 +1,8 @@
import React from 'react';
export const UploadIcon: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" {...props}>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
</svg>
);