Refactor GTM Architect to v2: Python-driven architecture, 9-phase process, new DB and Docker setup
This commit is contained in:
79
k-pop-thumbnail-genie/components/DebugConsole.tsx
Normal file
79
k-pop-thumbnail-genie/components/DebugConsole.tsx
Normal 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">×</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;
|
||||
103
k-pop-thumbnail-genie/components/ImageResult.tsx
Normal file
103
k-pop-thumbnail-genie/components/ImageResult.tsx
Normal 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;
|
||||
257
k-pop-thumbnail-genie/components/ImageSegmenter.tsx
Normal file
257
k-pop-thumbnail-genie/components/ImageSegmenter.tsx
Normal 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;
|
||||
113
k-pop-thumbnail-genie/components/ImageUploader.tsx
Normal file
113
k-pop-thumbnail-genie/components/ImageUploader.tsx
Normal 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;
|
||||
151
k-pop-thumbnail-genie/components/PromptCustomizer.tsx
Normal file
151
k-pop-thumbnail-genie/components/PromptCustomizer.tsx
Normal 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;
|
||||
53
k-pop-thumbnail-genie/components/StepIndicator.tsx
Normal file
53
k-pop-thumbnail-genie/components/StepIndicator.tsx
Normal 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;
|
||||
8
k-pop-thumbnail-genie/components/icons/ArrowLeftIcon.tsx
Normal file
8
k-pop-thumbnail-genie/components/icons/ArrowLeftIcon.tsx
Normal 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>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
8
k-pop-thumbnail-genie/components/icons/BrushIcon.tsx
Normal file
8
k-pop-thumbnail-genie/components/icons/BrushIcon.tsx
Normal 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>
|
||||
);
|
||||
8
k-pop-thumbnail-genie/components/icons/DownloadIcon.tsx
Normal file
8
k-pop-thumbnail-genie/components/icons/DownloadIcon.tsx
Normal 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>
|
||||
);
|
||||
9
k-pop-thumbnail-genie/components/icons/EraserIcon.tsx
Normal file
9
k-pop-thumbnail-genie/components/icons/EraserIcon.tsx
Normal 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>
|
||||
);
|
||||
8
k-pop-thumbnail-genie/components/icons/MagicIcon.tsx
Normal file
8
k-pop-thumbnail-genie/components/icons/MagicIcon.tsx
Normal 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>
|
||||
);
|
||||
8
k-pop-thumbnail-genie/components/icons/SparklesIcon.tsx
Normal file
8
k-pop-thumbnail-genie/components/icons/SparklesIcon.tsx
Normal 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>
|
||||
);
|
||||
8
k-pop-thumbnail-genie/components/icons/UploadIcon.tsx
Normal file
8
k-pop-thumbnail-genie/components/icons/UploadIcon.tsx
Normal 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>
|
||||
);
|
||||
Reference in New Issue
Block a user