AI Researcher and Video Studio implementation complete
This commit is contained in:
@@ -13,7 +13,7 @@ import LinkedInWriter from './components/LinkedInWriter/LinkedInWriter';
|
||||
import BlogWriter from './components/BlogWriter/BlogWriter';
|
||||
import StoryWriter from './components/StoryWriter/StoryWriter';
|
||||
import YouTubeCreator from './components/YouTubeCreator/YouTubeCreator';
|
||||
import { CreateStudio, EditStudio, UpscaleStudio, ControlStudio, SocialOptimizer, AssetLibrary, ImageStudioDashboard } from './components/ImageStudio';
|
||||
import { CreateStudio, EditStudio, UpscaleStudio, ControlStudio, SocialOptimizer, AssetLibrary, ImageStudioDashboard, FaceSwapStudio, CompressionStudio, ImageProcessingStudio } from './components/ImageStudio';
|
||||
import {
|
||||
VideoStudioDashboard,
|
||||
CreateVideo,
|
||||
@@ -494,6 +494,9 @@ const App: React.FC = () => {
|
||||
<Route path="/image-editor" element={<ProtectedRoute><EditStudio /></ProtectedRoute>} />
|
||||
<Route path="/image-upscale" element={<ProtectedRoute><UpscaleStudio /></ProtectedRoute>} />
|
||||
<Route path="/image-control" element={<ProtectedRoute><ControlStudio /></ProtectedRoute>} />
|
||||
<Route path="/image-studio/face-swap" element={<ProtectedRoute><FaceSwapStudio /></ProtectedRoute>} />
|
||||
<Route path="/image-studio/compress" element={<ProtectedRoute><CompressionStudio /></ProtectedRoute>} />
|
||||
<Route path="/image-studio/processing" element={<ProtectedRoute><ImageProcessingStudio /></ProtectedRoute>} />
|
||||
<Route path="/image-studio/social-optimizer" element={<ProtectedRoute><SocialOptimizer /></ProtectedRoute>} />
|
||||
<Route path="/asset-library" element={<ProtectedRoute><AssetLibrary /></ProtectedRoute>} />
|
||||
<Route path="/campaign-creator" element={<ProtectedRoute><ProductMarketingDashboard /></ProtectedRoute>} />
|
||||
|
||||
@@ -4,9 +4,15 @@ import { ResearchSources, ResearchGrounding, GoogleSearchModal } from './Researc
|
||||
|
||||
interface ResearchResultsProps {
|
||||
research: BlogResearchResponse;
|
||||
showSourcesOnly?: boolean;
|
||||
showAnalysisOnly?: boolean;
|
||||
}
|
||||
|
||||
export const ResearchResults: React.FC<ResearchResultsProps> = ({ research }) => {
|
||||
export const ResearchResults: React.FC<ResearchResultsProps> = ({
|
||||
research,
|
||||
showSourcesOnly = false,
|
||||
showAnalysisOnly = false,
|
||||
}) => {
|
||||
const [showAnglesModal, setShowAnglesModal] = useState(false);
|
||||
const [showCompetitorModal, setShowCompetitorModal] = useState(false);
|
||||
const [showGroundingModal, setShowGroundingModal] = useState(false);
|
||||
|
||||
553
frontend/src/components/ImageStudio/CompressionStudio.tsx
Normal file
553
frontend/src/components/ImageStudio/CompressionStudio.tsx
Normal file
@@ -0,0 +1,553 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Grid,
|
||||
Paper,
|
||||
Stack,
|
||||
Typography,
|
||||
TextField,
|
||||
Alert,
|
||||
Slider,
|
||||
Divider,
|
||||
Chip,
|
||||
Select,
|
||||
MenuItem,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Switch,
|
||||
FormControlLabel,
|
||||
Card,
|
||||
CardContent,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
CircularProgress,
|
||||
Button,
|
||||
} from '@mui/material';
|
||||
import { alpha } from '@mui/material/styles';
|
||||
import CompressIcon from '@mui/icons-material/Compress';
|
||||
import UploadIcon from '@mui/icons-material/CloudUpload';
|
||||
import DownloadIcon from '@mui/icons-material/Download';
|
||||
import RestartAltIcon from '@mui/icons-material/RestartAlt';
|
||||
import InfoIcon from '@mui/icons-material/Info';
|
||||
import { motion, type Variants, type Easing } from 'framer-motion';
|
||||
import {
|
||||
useImageStudio,
|
||||
CompressionRequest,
|
||||
} from '../../hooks/useImageStudio';
|
||||
import { ImageStudioLayout } from './ImageStudioLayout';
|
||||
import { OperationButton } from '../shared/OperationButton';
|
||||
|
||||
const MotionPaper = motion(Paper);
|
||||
const fadeEase: Easing = [0.4, 0, 0.2, 1];
|
||||
|
||||
const cardVariants: Variants = {
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: { duration: 0.4, ease: fadeEase },
|
||||
},
|
||||
};
|
||||
|
||||
const readFileAsDataURL = (file: File): Promise<string> =>
|
||||
new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result as string);
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
|
||||
export const CompressionStudio: React.FC = () => {
|
||||
const {
|
||||
compressionFormats,
|
||||
compressionPresets,
|
||||
loadCompressionFormats,
|
||||
loadCompressionPresets,
|
||||
estimateCompression,
|
||||
processCompression,
|
||||
isCompressing,
|
||||
compressionResult,
|
||||
compressionError,
|
||||
compressionEstimate,
|
||||
clearCompressionResult,
|
||||
} = useImageStudio();
|
||||
|
||||
const [sourceImage, setSourceImage] = useState<string | null>(null);
|
||||
const [originalSize, setOriginalSize] = useState<number>(0);
|
||||
const [quality, setQuality] = useState<number>(85);
|
||||
const [format, setFormat] = useState<string>('jpeg');
|
||||
const [targetSizeEnabled, setTargetSizeEnabled] = useState(false);
|
||||
const [targetSizeKb, setTargetSizeKb] = useState<number>(200);
|
||||
const [stripMetadata, setStripMetadata] = useState(true);
|
||||
const [progressive, setProgressive] = useState(true);
|
||||
const [optimize, setOptimize] = useState(true);
|
||||
const [selectedPreset, setSelectedPreset] = useState<string>('');
|
||||
const [localError, setLocalError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadCompressionFormats();
|
||||
loadCompressionPresets();
|
||||
}, [loadCompressionFormats, loadCompressionPresets]);
|
||||
|
||||
// Auto-estimate when image or settings change
|
||||
useEffect(() => {
|
||||
if (sourceImage && !isCompressing) {
|
||||
const timer = setTimeout(() => {
|
||||
estimateCompression(sourceImage, format, quality);
|
||||
}, 300);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [sourceImage, format, quality, estimateCompression, isCompressing]);
|
||||
|
||||
const handleFileUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
setOriginalSize(file.size / 1024); // KB
|
||||
const dataUrl = await readFileAsDataURL(file);
|
||||
setSourceImage(dataUrl);
|
||||
clearCompressionResult();
|
||||
setLocalError(null);
|
||||
};
|
||||
|
||||
const handlePresetSelect = (presetId: string) => {
|
||||
const preset = compressionPresets.find(p => p.id === presetId);
|
||||
if (preset) {
|
||||
setSelectedPreset(presetId);
|
||||
setFormat(preset.format);
|
||||
setQuality(preset.quality);
|
||||
setStripMetadata(preset.strip_metadata);
|
||||
if (preset.target_size_kb) {
|
||||
setTargetSizeEnabled(true);
|
||||
setTargetSizeKb(preset.target_size_kb);
|
||||
} else {
|
||||
setTargetSizeEnabled(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleCompress = async () => {
|
||||
if (!sourceImage) {
|
||||
setLocalError('Please upload an image first.');
|
||||
return;
|
||||
}
|
||||
|
||||
setLocalError(null);
|
||||
try {
|
||||
const request: CompressionRequest = {
|
||||
image_base64: sourceImage,
|
||||
quality,
|
||||
format,
|
||||
target_size_kb: targetSizeEnabled ? targetSizeKb : undefined,
|
||||
strip_metadata: stripMetadata,
|
||||
progressive,
|
||||
optimize,
|
||||
};
|
||||
await processCompression(request);
|
||||
} catch {
|
||||
// Error handled in hook
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = () => {
|
||||
if (!compressionResult?.image_base64) return;
|
||||
const link = document.createElement('a');
|
||||
link.href = compressionResult.image_base64;
|
||||
link.download = `compressed-image.${compressionResult.format}`;
|
||||
link.click();
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setSourceImage(null);
|
||||
setOriginalSize(0);
|
||||
clearCompressionResult();
|
||||
setLocalError(null);
|
||||
setSelectedPreset('');
|
||||
};
|
||||
|
||||
const currentFormat = compressionFormats.find(f => f.id === format);
|
||||
|
||||
return (
|
||||
<ImageStudioLayout>
|
||||
<MotionPaper
|
||||
variants={cardVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
elevation={0}
|
||||
sx={{
|
||||
maxWidth: 1400,
|
||||
mx: 'auto',
|
||||
background: 'rgba(15,23,42,0.7)',
|
||||
borderRadius: 4,
|
||||
border: '1px solid rgba(255,255,255,0.08)',
|
||||
p: { xs: 3, md: 4 },
|
||||
backdropFilter: 'blur(20px)',
|
||||
}}
|
||||
>
|
||||
<Stack spacing={0.5} mb={3}>
|
||||
<Typography
|
||||
variant="h4"
|
||||
fontWeight={800}
|
||||
sx={{
|
||||
background: 'linear-gradient(90deg, #ede9fe, #c7d2fe)',
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
}}
|
||||
>
|
||||
Compression Studio
|
||||
</Typography>
|
||||
<Typography variant="body1" color="text.secondary">
|
||||
Optimize images for web, email, and social media with smart compression.
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
{(localError || compressionError) && (
|
||||
<Alert
|
||||
severity="error"
|
||||
sx={{ mb: 3 }}
|
||||
onClose={() => setLocalError(null)}
|
||||
>
|
||||
{localError || compressionError}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Grid container spacing={3}>
|
||||
{/* Left Panel - Settings */}
|
||||
<Grid item xs={12} md={5}>
|
||||
<Stack spacing={3}>
|
||||
{/* Image Upload */}
|
||||
<Card
|
||||
variant="outlined"
|
||||
sx={{
|
||||
borderRadius: 3,
|
||||
borderStyle: sourceImage ? 'solid' : 'dashed',
|
||||
borderColor: sourceImage ? alpha('#667eea', 0.4) : alpha('#cbd5f5', 0.8),
|
||||
background: sourceImage ? alpha('#667eea', 0.08) : alpha('#667eea', 0.02),
|
||||
}}
|
||||
>
|
||||
<CardContent>
|
||||
<Stack spacing={2}>
|
||||
<Stack direction="row" alignItems="center" justifyContent="space-between">
|
||||
<Typography variant="subtitle2" fontWeight={700}>
|
||||
Source Image
|
||||
</Typography>
|
||||
{sourceImage && (
|
||||
<Tooltip title="Remove">
|
||||
<IconButton size="small" onClick={() => {
|
||||
setSourceImage(null);
|
||||
setOriginalSize(0);
|
||||
clearCompressionResult();
|
||||
}}>
|
||||
<RestartAltIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Stack>
|
||||
{sourceImage ? (
|
||||
<Box sx={{ borderRadius: 2, overflow: 'hidden' }}>
|
||||
<img
|
||||
src={sourceImage}
|
||||
alt="Source"
|
||||
style={{ width: '100%', maxHeight: 200, objectFit: 'contain' }}
|
||||
/>
|
||||
<Typography variant="caption" color="text.secondary" display="block" mt={1}>
|
||||
Original size: {originalSize.toFixed(2)} KB
|
||||
</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<Button
|
||||
variant="outlined"
|
||||
component="label"
|
||||
startIcon={<UploadIcon />}
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
borderStyle: 'dashed',
|
||||
py: 3,
|
||||
color: '#667eea',
|
||||
borderColor: alpha('#667eea', 0.6),
|
||||
}}
|
||||
>
|
||||
Upload Image
|
||||
<input hidden type="file" accept="image/*" onChange={handleFileUpload} />
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Divider sx={{ borderColor: 'rgba(255,255,255,0.08)' }} />
|
||||
|
||||
{/* Presets */}
|
||||
<Stack spacing={1.5}>
|
||||
<Typography variant="subtitle1" fontWeight={700}>
|
||||
Quick Presets
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
|
||||
{compressionPresets.map((preset) => (
|
||||
<Chip
|
||||
key={preset.id}
|
||||
label={preset.name}
|
||||
onClick={() => handlePresetSelect(preset.id)}
|
||||
variant={selectedPreset === preset.id ? 'filled' : 'outlined'}
|
||||
sx={{
|
||||
borderColor: selectedPreset === preset.id ? '#667eea' : 'rgba(255,255,255,0.2)',
|
||||
bgcolor: selectedPreset === preset.id ? alpha('#667eea', 0.2) : 'transparent',
|
||||
color: selectedPreset === preset.id ? '#667eea' : 'inherit',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
<Divider sx={{ borderColor: 'rgba(255,255,255,0.08)' }} />
|
||||
|
||||
{/* Format Selection */}
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Output Format</InputLabel>
|
||||
<Select
|
||||
value={format}
|
||||
label="Output Format"
|
||||
onChange={(e) => {
|
||||
setFormat(e.target.value);
|
||||
setSelectedPreset('');
|
||||
}}
|
||||
>
|
||||
{compressionFormats.map((fmt) => (
|
||||
<MenuItem key={fmt.id} value={fmt.id}>
|
||||
<Stack>
|
||||
<Typography variant="body2">{fmt.name}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{fmt.description}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
{/* Quality Slider */}
|
||||
<Stack spacing={1}>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center">
|
||||
<Typography variant="subtitle2" fontWeight={600}>
|
||||
Quality
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{quality}%
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Slider
|
||||
value={quality}
|
||||
onChange={(_, val) => {
|
||||
setQuality(val as number);
|
||||
setSelectedPreset('');
|
||||
}}
|
||||
min={1}
|
||||
max={100}
|
||||
sx={{ color: '#667eea' }}
|
||||
/>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Lower quality = smaller file size. Recommended: 80-85 for web.
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
{/* Target Size */}
|
||||
<Stack spacing={1}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={targetSizeEnabled}
|
||||
onChange={(e) => setTargetSizeEnabled(e.target.checked)}
|
||||
sx={{ '& .MuiSwitch-switchBase.Mui-checked': { color: '#667eea' } }}
|
||||
/>
|
||||
}
|
||||
label="Target file size"
|
||||
/>
|
||||
{targetSizeEnabled && (
|
||||
<TextField
|
||||
type="number"
|
||||
value={targetSizeKb}
|
||||
onChange={(e) => setTargetSizeKb(parseInt(e.target.value) || 100)}
|
||||
InputProps={{
|
||||
endAdornment: <Typography variant="caption">KB</Typography>,
|
||||
}}
|
||||
size="small"
|
||||
helperText="Quality will auto-adjust to meet target size"
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{/* Advanced Options */}
|
||||
<Stack spacing={1}>
|
||||
<Typography variant="subtitle2" fontWeight={600}>
|
||||
Advanced Options
|
||||
</Typography>
|
||||
<FormControlLabel
|
||||
control={<Switch checked={stripMetadata} onChange={(e) => setStripMetadata(e.target.checked)} size="small" />}
|
||||
label={<Typography variant="body2">Strip metadata (EXIF)</Typography>}
|
||||
/>
|
||||
{format === 'jpeg' && (
|
||||
<FormControlLabel
|
||||
control={<Switch checked={progressive} onChange={(e) => setProgressive(e.target.checked)} size="small" />}
|
||||
label={<Typography variant="body2">Progressive JPEG</Typography>}
|
||||
/>
|
||||
)}
|
||||
<FormControlLabel
|
||||
control={<Switch checked={optimize} onChange={(e) => setOptimize(e.target.checked)} size="small" />}
|
||||
label={<Typography variant="body2">Optimize encoding</Typography>}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Grid>
|
||||
|
||||
{/* Right Panel - Results */}
|
||||
<Grid item xs={12} md={7}>
|
||||
<Stack spacing={3}>
|
||||
{/* Estimation */}
|
||||
{compressionEstimate && !compressionResult && (
|
||||
<Card sx={{ borderRadius: 3, background: 'rgba(255,255,255,0.05)' }}>
|
||||
<CardContent>
|
||||
<Stack direction="row" spacing={2} alignItems="center">
|
||||
<InfoIcon sx={{ color: '#667eea' }} />
|
||||
<Box flex={1}>
|
||||
<Typography variant="subtitle2">Estimated Result</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
~{compressionEstimate.estimated_size_kb.toFixed(1)} KB ({compressionEstimate.estimated_reduction_percent.toFixed(0)}% smaller)
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<OperationButton
|
||||
operation={{
|
||||
provider: 'local',
|
||||
operation_type: 'compress',
|
||||
actual_provider_name: 'pillow',
|
||||
}}
|
||||
label="Compress Image"
|
||||
startIcon={<CompressIcon />}
|
||||
onClick={handleCompress}
|
||||
disabled={!sourceImage}
|
||||
loading={isCompressing}
|
||||
checkOnMount={false}
|
||||
sx={{
|
||||
borderRadius: 999,
|
||||
alignSelf: 'flex-start',
|
||||
px: 4,
|
||||
py: 1.5,
|
||||
textTransform: 'none',
|
||||
fontWeight: 700,
|
||||
background: 'linear-gradient(90deg, #7c3aed, #2563eb)',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Result Viewer */}
|
||||
<Card
|
||||
sx={{
|
||||
borderRadius: 3,
|
||||
background: 'rgba(255,255,255,0.05)',
|
||||
border: '1px solid rgba(255,255,255,0.08)',
|
||||
minHeight: 300,
|
||||
}}
|
||||
>
|
||||
<CardContent>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center" mb={2}>
|
||||
<Typography variant="h6" fontWeight={700}>
|
||||
Results
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={1}>
|
||||
<Tooltip title="Reset">
|
||||
<span>
|
||||
<IconButton disabled={!compressionResult && !sourceImage} onClick={handleReset}>
|
||||
<RestartAltIcon />
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip title="Download">
|
||||
<span>
|
||||
<IconButton disabled={!compressionResult} onClick={handleDownload}>
|
||||
<DownloadIcon />
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
{isCompressing ? (
|
||||
<Stack alignItems="center" spacing={2} py={6}>
|
||||
<CircularProgress />
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Compressing image...
|
||||
</Typography>
|
||||
</Stack>
|
||||
) : compressionResult ? (
|
||||
<Stack spacing={2}>
|
||||
{/* Before/After Comparison */}
|
||||
<Stack direction={{ xs: 'column', md: 'row' }} spacing={2}>
|
||||
<Box flex={1}>
|
||||
<Typography variant="caption" color="text.secondary">Original</Typography>
|
||||
<Box sx={{ mt: 1, borderRadius: 2, overflow: 'hidden', border: '1px solid rgba(255,255,255,0.1)' }}>
|
||||
<img src={sourceImage || ''} alt="Original" style={{ width: '100%', maxHeight: 250, objectFit: 'contain' }} />
|
||||
</Box>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{compressionResult.original_size_kb.toFixed(1)} KB
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box flex={1}>
|
||||
<Typography variant="caption" color="text.secondary">Compressed</Typography>
|
||||
<Box sx={{ mt: 1, borderRadius: 2, overflow: 'hidden', border: '1px solid rgba(255,255,255,0.1)' }}>
|
||||
<img src={compressionResult.image_base64} alt="Compressed" style={{ width: '100%', maxHeight: 250, objectFit: 'contain' }} />
|
||||
</Box>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{compressionResult.compressed_size_kb.toFixed(1)} KB
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Stats */}
|
||||
<Stack direction="row" spacing={2} flexWrap="wrap" useFlexGap>
|
||||
<StatChip label="Reduction" value={`${compressionResult.compression_ratio.toFixed(1)}%`} />
|
||||
<StatChip label="Format" value={compressionResult.format.toUpperCase()} />
|
||||
<StatChip label="Dimensions" value={`${compressionResult.width}×${compressionResult.height}`} />
|
||||
<StatChip label="Quality" value={`${compressionResult.quality_used}%`} />
|
||||
<StatChip label="Metadata" value={compressionResult.metadata_stripped ? 'Stripped' : 'Kept'} />
|
||||
</Stack>
|
||||
</Stack>
|
||||
) : (
|
||||
<Stack alignItems="center" justifyContent="center" py={6}>
|
||||
<Typography variant="body1" color="text.secondary">
|
||||
Upload an image and configure settings to see compression results.
|
||||
</Typography>
|
||||
</Stack>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Stack>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</MotionPaper>
|
||||
</ImageStudioLayout>
|
||||
);
|
||||
};
|
||||
|
||||
const StatChip: React.FC<{ label: string; value: string }> = ({ label, value }) => (
|
||||
<Box
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
background: 'rgba(255,255,255,0.08)',
|
||||
px: 2,
|
||||
py: 1,
|
||||
minWidth: 100,
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{label}
|
||||
</Typography>
|
||||
<Typography variant="body2" fontWeight={600}>
|
||||
{value}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
@@ -29,6 +29,7 @@ import { EditResultViewer } from './EditResultViewer';
|
||||
import { ImageStudioLayout } from './ImageStudioLayout';
|
||||
import { OperationButton } from '../shared/OperationButton';
|
||||
import { ImageMaskEditor } from './ImageMaskEditor';
|
||||
import { ModelSelector } from './ModelSelector';
|
||||
|
||||
const MotionPaper = motion(Paper);
|
||||
const fadeEase: Easing = [0.4, 0, 0.2, 1];
|
||||
@@ -52,6 +53,12 @@ export const EditStudio: React.FC = () => {
|
||||
editResult,
|
||||
editError,
|
||||
clearEditResult,
|
||||
editModels,
|
||||
isLoadingEditModels,
|
||||
loadEditModels,
|
||||
modelRecommendation,
|
||||
isLoadingRecommendation,
|
||||
getModelRecommendation,
|
||||
} = useImageStudio();
|
||||
|
||||
const [operation, setOperation] = useState<string>('remove_background');
|
||||
@@ -71,10 +78,33 @@ export const EditStudio: React.FC = () => {
|
||||
});
|
||||
const [localError, setLocalError] = useState<string | null>(null);
|
||||
const [showMaskEditor, setShowMaskEditor] = useState(false);
|
||||
const [selectedModel, setSelectedModel] = useState<string | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
loadEditOperations();
|
||||
}, [loadEditOperations]);
|
||||
loadEditModels();
|
||||
}, [loadEditOperations, loadEditModels]);
|
||||
|
||||
// Auto-detect model when operation or image changes
|
||||
useEffect(() => {
|
||||
if (baseImage && operation === 'general_edit' && !selectedModel) {
|
||||
// Get image dimensions
|
||||
const img = new Image();
|
||||
img.onload = async () => {
|
||||
const recommendation = await getModelRecommendation(
|
||||
operation,
|
||||
{ width: img.width, height: img.height },
|
||||
undefined,
|
||||
{ prioritize_cost: true }
|
||||
);
|
||||
// Auto-select recommended model if available
|
||||
if (recommendation?.recommended_model) {
|
||||
setSelectedModel(recommendation.recommended_model);
|
||||
}
|
||||
};
|
||||
img.src = baseImage;
|
||||
}
|
||||
}, [baseImage, operation, selectedModel, getModelRecommendation]);
|
||||
|
||||
useEffect(() => {
|
||||
const keys = Object.keys(editOperations);
|
||||
@@ -142,6 +172,8 @@ export const EditStudio: React.FC = () => {
|
||||
expand_up: fields.expansion ? expansion.up : undefined,
|
||||
expand_down: fields.expansion ? expansion.down : undefined,
|
||||
output_format: 'png',
|
||||
provider: selectedModel ? 'wavespeed' : undefined,
|
||||
model: selectedModel || undefined,
|
||||
options: {},
|
||||
};
|
||||
return payload;
|
||||
@@ -223,6 +255,34 @@ export const EditStudio: React.FC = () => {
|
||||
onOpenMaskEditor={() => setShowMaskEditor(true)}
|
||||
/>
|
||||
<Divider sx={{ borderColor: 'rgba(255,255,255,0.08)' }} />
|
||||
|
||||
{/* Model Selection - Only show for general_edit */}
|
||||
{operation === 'general_edit' && editModels.length > 0 && (
|
||||
<>
|
||||
<Stack spacing={1.5}>
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<AutoFixHighIcon sx={{ color: '#a78bfa' }} />
|
||||
<Typography variant="subtitle1" fontWeight={700}>
|
||||
AI Model Selection
|
||||
</Typography>
|
||||
</Stack>
|
||||
<ModelSelector
|
||||
models={editModels}
|
||||
selectedModel={selectedModel}
|
||||
recommendedModel={modelRecommendation?.recommended_model}
|
||||
recommendationReason={modelRecommendation?.reason}
|
||||
onModelSelect={(modelId) => {
|
||||
setSelectedModel(modelId);
|
||||
setLocalError(null);
|
||||
}}
|
||||
loading={isLoadingEditModels || isLoadingRecommendation}
|
||||
operation={operation}
|
||||
/>
|
||||
</Stack>
|
||||
<Divider sx={{ borderColor: 'rgba(255,255,255,0.08)' }} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<Stack spacing={1.5}>
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<AutoFixHighIcon sx={{ color: '#a78bfa' }} />
|
||||
@@ -235,6 +295,10 @@ export const EditStudio: React.FC = () => {
|
||||
selectedOperation={operation}
|
||||
onSelect={key => {
|
||||
setOperation(key);
|
||||
// Reset model when operation changes (unless it's still general_edit)
|
||||
if (key !== 'general_edit') {
|
||||
setSelectedModel(undefined);
|
||||
}
|
||||
setLocalError(null);
|
||||
clearEditResult();
|
||||
}}
|
||||
@@ -357,6 +421,7 @@ export const EditStudio: React.FC = () => {
|
||||
setMaskImage(null);
|
||||
setBackgroundImage(null);
|
||||
setLightingImage(null);
|
||||
setSelectedModel(undefined);
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
142
frontend/src/components/ImageStudio/FaceSwapImageUploader.tsx
Normal file
142
frontend/src/components/ImageStudio/FaceSwapImageUploader.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
CardContent,
|
||||
Typography,
|
||||
Button,
|
||||
Stack,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
} from '@mui/material';
|
||||
import UploadIcon from '@mui/icons-material/CloudUpload';
|
||||
import DeleteIcon from '@mui/icons-material/DeleteOutline';
|
||||
import FaceIcon from '@mui/icons-material/Face';
|
||||
import ImageIcon from '@mui/icons-material/Image';
|
||||
import { alpha } from '@mui/material/styles';
|
||||
|
||||
interface FaceSwapImageUploaderProps {
|
||||
baseImage?: string | null;
|
||||
faceImage?: string | null;
|
||||
onBaseImageChange: (value: string | null) => void;
|
||||
onFaceImageChange: (value: string | null) => void;
|
||||
}
|
||||
|
||||
const readFileAsDataURL = (file: File): Promise<string> =>
|
||||
new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result as string);
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
|
||||
const UploadSlot: React.FC<{
|
||||
label: string;
|
||||
helper: string;
|
||||
icon: React.ReactNode;
|
||||
value?: string | null;
|
||||
onChange: (value: string | null) => void;
|
||||
}> = ({ label, helper, icon, value, onChange }) => {
|
||||
const handleFile = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
const dataUrl = await readFileAsDataURL(file);
|
||||
onChange(dataUrl);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
variant="outlined"
|
||||
sx={{
|
||||
borderRadius: 3,
|
||||
borderStyle: value ? 'solid' : 'dashed',
|
||||
borderColor: value ? alpha('#667eea', 0.4) : alpha('#cbd5f5', 0.8),
|
||||
background: value ? alpha('#667eea', 0.08) : alpha('#667eea', 0.02),
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<CardContent>
|
||||
<Stack spacing={1.5}>
|
||||
<Stack direction="row" alignItems="center" justifyContent="space-between">
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
{icon}
|
||||
<Box>
|
||||
<Typography variant="subtitle2" fontWeight={700}>
|
||||
{label}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{helper}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
{value && (
|
||||
<Tooltip title="Remove image">
|
||||
<IconButton size="small" onClick={() => onChange(null)}>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Stack>
|
||||
{value ? (
|
||||
<Box
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
border: '1px solid rgba(255,255,255,0.2)',
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={value}
|
||||
alt={`${label} preview`}
|
||||
style={{ width: '100%', display: 'block', objectFit: 'cover', maxHeight: 300 }}
|
||||
/>
|
||||
</Box>
|
||||
) : (
|
||||
<Button
|
||||
variant="outlined"
|
||||
component="label"
|
||||
startIcon={<UploadIcon />}
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
borderStyle: 'dashed',
|
||||
py: 2,
|
||||
color: '#667eea',
|
||||
borderColor: alpha('#667eea', 0.6),
|
||||
}}
|
||||
>
|
||||
Upload Image
|
||||
<input hidden type="file" accept="image/*" onChange={handleFile} />
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export const FaceSwapImageUploader: React.FC<FaceSwapImageUploaderProps> = ({
|
||||
baseImage,
|
||||
faceImage,
|
||||
onBaseImageChange,
|
||||
onFaceImageChange,
|
||||
}) => {
|
||||
return (
|
||||
<Stack spacing={2.5}>
|
||||
<UploadSlot
|
||||
label="Base Image"
|
||||
helper="Required. The image where the face will be swapped."
|
||||
icon={<ImageIcon sx={{ color: '#667eea' }} />}
|
||||
value={baseImage}
|
||||
onChange={onBaseImageChange}
|
||||
/>
|
||||
|
||||
<UploadSlot
|
||||
label="Face Image"
|
||||
helper="Required. The face to swap into the base image."
|
||||
icon={<FaceIcon sx={{ color: '#a78bfa' }} />}
|
||||
value={faceImage}
|
||||
onChange={onFaceImageChange}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
207
frontend/src/components/ImageStudio/FaceSwapResultViewer.tsx
Normal file
207
frontend/src/components/ImageStudio/FaceSwapResultViewer.tsx
Normal file
@@ -0,0 +1,207 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
CardContent,
|
||||
Divider,
|
||||
IconButton,
|
||||
Stack,
|
||||
Typography,
|
||||
Tooltip,
|
||||
CircularProgress,
|
||||
} from '@mui/material';
|
||||
import DownloadIcon from '@mui/icons-material/Download';
|
||||
import RestartAltIcon from '@mui/icons-material/RestartAlt';
|
||||
import { FaceSwapResult } from '../../hooks/useImageStudio';
|
||||
|
||||
interface FaceSwapResultViewerProps {
|
||||
baseImage?: string | null;
|
||||
faceImage?: string | null;
|
||||
result?: FaceSwapResult | null;
|
||||
isProcessing?: boolean;
|
||||
onReset?: () => void;
|
||||
}
|
||||
|
||||
export const FaceSwapResultViewer: React.FC<FaceSwapResultViewerProps> = ({
|
||||
baseImage,
|
||||
faceImage,
|
||||
result,
|
||||
isProcessing,
|
||||
onReset,
|
||||
}) => {
|
||||
const handleDownload = () => {
|
||||
if (!result?.image_base64) return;
|
||||
const link = document.createElement('a');
|
||||
link.href = result.image_base64;
|
||||
link.download = `face-swap-${result.model}-${Date.now()}.png`;
|
||||
link.click();
|
||||
};
|
||||
|
||||
if (!baseImage || !faceImage) {
|
||||
return (
|
||||
<Card
|
||||
sx={{
|
||||
borderRadius: 3,
|
||||
borderStyle: 'dashed',
|
||||
borderColor: 'rgba(255,255,255,0.1)',
|
||||
minHeight: 280,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: 'rgba(255,255,255,0.02)',
|
||||
}}
|
||||
>
|
||||
<Typography variant="body1" color="text.secondary">
|
||||
Upload both images to preview face swap.
|
||||
</Typography>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
sx={{
|
||||
borderRadius: 3,
|
||||
background: 'rgba(255,255,255,0.05)',
|
||||
border: '1px solid rgba(255,255,255,0.08)',
|
||||
}}
|
||||
>
|
||||
<CardContent>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center" mb={2}>
|
||||
<Typography variant="h6" fontWeight={700}>
|
||||
Results
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={1}>
|
||||
<Tooltip title="Reset">
|
||||
<span>
|
||||
<IconButton disabled={!result && !baseImage} onClick={onReset}>
|
||||
<RestartAltIcon />
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip title="Download result">
|
||||
<span>
|
||||
<IconButton disabled={!result} onClick={handleDownload}>
|
||||
<DownloadIcon />
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
<Stack direction={{ xs: 'column', md: 'row' }} spacing={2}>
|
||||
{/* Base Image */}
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Typography variant="caption" color="text.secondary" display="block" mb={1}>
|
||||
Base Image
|
||||
</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
border: '1px solid rgba(255,255,255,0.1)',
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={baseImage}
|
||||
alt="Base image"
|
||||
style={{ width: '100%', display: 'block', maxHeight: 300, objectFit: 'contain' }}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Face Image */}
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Typography variant="caption" color="text.secondary" display="block" mb={1}>
|
||||
Face Image
|
||||
</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
border: '1px solid rgba(255,255,255,0.1)',
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={faceImage}
|
||||
alt="Face image"
|
||||
style={{ width: '100%', display: 'block', maxHeight: 300, objectFit: 'contain' }}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Result */}
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Typography variant="caption" color="text.secondary" display="block" mb={1}>
|
||||
Swapped Result
|
||||
</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
minHeight: 180,
|
||||
border: '1px solid rgba(255,255,255,0.1)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
background: 'rgba(255,255,255,0.02)',
|
||||
}}
|
||||
>
|
||||
{isProcessing && (
|
||||
<Stack alignItems="center" spacing={1} py={6}>
|
||||
<CircularProgress />
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Swapping faces...
|
||||
</Typography>
|
||||
</Stack>
|
||||
)}
|
||||
{!isProcessing && result?.image_base64 && (
|
||||
<img
|
||||
src={result.image_base64}
|
||||
alt="Face swap result"
|
||||
style={{ width: '100%', display: 'block', maxHeight: 300, objectFit: 'contain' }}
|
||||
/>
|
||||
)}
|
||||
{!isProcessing && !result && (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
No result yet. Select a model and swap.
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
{result && (
|
||||
<>
|
||||
<Divider sx={{ my: 2 }} />
|
||||
<Stack direction="row" spacing={2} flexWrap="wrap" useFlexGap>
|
||||
<ChipLabel label="Model" value={result.model} />
|
||||
<ChipLabel label="Provider" value={result.provider} />
|
||||
<ChipLabel label="Resolution" value={`${result.width}×${result.height}`} />
|
||||
</Stack>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const ChipLabel: React.FC<{ label: string; value: string }> = ({ label, value }) => (
|
||||
<Box
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
background: 'rgba(255,255,255,0.08)',
|
||||
px: 2,
|
||||
py: 1,
|
||||
minWidth: 140,
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{label}
|
||||
</Typography>
|
||||
<Typography variant="body2" fontWeight={600}>
|
||||
{value}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
252
frontend/src/components/ImageStudio/FaceSwapStudio.tsx
Normal file
252
frontend/src/components/ImageStudio/FaceSwapStudio.tsx
Normal file
@@ -0,0 +1,252 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Grid,
|
||||
Paper,
|
||||
Stack,
|
||||
Typography,
|
||||
Alert,
|
||||
Divider,
|
||||
} from '@mui/material';
|
||||
import { alpha } from '@mui/material/styles';
|
||||
import SwapHorizIcon from '@mui/icons-material/SwapHoriz';
|
||||
import { motion, type Variants, type Easing } from 'framer-motion';
|
||||
import {
|
||||
useImageStudio,
|
||||
FaceSwapRequestPayload,
|
||||
} from '../../hooks/useImageStudio';
|
||||
import { FaceSwapImageUploader } from './FaceSwapImageUploader';
|
||||
import { FaceSwapResultViewer } from './FaceSwapResultViewer';
|
||||
import { ImageStudioLayout } from './ImageStudioLayout';
|
||||
import { OperationButton } from '../shared/OperationButton';
|
||||
import { ModelSelector } from './ModelSelector';
|
||||
|
||||
const MotionPaper = motion(Paper);
|
||||
const fadeEase: Easing = [0.4, 0, 0.2, 1];
|
||||
|
||||
const cardVariants: Variants = {
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: { duration: 0.4, ease: fadeEase },
|
||||
},
|
||||
};
|
||||
|
||||
export const FaceSwapStudio: React.FC = () => {
|
||||
const {
|
||||
loadFaceSwapModels,
|
||||
faceSwapModels,
|
||||
isLoadingFaceSwapModels,
|
||||
getFaceSwapModelRecommendation,
|
||||
faceSwapModelRecommendation,
|
||||
isLoadingFaceSwapRecommendation,
|
||||
processFaceSwap,
|
||||
isProcessingFaceSwap,
|
||||
faceSwapResult,
|
||||
faceSwapError,
|
||||
clearFaceSwapResult,
|
||||
} = useImageStudio();
|
||||
|
||||
const [baseImage, setBaseImage] = useState<string | null>(null);
|
||||
const [faceImage, setFaceImage] = useState<string | null>(null);
|
||||
const [selectedModel, setSelectedModel] = useState<string | undefined>(undefined);
|
||||
const [localError, setLocalError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadFaceSwapModels();
|
||||
}, [loadFaceSwapModels]);
|
||||
|
||||
// Auto-detect model when images change
|
||||
useEffect(() => {
|
||||
if (baseImage && faceImage && !selectedModel) {
|
||||
// Get image dimensions
|
||||
const baseImg = new Image();
|
||||
const faceImg = new Image();
|
||||
|
||||
baseImg.onload = () => {
|
||||
faceImg.onload = async () => {
|
||||
const recommendation = await getFaceSwapModelRecommendation(
|
||||
{ width: baseImg.width, height: baseImg.height },
|
||||
{ width: faceImg.width, height: faceImg.height },
|
||||
undefined,
|
||||
{ prioritize_cost: true }
|
||||
);
|
||||
// Auto-select recommended model if available
|
||||
if (recommendation?.recommended_model) {
|
||||
setSelectedModel(recommendation.recommended_model);
|
||||
}
|
||||
};
|
||||
faceImg.src = faceImage;
|
||||
};
|
||||
baseImg.src = baseImage;
|
||||
}
|
||||
}, [baseImage, faceImage, selectedModel, getFaceSwapModelRecommendation]);
|
||||
|
||||
const canSubmit = useMemo(() => {
|
||||
return !!(baseImage && faceImage);
|
||||
}, [baseImage, faceImage]);
|
||||
|
||||
const buildPayload = (): FaceSwapRequestPayload | null => {
|
||||
if (!baseImage) {
|
||||
setLocalError('Please upload a base image.');
|
||||
return null;
|
||||
}
|
||||
if (!faceImage) {
|
||||
setLocalError('Please upload a face image.');
|
||||
return null;
|
||||
}
|
||||
|
||||
const payload: FaceSwapRequestPayload = {
|
||||
base_image_base64: baseImage,
|
||||
face_image_base64: faceImage,
|
||||
model: selectedModel,
|
||||
};
|
||||
return payload;
|
||||
};
|
||||
|
||||
const handleSwap = async () => {
|
||||
setLocalError(null);
|
||||
try {
|
||||
const payload = buildPayload();
|
||||
if (!payload) return;
|
||||
await processFaceSwap(payload);
|
||||
} catch {
|
||||
// errors handled in hook
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ImageStudioLayout>
|
||||
<MotionPaper
|
||||
variants={cardVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
elevation={0}
|
||||
sx={{
|
||||
maxWidth: 1400,
|
||||
mx: 'auto',
|
||||
background: 'rgba(15,23,42,0.7)',
|
||||
borderRadius: 4,
|
||||
border: '1px solid rgba(255,255,255,0.08)',
|
||||
p: { xs: 3, md: 4 },
|
||||
backdropFilter: 'blur(20px)',
|
||||
}}
|
||||
>
|
||||
<Stack spacing={0.5} mb={3}>
|
||||
<Typography
|
||||
variant="h4"
|
||||
fontWeight={800}
|
||||
sx={{
|
||||
background: 'linear-gradient(90deg, #ede9fe, #c7d2fe)',
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
}}
|
||||
>
|
||||
Face Swap Studio
|
||||
</Typography>
|
||||
<Typography variant="body1" color="text.secondary">
|
||||
AI-powered face swapping with multiple models. Swap faces in photos with realistic, high-quality results.
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
{(localError || faceSwapError) && (
|
||||
<Alert
|
||||
severity="error"
|
||||
sx={{ mb: 3 }}
|
||||
onClose={() => {
|
||||
setLocalError(null);
|
||||
}}
|
||||
>
|
||||
{localError || faceSwapError}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12} md={5}>
|
||||
<Stack spacing={3}>
|
||||
<FaceSwapImageUploader
|
||||
baseImage={baseImage}
|
||||
faceImage={faceImage}
|
||||
onBaseImageChange={(img) => {
|
||||
setBaseImage(img);
|
||||
setLocalError(null);
|
||||
clearFaceSwapResult();
|
||||
}}
|
||||
onFaceImageChange={(img) => {
|
||||
setFaceImage(img);
|
||||
setLocalError(null);
|
||||
clearFaceSwapResult();
|
||||
}}
|
||||
/>
|
||||
<Divider sx={{ borderColor: 'rgba(255,255,255,0.08)' }} />
|
||||
|
||||
{/* Model Selection */}
|
||||
{faceSwapModels.length > 0 && (
|
||||
<Stack spacing={1.5}>
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<SwapHorizIcon sx={{ color: '#a78bfa' }} />
|
||||
<Typography variant="subtitle1" fontWeight={700}>
|
||||
AI Model Selection
|
||||
</Typography>
|
||||
</Stack>
|
||||
<ModelSelector
|
||||
models={faceSwapModels}
|
||||
selectedModel={selectedModel}
|
||||
recommendedModel={faceSwapModelRecommendation?.recommended_model}
|
||||
recommendationReason={faceSwapModelRecommendation?.reason}
|
||||
onModelSelect={(modelId) => {
|
||||
setSelectedModel(modelId);
|
||||
setLocalError(null);
|
||||
}}
|
||||
loading={isLoadingFaceSwapModels || isLoadingFaceSwapRecommendation}
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={7}>
|
||||
<Stack spacing={3}>
|
||||
<OperationButton
|
||||
operation={{
|
||||
provider: 'wavespeed',
|
||||
operation_type: 'face-swap',
|
||||
actual_provider_name: 'wavespeed',
|
||||
}}
|
||||
label="Swap Face"
|
||||
startIcon={<SwapHorizIcon />}
|
||||
onClick={handleSwap}
|
||||
disabled={!canSubmit}
|
||||
loading={isProcessingFaceSwap}
|
||||
checkOnMount
|
||||
sx={{
|
||||
borderRadius: 999,
|
||||
alignSelf: 'flex-start',
|
||||
px: 4,
|
||||
py: 1.5,
|
||||
textTransform: 'none',
|
||||
fontWeight: 700,
|
||||
background: 'linear-gradient(90deg, #7c3aed, #2563eb)',
|
||||
}}
|
||||
/>
|
||||
|
||||
<FaceSwapResultViewer
|
||||
baseImage={baseImage}
|
||||
faceImage={faceImage}
|
||||
result={faceSwapResult}
|
||||
isProcessing={isProcessingFaceSwap}
|
||||
onReset={() => {
|
||||
clearFaceSwapResult();
|
||||
setBaseImage(null);
|
||||
setFaceImage(null);
|
||||
setSelectedModel(undefined);
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</MotionPaper>
|
||||
</ImageStudioLayout>
|
||||
);
|
||||
};
|
||||
154
frontend/src/components/ImageStudio/ImageProcessingStudio.tsx
Normal file
154
frontend/src/components/ImageStudio/ImageProcessingStudio.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Paper,
|
||||
Tabs,
|
||||
Tab,
|
||||
Typography,
|
||||
Stack,
|
||||
} from '@mui/material';
|
||||
import { motion } from 'framer-motion';
|
||||
import CompressIcon from '@mui/icons-material/Compress';
|
||||
import TransformIcon from '@mui/icons-material/Transform';
|
||||
import AspectRatioIcon from '@mui/icons-material/AspectRatio';
|
||||
import WaterDropIcon from '@mui/icons-material/WaterDrop';
|
||||
import { ImageStudioLayout } from './ImageStudioLayout';
|
||||
import { FormatConverterTab } from './ImageProcessingStudio/FormatConverterTab';
|
||||
import { CompressionTab } from './ImageProcessingStudio/CompressionTab';
|
||||
|
||||
interface TabPanelProps {
|
||||
children?: React.ReactNode;
|
||||
index: number;
|
||||
value: number;
|
||||
}
|
||||
|
||||
const TabPanel: React.FC<TabPanelProps> = ({ children, value, index }) => {
|
||||
return (
|
||||
<div
|
||||
role="tabpanel"
|
||||
hidden={value !== index}
|
||||
id={`processing-tabpanel-${index}`}
|
||||
aria-labelledby={`processing-tab-${index}`}
|
||||
>
|
||||
{value === index && <Box sx={{ pt: 3 }}>{children}</Box>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const ImageProcessingStudio: React.FC = () => {
|
||||
const [activeTab, setActiveTab] = useState(0);
|
||||
|
||||
const handleTabChange = (_event: React.SyntheticEvent, newValue: number) => {
|
||||
setActiveTab(newValue);
|
||||
};
|
||||
|
||||
return (
|
||||
<ImageStudioLayout>
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
maxWidth: 1400,
|
||||
mx: 'auto',
|
||||
background: 'rgba(15,23,42,0.7)',
|
||||
borderRadius: 4,
|
||||
border: '1px solid rgba(255,255,255,0.08)',
|
||||
p: { xs: 2, md: 3 },
|
||||
backdropFilter: 'blur(20px)',
|
||||
}}
|
||||
>
|
||||
<Stack spacing={2} mb={2}>
|
||||
<Typography
|
||||
variant="h4"
|
||||
fontWeight={800}
|
||||
sx={{
|
||||
background: 'linear-gradient(90deg, #ede9fe, #c7d2fe)',
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
}}
|
||||
>
|
||||
Image Processing Studio
|
||||
</Typography>
|
||||
<Typography variant="body1" color="text.secondary">
|
||||
All-in-one toolkit for compression, format conversion, resizing, and watermarking.
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
<Box sx={{ borderBottom: 1, borderColor: 'rgba(255,255,255,0.1)' }}>
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onChange={handleTabChange}
|
||||
sx={{
|
||||
'& .MuiTab-root': {
|
||||
color: 'rgba(255,255,255,0.6)',
|
||||
textTransform: 'none',
|
||||
fontWeight: 600,
|
||||
minHeight: 64,
|
||||
'&.Mui-selected': {
|
||||
color: '#667eea',
|
||||
},
|
||||
},
|
||||
'& .MuiTabs-indicator': {
|
||||
backgroundColor: '#667eea',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Tab
|
||||
icon={<CompressIcon />}
|
||||
iconPosition="start"
|
||||
label="Compression"
|
||||
id="processing-tab-0"
|
||||
aria-controls="processing-tabpanel-0"
|
||||
/>
|
||||
<Tab
|
||||
icon={<TransformIcon />}
|
||||
iconPosition="start"
|
||||
label="Format Converter"
|
||||
id="processing-tab-1"
|
||||
aria-controls="processing-tabpanel-1"
|
||||
/>
|
||||
<Tab
|
||||
icon={<AspectRatioIcon />}
|
||||
iconPosition="start"
|
||||
label="Resizer"
|
||||
id="processing-tab-2"
|
||||
aria-controls="processing-tabpanel-2"
|
||||
disabled
|
||||
/>
|
||||
<Tab
|
||||
icon={<WaterDropIcon />}
|
||||
iconPosition="start"
|
||||
label="Watermark"
|
||||
id="processing-tab-3"
|
||||
aria-controls="processing-tabpanel-3"
|
||||
disabled
|
||||
/>
|
||||
</Tabs>
|
||||
</Box>
|
||||
|
||||
<TabPanel value={activeTab} index={0}>
|
||||
<CompressionTab />
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel value={activeTab} index={1}>
|
||||
<FormatConverterTab />
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel value={activeTab} index={2}>
|
||||
<Box sx={{ p: 4, textAlign: 'center' }}>
|
||||
<Typography variant="h6" color="text.secondary">
|
||||
Resizer & Cropper - Coming Soon
|
||||
</Typography>
|
||||
</Box>
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel value={activeTab} index={3}>
|
||||
<Box sx={{ p: 4, textAlign: 'center' }}>
|
||||
<Typography variant="h6" color="text.secondary">
|
||||
Watermark Studio - Coming Soon
|
||||
</Typography>
|
||||
</Box>
|
||||
</TabPanel>
|
||||
</Paper>
|
||||
</ImageStudioLayout>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,484 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Grid,
|
||||
Stack,
|
||||
Typography,
|
||||
TextField,
|
||||
Alert,
|
||||
Slider,
|
||||
Divider,
|
||||
Chip,
|
||||
Select,
|
||||
MenuItem,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Switch,
|
||||
FormControlLabel,
|
||||
Card,
|
||||
CardContent,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
CircularProgress,
|
||||
Button,
|
||||
} from '@mui/material';
|
||||
import { alpha } from '@mui/material/styles';
|
||||
import CompressIcon from '@mui/icons-material/Compress';
|
||||
import UploadIcon from '@mui/icons-material/CloudUpload';
|
||||
import DownloadIcon from '@mui/icons-material/Download';
|
||||
import RestartAltIcon from '@mui/icons-material/RestartAlt';
|
||||
import InfoIcon from '@mui/icons-material/Info';
|
||||
import {
|
||||
useImageStudio,
|
||||
CompressionRequest,
|
||||
} from '../../../hooks/useImageStudio';
|
||||
import { OperationButton } from '../../shared/OperationButton';
|
||||
|
||||
const readFileAsDataURL = (file: File): Promise<string> =>
|
||||
new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result as string);
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
|
||||
const StatChip: React.FC<{ label: string; value: string }> = ({ label, value }) => (
|
||||
<Box
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
background: 'rgba(255,255,255,0.08)',
|
||||
px: 2,
|
||||
py: 1,
|
||||
minWidth: 100,
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{label}
|
||||
</Typography>
|
||||
<Typography variant="body2" fontWeight={600}>
|
||||
{value}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
|
||||
export const CompressionTab: React.FC = () => {
|
||||
const {
|
||||
compressionFormats,
|
||||
compressionPresets,
|
||||
loadCompressionFormats,
|
||||
loadCompressionPresets,
|
||||
estimateCompression,
|
||||
processCompression,
|
||||
isCompressing,
|
||||
compressionResult,
|
||||
compressionError,
|
||||
compressionEstimate,
|
||||
clearCompressionResult,
|
||||
} = useImageStudio();
|
||||
|
||||
const [sourceImage, setSourceImage] = useState<string | null>(null);
|
||||
const [originalSize, setOriginalSize] = useState<number>(0);
|
||||
const [quality, setQuality] = useState<number>(85);
|
||||
const [format, setFormat] = useState<string>('jpeg');
|
||||
const [targetSizeEnabled, setTargetSizeEnabled] = useState(false);
|
||||
const [targetSizeKb, setTargetSizeKb] = useState<number>(200);
|
||||
const [stripMetadata, setStripMetadata] = useState(true);
|
||||
const [progressive, setProgressive] = useState(true);
|
||||
const [optimize, setOptimize] = useState(true);
|
||||
const [selectedPreset, setSelectedPreset] = useState<string>('');
|
||||
const [localError, setLocalError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadCompressionFormats();
|
||||
loadCompressionPresets();
|
||||
}, [loadCompressionFormats, loadCompressionPresets]);
|
||||
|
||||
useEffect(() => {
|
||||
if (sourceImage && !isCompressing) {
|
||||
const timer = setTimeout(() => {
|
||||
estimateCompression(sourceImage, format, quality);
|
||||
}, 300);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [sourceImage, format, quality, estimateCompression, isCompressing]);
|
||||
|
||||
const handleFileUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
setOriginalSize(file.size / 1024);
|
||||
const dataUrl = await readFileAsDataURL(file);
|
||||
setSourceImage(dataUrl);
|
||||
clearCompressionResult();
|
||||
setLocalError(null);
|
||||
};
|
||||
|
||||
const handlePresetSelect = (presetId: string) => {
|
||||
const preset = compressionPresets.find(p => p.id === presetId);
|
||||
if (preset) {
|
||||
setSelectedPreset(presetId);
|
||||
setFormat(preset.format);
|
||||
setQuality(preset.quality);
|
||||
setStripMetadata(preset.strip_metadata);
|
||||
if (preset.target_size_kb) {
|
||||
setTargetSizeEnabled(true);
|
||||
setTargetSizeKb(preset.target_size_kb);
|
||||
} else {
|
||||
setTargetSizeEnabled(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleCompress = async () => {
|
||||
if (!sourceImage) {
|
||||
setLocalError('Please upload an image first.');
|
||||
return;
|
||||
}
|
||||
|
||||
setLocalError(null);
|
||||
try {
|
||||
const request: CompressionRequest = {
|
||||
image_base64: sourceImage,
|
||||
quality,
|
||||
format,
|
||||
target_size_kb: targetSizeEnabled ? targetSizeKb : undefined,
|
||||
strip_metadata: stripMetadata,
|
||||
progressive,
|
||||
optimize,
|
||||
};
|
||||
await processCompression(request);
|
||||
} catch {
|
||||
// Error handled in hook
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = () => {
|
||||
if (!compressionResult?.image_base64) return;
|
||||
const link = document.createElement('a');
|
||||
link.href = compressionResult.image_base64;
|
||||
link.download = `compressed-image.${compressionResult.format}`;
|
||||
link.click();
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setSourceImage(null);
|
||||
setOriginalSize(0);
|
||||
clearCompressionResult();
|
||||
setLocalError(null);
|
||||
setSelectedPreset('');
|
||||
};
|
||||
|
||||
return (
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12} md={5}>
|
||||
<Stack spacing={3}>
|
||||
<Card
|
||||
variant="outlined"
|
||||
sx={{
|
||||
borderRadius: 3,
|
||||
borderStyle: sourceImage ? 'solid' : 'dashed',
|
||||
borderColor: sourceImage ? alpha('#667eea', 0.4) : alpha('#cbd5f5', 0.8),
|
||||
background: sourceImage ? alpha('#667eea', 0.08) : alpha('#667eea', 0.02),
|
||||
}}
|
||||
>
|
||||
<CardContent>
|
||||
<Stack spacing={2}>
|
||||
<Stack direction="row" alignItems="center" justifyContent="space-between">
|
||||
<Typography variant="subtitle2" fontWeight={700}>
|
||||
Source Image
|
||||
</Typography>
|
||||
{sourceImage && (
|
||||
<Tooltip title="Remove">
|
||||
<IconButton size="small" onClick={() => {
|
||||
setSourceImage(null);
|
||||
setOriginalSize(0);
|
||||
clearCompressionResult();
|
||||
}}>
|
||||
<RestartAltIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Stack>
|
||||
{sourceImage ? (
|
||||
<Box sx={{ borderRadius: 2, overflow: 'hidden' }}>
|
||||
<img
|
||||
src={sourceImage}
|
||||
alt="Source"
|
||||
style={{ width: '100%', maxHeight: 200, objectFit: 'contain' }}
|
||||
/>
|
||||
<Typography variant="caption" color="text.secondary" display="block" mt={1}>
|
||||
Original size: {originalSize.toFixed(2)} KB
|
||||
</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<Button
|
||||
variant="outlined"
|
||||
component="label"
|
||||
startIcon={<UploadIcon />}
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
borderStyle: 'dashed',
|
||||
py: 3,
|
||||
color: '#667eea',
|
||||
borderColor: alpha('#667eea', 0.6),
|
||||
}}
|
||||
>
|
||||
Upload Image
|
||||
<input hidden type="file" accept="image/*" onChange={handleFileUpload} />
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Divider sx={{ borderColor: 'rgba(255,255,255,0.08)' }} />
|
||||
|
||||
<Stack spacing={1.5}>
|
||||
<Typography variant="subtitle1" fontWeight={700}>
|
||||
Quick Presets
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
|
||||
{compressionPresets.map((preset) => (
|
||||
<Chip
|
||||
key={preset.id}
|
||||
label={preset.name}
|
||||
onClick={() => handlePresetSelect(preset.id)}
|
||||
variant={selectedPreset === preset.id ? 'filled' : 'outlined'}
|
||||
sx={{
|
||||
borderColor: selectedPreset === preset.id ? '#667eea' : 'rgba(255,255,255,0.2)',
|
||||
bgcolor: selectedPreset === preset.id ? alpha('#667eea', 0.2) : 'transparent',
|
||||
color: selectedPreset === preset.id ? '#667eea' : 'inherit',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
<Divider sx={{ borderColor: 'rgba(255,255,255,0.08)' }} />
|
||||
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Output Format</InputLabel>
|
||||
<Select
|
||||
value={format}
|
||||
label="Output Format"
|
||||
onChange={(e) => {
|
||||
setFormat(e.target.value);
|
||||
setSelectedPreset('');
|
||||
}}
|
||||
>
|
||||
{compressionFormats.map((fmt) => (
|
||||
<MenuItem key={fmt.id} value={fmt.id}>
|
||||
<Stack>
|
||||
<Typography variant="body2">{fmt.name}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{fmt.description}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<Stack spacing={1}>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center">
|
||||
<Typography variant="subtitle2" fontWeight={600}>
|
||||
Quality
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{quality}%
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Slider
|
||||
value={quality}
|
||||
onChange={(_, val) => {
|
||||
setQuality(val as number);
|
||||
setSelectedPreset('');
|
||||
}}
|
||||
min={1}
|
||||
max={100}
|
||||
sx={{ color: '#667eea' }}
|
||||
/>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Lower quality = smaller file size. Recommended: 80-85 for web.
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
<Stack spacing={1}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={targetSizeEnabled}
|
||||
onChange={(e) => setTargetSizeEnabled(e.target.checked)}
|
||||
sx={{ '& .MuiSwitch-switchBase.Mui-checked': { color: '#667eea' } }}
|
||||
/>
|
||||
}
|
||||
label="Target file size"
|
||||
/>
|
||||
{targetSizeEnabled && (
|
||||
<TextField
|
||||
type="number"
|
||||
value={targetSizeKb}
|
||||
onChange={(e) => setTargetSizeKb(parseInt(e.target.value) || 100)}
|
||||
InputProps={{
|
||||
endAdornment: <Typography variant="caption">KB</Typography>,
|
||||
}}
|
||||
size="small"
|
||||
helperText="Quality will auto-adjust to meet target size"
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Stack spacing={1}>
|
||||
<Typography variant="subtitle2" fontWeight={600}>
|
||||
Advanced Options
|
||||
</Typography>
|
||||
<FormControlLabel
|
||||
control={<Switch checked={stripMetadata} onChange={(e) => setStripMetadata(e.target.checked)} size="small" />}
|
||||
label={<Typography variant="body2">Strip metadata (EXIF)</Typography>}
|
||||
/>
|
||||
{format === 'jpeg' && (
|
||||
<FormControlLabel
|
||||
control={<Switch checked={progressive} onChange={(e) => setProgressive(e.target.checked)} size="small" />}
|
||||
label={<Typography variant="body2">Progressive JPEG</Typography>}
|
||||
/>
|
||||
)}
|
||||
<FormControlLabel
|
||||
control={<Switch checked={optimize} onChange={(e) => setOptimize(e.target.checked)} size="small" />}
|
||||
label={<Typography variant="body2">Optimize encoding</Typography>}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={7}>
|
||||
<Stack spacing={3}>
|
||||
{(localError || compressionError) && (
|
||||
<Alert severity="error" onClose={() => setLocalError(null)}>
|
||||
{localError || compressionError}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{compressionEstimate && !compressionResult && (
|
||||
<Card sx={{ borderRadius: 3, background: 'rgba(255,255,255,0.05)' }}>
|
||||
<CardContent>
|
||||
<Stack direction="row" spacing={2} alignItems="center">
|
||||
<InfoIcon sx={{ color: '#667eea' }} />
|
||||
<Box flex={1}>
|
||||
<Typography variant="subtitle2">Estimated Result</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
~{compressionEstimate.estimated_size_kb.toFixed(1)} KB ({compressionEstimate.estimated_reduction_percent.toFixed(0)}% smaller)
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<OperationButton
|
||||
operation={{
|
||||
provider: 'local',
|
||||
operation_type: 'compress',
|
||||
actual_provider_name: 'pillow',
|
||||
}}
|
||||
label="Compress Image"
|
||||
startIcon={<CompressIcon />}
|
||||
onClick={handleCompress}
|
||||
disabled={!sourceImage}
|
||||
loading={isCompressing}
|
||||
checkOnMount={false}
|
||||
sx={{
|
||||
borderRadius: 999,
|
||||
alignSelf: 'flex-start',
|
||||
px: 4,
|
||||
py: 1.5,
|
||||
textTransform: 'none',
|
||||
fontWeight: 700,
|
||||
background: 'linear-gradient(90deg, #7c3aed, #2563eb)',
|
||||
}}
|
||||
/>
|
||||
|
||||
<Card
|
||||
sx={{
|
||||
borderRadius: 3,
|
||||
background: 'rgba(255,255,255,0.05)',
|
||||
border: '1px solid rgba(255,255,255,0.08)',
|
||||
minHeight: 300,
|
||||
}}
|
||||
>
|
||||
<CardContent>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center" mb={2}>
|
||||
<Typography variant="h6" fontWeight={700}>
|
||||
Results
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={1}>
|
||||
<Tooltip title="Reset">
|
||||
<span>
|
||||
<IconButton disabled={!compressionResult && !sourceImage} onClick={handleReset}>
|
||||
<RestartAltIcon />
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip title="Download">
|
||||
<span>
|
||||
<IconButton disabled={!compressionResult} onClick={handleDownload}>
|
||||
<DownloadIcon />
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
{isCompressing ? (
|
||||
<Stack alignItems="center" spacing={2} py={6}>
|
||||
<CircularProgress />
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Compressing image...
|
||||
</Typography>
|
||||
</Stack>
|
||||
) : compressionResult ? (
|
||||
<Stack spacing={2}>
|
||||
<Stack direction={{ xs: 'column', md: 'row' }} spacing={2}>
|
||||
<Box flex={1}>
|
||||
<Typography variant="caption" color="text.secondary">Original</Typography>
|
||||
<Box sx={{ mt: 1, borderRadius: 2, overflow: 'hidden', border: '1px solid rgba(255,255,255,0.1)' }}>
|
||||
<img src={sourceImage || ''} alt="Original" style={{ width: '100%', maxHeight: 250, objectFit: 'contain' }} />
|
||||
</Box>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{compressionResult.original_size_kb.toFixed(1)} KB
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box flex={1}>
|
||||
<Typography variant="caption" color="text.secondary">Compressed</Typography>
|
||||
<Box sx={{ mt: 1, borderRadius: 2, overflow: 'hidden', border: '1px solid rgba(255,255,255,0.1)' }}>
|
||||
<img src={compressionResult.image_base64} alt="Compressed" style={{ width: '100%', maxHeight: 250, objectFit: 'contain' }} />
|
||||
</Box>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{compressionResult.compressed_size_kb.toFixed(1)} KB
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Stack direction="row" spacing={2} flexWrap="wrap" useFlexGap>
|
||||
<StatChip label="Reduction" value={`${compressionResult.compression_ratio.toFixed(1)}%`} />
|
||||
<StatChip label="Format" value={compressionResult.format.toUpperCase()} />
|
||||
<StatChip label="Dimensions" value={`${compressionResult.width}×${compressionResult.height}`} />
|
||||
<StatChip label="Quality" value={`${compressionResult.quality_used}%`} />
|
||||
<StatChip label="Metadata" value={compressionResult.metadata_stripped ? 'Stripped' : 'Kept'} />
|
||||
</Stack>
|
||||
</Stack>
|
||||
) : (
|
||||
<Stack alignItems="center" justifyContent="center" py={6}>
|
||||
<Typography variant="body1" color="text.secondary">
|
||||
Upload an image and configure settings to see compression results.
|
||||
</Typography>
|
||||
</Stack>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Stack>
|
||||
</Grid>
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,509 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Grid,
|
||||
Stack,
|
||||
Typography,
|
||||
TextField,
|
||||
Alert,
|
||||
Select,
|
||||
MenuItem,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Switch,
|
||||
FormControlLabel,
|
||||
Card,
|
||||
CardContent,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
CircularProgress,
|
||||
Button,
|
||||
Chip,
|
||||
} from '@mui/material';
|
||||
import { alpha } from '@mui/material/styles';
|
||||
import TransformIcon from '@mui/icons-material/Transform';
|
||||
import UploadIcon from '@mui/icons-material/CloudUpload';
|
||||
import DownloadIcon from '@mui/icons-material/Download';
|
||||
import RestartAltIcon from '@mui/icons-material/RestartAlt';
|
||||
import InfoIcon from '@mui/icons-material/Info';
|
||||
import {
|
||||
useImageStudio,
|
||||
FormatConversionRequest,
|
||||
} from '../../../hooks/useImageStudio';
|
||||
import { OperationButton } from '../../shared/OperationButton';
|
||||
|
||||
const readFileAsDataURL = (file: File): Promise<string> =>
|
||||
new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result as string);
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
|
||||
export const FormatConverterTab: React.FC = () => {
|
||||
const {
|
||||
supportedFormats,
|
||||
loadSupportedFormats,
|
||||
getFormatRecommendations,
|
||||
processFormatConversion,
|
||||
isConvertingFormat,
|
||||
formatConversionResult,
|
||||
formatConversionError,
|
||||
formatRecommendations,
|
||||
clearFormatConversionResult,
|
||||
} = useImageStudio();
|
||||
|
||||
const [sourceImage, setSourceImage] = useState<string | null>(null);
|
||||
const [originalFormat, setOriginalFormat] = useState<string>('');
|
||||
const [originalSize, setOriginalSize] = useState<number>(0);
|
||||
const [targetFormat, setTargetFormat] = useState<string>('webp');
|
||||
const [preserveTransparency, setPreserveTransparency] = useState(true);
|
||||
const [quality, setQuality] = useState<number | undefined>(85);
|
||||
const [colorSpace, setColorSpace] = useState<string>('');
|
||||
const [stripMetadata, setStripMetadata] = useState(false);
|
||||
const [optimize, setOptimize] = useState(true);
|
||||
const [progressive, setProgressive] = useState(true);
|
||||
const [localError, setLocalError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadSupportedFormats();
|
||||
}, [loadSupportedFormats]);
|
||||
|
||||
useEffect(() => {
|
||||
if (sourceImage && originalFormat) {
|
||||
getFormatRecommendations(originalFormat);
|
||||
}
|
||||
}, [sourceImage, originalFormat, getFormatRecommendations]);
|
||||
|
||||
const handleFileUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
setOriginalSize(file.size / 1024); // KB
|
||||
const dataUrl = await readFileAsDataURL(file);
|
||||
setSourceImage(dataUrl);
|
||||
|
||||
// Detect format from file
|
||||
const fileExtension = file.name.split('.').pop()?.toLowerCase() || '';
|
||||
setOriginalFormat(fileExtension);
|
||||
|
||||
clearFormatConversionResult();
|
||||
setLocalError(null);
|
||||
};
|
||||
|
||||
const handleConvert = async () => {
|
||||
if (!sourceImage) {
|
||||
setLocalError('Please upload an image first.');
|
||||
return;
|
||||
}
|
||||
|
||||
setLocalError(null);
|
||||
try {
|
||||
const request: FormatConversionRequest = {
|
||||
image_base64: sourceImage,
|
||||
target_format: targetFormat,
|
||||
preserve_transparency: preserveTransparency,
|
||||
quality: quality,
|
||||
color_space: colorSpace || undefined,
|
||||
strip_metadata: stripMetadata,
|
||||
optimize,
|
||||
progressive,
|
||||
};
|
||||
await processFormatConversion(request);
|
||||
} catch {
|
||||
// Error handled in hook
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = () => {
|
||||
if (!formatConversionResult?.image_base64) return;
|
||||
const link = document.createElement('a');
|
||||
link.href = formatConversionResult.image_base64;
|
||||
link.download = `converted-image.${formatConversionResult.target_format}`;
|
||||
link.click();
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setSourceImage(null);
|
||||
setOriginalFormat('');
|
||||
setOriginalSize(0);
|
||||
clearFormatConversionResult();
|
||||
setLocalError(null);
|
||||
};
|
||||
|
||||
const currentFormat = supportedFormats.find(f => f.id === targetFormat);
|
||||
const supportsQuality = currentFormat?.supports_lossy;
|
||||
|
||||
return (
|
||||
<Grid container spacing={3}>
|
||||
{/* Left Panel - Settings */}
|
||||
<Grid item xs={12} md={5}>
|
||||
<Stack spacing={3}>
|
||||
{/* Image Upload */}
|
||||
<Card
|
||||
variant="outlined"
|
||||
sx={{
|
||||
borderRadius: 3,
|
||||
borderStyle: sourceImage ? 'solid' : 'dashed',
|
||||
borderColor: sourceImage ? alpha('#667eea', 0.4) : alpha('#cbd5f5', 0.8),
|
||||
background: sourceImage ? alpha('#667eea', 0.08) : alpha('#667eea', 0.02),
|
||||
}}
|
||||
>
|
||||
<CardContent>
|
||||
<Stack spacing={2}>
|
||||
<Stack direction="row" alignItems="center" justifyContent="space-between">
|
||||
<Typography variant="subtitle2" fontWeight={700}>
|
||||
Source Image
|
||||
</Typography>
|
||||
{sourceImage && (
|
||||
<Tooltip title="Remove">
|
||||
<IconButton size="small" onClick={() => {
|
||||
setSourceImage(null);
|
||||
setOriginalFormat('');
|
||||
setOriginalSize(0);
|
||||
clearFormatConversionResult();
|
||||
}}>
|
||||
<RestartAltIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Stack>
|
||||
{sourceImage ? (
|
||||
<Box sx={{ borderRadius: 2, overflow: 'hidden' }}>
|
||||
<img
|
||||
src={sourceImage}
|
||||
alt="Source"
|
||||
style={{ width: '100%', maxHeight: 200, objectFit: 'contain' }}
|
||||
/>
|
||||
<Typography variant="caption" color="text.secondary" display="block" mt={1}>
|
||||
{originalFormat.toUpperCase()} • {originalSize.toFixed(2)} KB
|
||||
</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<Button
|
||||
variant="outlined"
|
||||
component="label"
|
||||
startIcon={<UploadIcon />}
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
borderStyle: 'dashed',
|
||||
py: 3,
|
||||
color: '#667eea',
|
||||
borderColor: alpha('#667eea', 0.6),
|
||||
}}
|
||||
>
|
||||
Upload Image
|
||||
<input hidden type="file" accept="image/*" onChange={handleFileUpload} />
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Recommendations */}
|
||||
{formatRecommendations.length > 0 && (
|
||||
<Card sx={{ borderRadius: 3, background: 'rgba(255,255,255,0.05)' }}>
|
||||
<CardContent>
|
||||
<Stack spacing={1}>
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<InfoIcon sx={{ color: '#667eea', fontSize: 18 }} />
|
||||
<Typography variant="subtitle2">Recommended Formats</Typography>
|
||||
</Stack>
|
||||
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
|
||||
{formatRecommendations.map((rec, idx) => (
|
||||
<Chip
|
||||
key={idx}
|
||||
label={`${rec.format.toUpperCase()}: ${rec.reason}`}
|
||||
size="small"
|
||||
onClick={() => setTargetFormat(rec.format)}
|
||||
sx={{
|
||||
borderColor: targetFormat === rec.format ? '#667eea' : 'rgba(255,255,255,0.2)',
|
||||
bgcolor: targetFormat === rec.format ? alpha('#667eea', 0.2) : 'transparent',
|
||||
color: targetFormat === rec.format ? '#667eea' : 'inherit',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Format Selection */}
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Target Format</InputLabel>
|
||||
<Select
|
||||
value={targetFormat}
|
||||
label="Target Format"
|
||||
onChange={(e) => setTargetFormat(e.target.value)}
|
||||
>
|
||||
{supportedFormats.map((fmt) => (
|
||||
<MenuItem key={fmt.id} value={fmt.id}>
|
||||
<Stack>
|
||||
<Typography variant="body2">{fmt.name}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{fmt.description}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
{/* Quality (for lossy formats) */}
|
||||
{supportsQuality && (
|
||||
<Stack spacing={1}>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center">
|
||||
<Typography variant="subtitle2" fontWeight={600}>
|
||||
Quality
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{quality || 85}%
|
||||
</Typography>
|
||||
</Stack>
|
||||
<TextField
|
||||
type="number"
|
||||
value={quality || ''}
|
||||
onChange={(e) => setQuality(parseInt(e.target.value) || undefined)}
|
||||
inputProps={{ min: 1, max: 100 }}
|
||||
size="small"
|
||||
helperText="Higher quality = larger file size"
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{/* Advanced Options */}
|
||||
<Stack spacing={1}>
|
||||
<Typography variant="subtitle2" fontWeight={600}>
|
||||
Advanced Options
|
||||
</Typography>
|
||||
{currentFormat?.supports_transparency && (
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={preserveTransparency}
|
||||
onChange={(e) => setPreserveTransparency(e.target.checked)}
|
||||
size="small"
|
||||
/>
|
||||
}
|
||||
label={<Typography variant="body2">Preserve transparency</Typography>}
|
||||
/>
|
||||
)}
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={stripMetadata}
|
||||
onChange={(e) => setStripMetadata(e.target.checked)}
|
||||
size="small"
|
||||
/>
|
||||
}
|
||||
label={<Typography variant="body2">Strip metadata (EXIF)</Typography>}
|
||||
/>
|
||||
{targetFormat === 'jpeg' && (
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={progressive}
|
||||
onChange={(e) => setProgressive(e.target.checked)}
|
||||
size="small"
|
||||
/>
|
||||
}
|
||||
label={<Typography variant="body2">Progressive JPEG</Typography>}
|
||||
/>
|
||||
)}
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={optimize}
|
||||
onChange={(e) => setOptimize(e.target.checked)}
|
||||
size="small"
|
||||
/>
|
||||
}
|
||||
label={<Typography variant="body2">Optimize encoding</Typography>}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Grid>
|
||||
|
||||
{/* Right Panel - Results */}
|
||||
<Grid item xs={12} md={7}>
|
||||
<Stack spacing={3}>
|
||||
{(localError || formatConversionError) && (
|
||||
<Alert
|
||||
severity="error"
|
||||
onClose={() => setLocalError(null)}
|
||||
>
|
||||
{localError || formatConversionError}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<OperationButton
|
||||
operation={{
|
||||
provider: 'local',
|
||||
operation_type: 'convert_format',
|
||||
actual_provider_name: 'pillow',
|
||||
}}
|
||||
label="Convert Format"
|
||||
startIcon={<TransformIcon />}
|
||||
onClick={handleConvert}
|
||||
disabled={!sourceImage}
|
||||
loading={isConvertingFormat}
|
||||
checkOnMount={false}
|
||||
sx={{
|
||||
borderRadius: 999,
|
||||
alignSelf: 'flex-start',
|
||||
px: 4,
|
||||
py: 1.5,
|
||||
textTransform: 'none',
|
||||
fontWeight: 700,
|
||||
background: 'linear-gradient(90deg, #7c3aed, #2563eb)',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Result Viewer */}
|
||||
<Card
|
||||
sx={{
|
||||
borderRadius: 3,
|
||||
background: 'rgba(255,255,255,0.05)',
|
||||
border: '1px solid rgba(255,255,255,0.08)',
|
||||
minHeight: 300,
|
||||
}}
|
||||
>
|
||||
<CardContent>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center" mb={2}>
|
||||
<Typography variant="h6" fontWeight={700}>
|
||||
Results
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={1}>
|
||||
<Tooltip title="Reset">
|
||||
<span>
|
||||
<IconButton disabled={!formatConversionResult && !sourceImage} onClick={handleReset}>
|
||||
<RestartAltIcon />
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip title="Download">
|
||||
<span>
|
||||
<IconButton disabled={!formatConversionResult} onClick={handleDownload}>
|
||||
<DownloadIcon />
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
{isConvertingFormat ? (
|
||||
<Stack alignItems="center" spacing={2} py={6}>
|
||||
<CircularProgress />
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Converting format...
|
||||
</Typography>
|
||||
</Stack>
|
||||
) : formatConversionResult ? (
|
||||
<Stack spacing={2}>
|
||||
{/* Before/After Comparison */}
|
||||
<Stack direction={{ xs: 'column', md: 'row' }} spacing={2}>
|
||||
<Box flex={1}>
|
||||
<Typography variant="caption" color="text.secondary">Original ({formatConversionResult.original_format.toUpperCase()})</Typography>
|
||||
<Box sx={{ mt: 1, borderRadius: 2, overflow: 'hidden', border: '1px solid rgba(255,255,255,0.1)' }}>
|
||||
<img src={sourceImage || ''} alt="Original" style={{ width: '100%', maxHeight: 250, objectFit: 'contain' }} />
|
||||
</Box>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{formatConversionResult.original_size_kb.toFixed(1)} KB
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box flex={1}>
|
||||
<Typography variant="caption" color="text.secondary">Converted ({formatConversionResult.target_format.toUpperCase()})</Typography>
|
||||
<Box sx={{ mt: 1, borderRadius: 2, overflow: 'hidden', border: '1px solid rgba(255,255,255,0.1)' }}>
|
||||
<img src={formatConversionResult.image_base64} alt="Converted" style={{ width: '100%', maxHeight: 250, objectFit: 'contain' }} />
|
||||
</Box>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{formatConversionResult.converted_size_kb.toFixed(1)} KB
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
{/* Stats */}
|
||||
<Stack direction="row" spacing={2} flexWrap="wrap" useFlexGap>
|
||||
<Box
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
background: 'rgba(255,255,255,0.08)',
|
||||
px: 2,
|
||||
py: 1,
|
||||
minWidth: 100,
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Format
|
||||
</Typography>
|
||||
<Typography variant="body2" fontWeight={600}>
|
||||
{formatConversionResult.original_format.toUpperCase()} → {formatConversionResult.target_format.toUpperCase()}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
background: 'rgba(255,255,255,0.08)',
|
||||
px: 2,
|
||||
py: 1,
|
||||
minWidth: 100,
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Size Change
|
||||
</Typography>
|
||||
<Typography variant="body2" fontWeight={600}>
|
||||
{formatConversionResult.converted_size_kb > formatConversionResult.original_size_kb ? '+' : ''}
|
||||
{((formatConversionResult.converted_size_kb - formatConversionResult.original_size_kb) / formatConversionResult.original_size_kb * 100).toFixed(1)}%
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
background: 'rgba(255,255,255,0.08)',
|
||||
px: 2,
|
||||
py: 1,
|
||||
minWidth: 100,
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Dimensions
|
||||
</Typography>
|
||||
<Typography variant="body2" fontWeight={600}>
|
||||
{formatConversionResult.width}×{formatConversionResult.height}
|
||||
</Typography>
|
||||
</Box>
|
||||
{formatConversionResult.transparency_preserved && (
|
||||
<Box
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
background: 'rgba(255,255,255,0.08)',
|
||||
px: 2,
|
||||
py: 1,
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Transparency
|
||||
</Typography>
|
||||
<Typography variant="body2" fontWeight={600}>
|
||||
Preserved
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
) : (
|
||||
<Stack alignItems="center" justifyContent="center" py={6}>
|
||||
<Typography variant="body1" color="text.secondary">
|
||||
Upload an image and select target format to see conversion results.
|
||||
</Typography>
|
||||
</Stack>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Stack>
|
||||
</Grid>
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
237
frontend/src/components/ImageStudio/ModelInfoCard.tsx
Normal file
237
frontend/src/components/ImageStudio/ModelInfoCard.tsx
Normal file
@@ -0,0 +1,237 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
Typography,
|
||||
Stack,
|
||||
Chip,
|
||||
Box,
|
||||
Button,
|
||||
Divider,
|
||||
} from '@mui/material';
|
||||
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
||||
import { alpha } from '@mui/material/styles';
|
||||
import { EditingModel } from '../../hooks/useImageStudio';
|
||||
|
||||
interface ModelInfoCardProps {
|
||||
model: EditingModel;
|
||||
isSelected: boolean;
|
||||
isRecommended: boolean;
|
||||
onSelect: () => void;
|
||||
onLearnMore?: () => void;
|
||||
}
|
||||
|
||||
export const ModelInfoCard: React.FC<ModelInfoCardProps> = ({
|
||||
model,
|
||||
isSelected,
|
||||
isRecommended,
|
||||
onSelect,
|
||||
onLearnMore,
|
||||
}) => {
|
||||
const getTierColor = (tier: string) => {
|
||||
switch (tier) {
|
||||
case 'budget':
|
||||
return '#10b981';
|
||||
case 'mid':
|
||||
return '#3b82f6';
|
||||
case 'premium':
|
||||
return '#8b5cf6';
|
||||
default:
|
||||
return '#6b7280';
|
||||
}
|
||||
};
|
||||
|
||||
const getTierLabel = (tier: string) => {
|
||||
switch (tier) {
|
||||
case 'budget':
|
||||
return 'Budget';
|
||||
case 'mid':
|
||||
return 'Mid';
|
||||
case 'premium':
|
||||
return 'Premium';
|
||||
default:
|
||||
return tier;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
sx={{
|
||||
borderRadius: 3,
|
||||
borderWidth: 2,
|
||||
borderStyle: 'solid',
|
||||
borderColor: isSelected
|
||||
? alpha('#667eea', 0.8)
|
||||
: isRecommended
|
||||
? alpha('#f59e0b', 0.4)
|
||||
: 'transparent',
|
||||
background: isSelected
|
||||
? 'linear-gradient(135deg, rgba(102, 126, 234, 0.15), rgba(118, 75, 162, 0.15))'
|
||||
: 'rgba(255,255,255,0.05)',
|
||||
transition: 'all 0.2s ease',
|
||||
'&:hover': {
|
||||
borderColor: alpha('#667eea', 0.6),
|
||||
background: 'rgba(255,255,255,0.08)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<CardContent>
|
||||
<Stack spacing={2}>
|
||||
{/* Header */}
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="flex-start">
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Stack direction="row" spacing={1} alignItems="center" mb={0.5}>
|
||||
<Typography variant="subtitle1" fontWeight={700}>
|
||||
{model.name}
|
||||
</Typography>
|
||||
{isSelected && (
|
||||
<CheckCircleIcon sx={{ fontSize: 18, color: '#667eea' }} />
|
||||
)}
|
||||
{isRecommended && (
|
||||
<Chip
|
||||
size="small"
|
||||
label="Recommended"
|
||||
sx={{
|
||||
height: 20,
|
||||
fontSize: '0.7rem',
|
||||
bgcolor: alpha('#f59e0b', 0.2),
|
||||
color: '#f59e0b',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
<Chip
|
||||
size="small"
|
||||
label={getTierLabel(model.tier)}
|
||||
sx={{
|
||||
bgcolor: alpha(getTierColor(model.tier), 0.2),
|
||||
color: getTierColor(model.tier),
|
||||
fontWeight: 600,
|
||||
fontSize: '0.7rem',
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Typography
|
||||
variant="h6"
|
||||
fontWeight={700}
|
||||
sx={{
|
||||
color: '#667eea',
|
||||
}}
|
||||
>
|
||||
${model.cost.toFixed(3)}
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
{/* Description */}
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{model.description}
|
||||
</Typography>
|
||||
|
||||
<Divider sx={{ borderColor: 'rgba(255,255,255,0.08)' }} />
|
||||
|
||||
{/* Details */}
|
||||
<Stack spacing={1}>
|
||||
<Stack direction="row" spacing={2}>
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Max Resolution
|
||||
</Typography>
|
||||
<Typography variant="body2" fontWeight={600}>
|
||||
{model.max_resolution[0]}×{model.max_resolution[1]}
|
||||
</Typography>
|
||||
</Box>
|
||||
{model.cost_8k && (
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
8K Cost
|
||||
</Typography>
|
||||
<Typography variant="body2" fontWeight={600}>
|
||||
${model.cost_8k.toFixed(3)}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{/* Features */}
|
||||
{model.features.length > 0 && (
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary" display="block" mb={0.5}>
|
||||
Features
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={0.5} flexWrap="wrap" gap={0.5}>
|
||||
{model.features.map((feature, idx) => (
|
||||
<Chip
|
||||
key={idx}
|
||||
size="small"
|
||||
label={feature}
|
||||
sx={{
|
||||
height: 22,
|
||||
fontSize: '0.7rem',
|
||||
bgcolor: alpha('#667eea', 0.1),
|
||||
color: '#667eea',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Use Cases */}
|
||||
{model.use_cases.length > 0 && (
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary" display="block" mb={0.5}>
|
||||
Best For
|
||||
</Typography>
|
||||
<Stack spacing={0.5}>
|
||||
{model.use_cases.slice(0, 3).map((useCase, idx) => (
|
||||
<Typography key={idx} variant="caption" color="text.secondary">
|
||||
• {useCase}
|
||||
</Typography>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{/* Actions */}
|
||||
<Stack direction="row" spacing={1}>
|
||||
<Button
|
||||
variant={isSelected ? 'contained' : 'outlined'}
|
||||
onClick={onSelect}
|
||||
fullWidth
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
textTransform: 'none',
|
||||
fontWeight: 600,
|
||||
...(isSelected
|
||||
? {
|
||||
background: 'linear-gradient(90deg, #667eea, #764ba2)',
|
||||
'&:hover': {
|
||||
background: 'linear-gradient(90deg, #5568d3, #65408b)',
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
}}
|
||||
>
|
||||
{isSelected ? 'Selected' : 'Select Model'}
|
||||
</Button>
|
||||
{onLearnMore && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={onLearnMore}
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
textTransform: 'none',
|
||||
minWidth: 100,
|
||||
}}
|
||||
>
|
||||
Learn More
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
345
frontend/src/components/ImageStudio/ModelSelector.tsx
Normal file
345
frontend/src/components/ImageStudio/ModelSelector.tsx
Normal file
@@ -0,0 +1,345 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import {
|
||||
Box,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Select,
|
||||
MenuItem,
|
||||
Chip,
|
||||
Typography,
|
||||
Stack,
|
||||
TextField,
|
||||
InputAdornment,
|
||||
CircularProgress,
|
||||
Tooltip,
|
||||
IconButton,
|
||||
} from '@mui/material';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import InfoIcon from '@mui/icons-material/Info';
|
||||
import { alpha } from '@mui/material/styles';
|
||||
import { EditingModel, FaceSwapModel } from '../../hooks/useImageStudio';
|
||||
|
||||
type ModelType = EditingModel | FaceSwapModel;
|
||||
|
||||
interface ModelSelectorProps {
|
||||
models: ModelType[];
|
||||
selectedModel?: string;
|
||||
recommendedModel?: string;
|
||||
recommendationReason?: string;
|
||||
onModelSelect: (modelId: string) => void;
|
||||
loading?: boolean;
|
||||
operation?: string;
|
||||
}
|
||||
|
||||
export const ModelSelector: React.FC<ModelSelectorProps> = ({
|
||||
models,
|
||||
selectedModel,
|
||||
recommendedModel,
|
||||
recommendationReason,
|
||||
onModelSelect,
|
||||
loading = false,
|
||||
operation,
|
||||
}) => {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [tierFilter, setTierFilter] = useState<string>('all');
|
||||
|
||||
const filteredModels = useMemo(() => {
|
||||
let filtered = models;
|
||||
|
||||
// Filter by search query
|
||||
if (searchQuery) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
filtered = filtered.filter(
|
||||
(model) =>
|
||||
model.name.toLowerCase().includes(query) ||
|
||||
model.description.toLowerCase().includes(query) ||
|
||||
model.id.toLowerCase().includes(query)
|
||||
);
|
||||
}
|
||||
|
||||
// Filter by tier
|
||||
if (tierFilter !== 'all') {
|
||||
filtered = filtered.filter((model) => model.tier === tierFilter);
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}, [models, searchQuery, tierFilter]);
|
||||
|
||||
const groupedModels = useMemo(() => {
|
||||
const groups: Record<string, ModelType[]> = {
|
||||
budget: [],
|
||||
mid: [],
|
||||
premium: [],
|
||||
};
|
||||
|
||||
filteredModels.forEach((model) => {
|
||||
groups[model.tier].push(model);
|
||||
});
|
||||
|
||||
return groups;
|
||||
}, [filteredModels]);
|
||||
|
||||
const getTierColor = (tier: string) => {
|
||||
switch (tier) {
|
||||
case 'budget':
|
||||
return '#10b981';
|
||||
case 'mid':
|
||||
return '#3b82f6';
|
||||
case 'premium':
|
||||
return '#8b5cf6';
|
||||
default:
|
||||
return '#6b7280';
|
||||
}
|
||||
};
|
||||
|
||||
const getTierLabel = (tier: string) => {
|
||||
switch (tier) {
|
||||
case 'budget':
|
||||
return 'Budget';
|
||||
case 'mid':
|
||||
return 'Mid';
|
||||
case 'premium':
|
||||
return 'Premium';
|
||||
default:
|
||||
return tier;
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 3 }}>
|
||||
<CircularProgress size={24} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack spacing={2}>
|
||||
{/* Search and Filter */}
|
||||
<Stack direction="row" spacing={2}>
|
||||
<TextField
|
||||
size="small"
|
||||
placeholder="Search models..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<SearchIcon sx={{ fontSize: 18, color: 'text.secondary' }} />
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
sx={{ flex: 1 }}
|
||||
/>
|
||||
<FormControl size="small" sx={{ minWidth: 120 }}>
|
||||
<InputLabel>Tier</InputLabel>
|
||||
<Select
|
||||
value={tierFilter}
|
||||
label="Tier"
|
||||
onChange={(e) => setTierFilter(e.target.value)}
|
||||
>
|
||||
<MenuItem value="all">All</MenuItem>
|
||||
<MenuItem value="budget">Budget</MenuItem>
|
||||
<MenuItem value="mid">Mid</MenuItem>
|
||||
<MenuItem value="premium">Premium</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Stack>
|
||||
|
||||
{/* Model Selector */}
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>AI Model</InputLabel>
|
||||
<Select
|
||||
value={selectedModel || ''}
|
||||
label="AI Model"
|
||||
onChange={(e) => onModelSelect(e.target.value)}
|
||||
renderValue={(value) => {
|
||||
const model = models.find((m) => m.id === value);
|
||||
if (!model) return value;
|
||||
return (
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<Typography variant="body2">{model.name}</Typography>
|
||||
<Chip
|
||||
size="small"
|
||||
label={getTierLabel(model.tier)}
|
||||
sx={{
|
||||
height: 20,
|
||||
fontSize: '0.7rem',
|
||||
bgcolor: alpha(getTierColor(model.tier), 0.2),
|
||||
color: getTierColor(model.tier),
|
||||
fontWeight: 600,
|
||||
}}
|
||||
/>
|
||||
{value === recommendedModel && (
|
||||
<Chip
|
||||
size="small"
|
||||
label="Recommended"
|
||||
sx={{
|
||||
height: 20,
|
||||
fontSize: '0.7rem',
|
||||
bgcolor: alpha('#f59e0b', 0.2),
|
||||
color: '#f59e0b',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}}
|
||||
>
|
||||
{Object.entries(groupedModels).map(([tier, tierModels]) => {
|
||||
if (tierModels.length === 0) return null;
|
||||
return [
|
||||
<MenuItem key={`header-${tier}`} disabled>
|
||||
<Typography variant="overline" sx={{ fontWeight: 700 }}>
|
||||
{getTierLabel(tier)} ({tierModels.length})
|
||||
</Typography>
|
||||
</MenuItem>,
|
||||
...tierModels.map((model) => (
|
||||
<MenuItem key={model.id} value={model.id}>
|
||||
<Stack direction="row" spacing={1} alignItems="center" sx={{ width: '100%' }}>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<Typography variant="body2" fontWeight={500}>
|
||||
{model.name}
|
||||
</Typography>
|
||||
{model.id === recommendedModel && (
|
||||
<Chip
|
||||
size="small"
|
||||
label="Recommended"
|
||||
sx={{
|
||||
height: 18,
|
||||
fontSize: '0.65rem',
|
||||
bgcolor: alpha('#f59e0b', 0.2),
|
||||
color: '#f59e0b',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
<Stack direction="row" spacing={1} alignItems="center" mt={0.5}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
${model.cost.toFixed(3)} {('max_resolution' in model) ? 'per edit' : 'per swap'}
|
||||
</Typography>
|
||||
{'max_resolution' in model && model.max_resolution && (
|
||||
<>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
•
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{model.max_resolution[0]}×{model.max_resolution[1]}
|
||||
</Typography>
|
||||
</>
|
||||
)}
|
||||
{'max_faces' in model && model.max_faces && model.max_faces > 1 && (
|
||||
<>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
•
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Up to {model.max_faces} faces
|
||||
</Typography>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
<Chip
|
||||
size="small"
|
||||
label={getTierLabel(model.tier)}
|
||||
sx={{
|
||||
height: 20,
|
||||
fontSize: '0.7rem',
|
||||
bgcolor: alpha(getTierColor(model.tier), 0.2),
|
||||
color: getTierColor(model.tier),
|
||||
fontWeight: 600,
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
</MenuItem>
|
||||
)),
|
||||
];
|
||||
})}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
{/* Recommendation Badge */}
|
||||
{recommendedModel && recommendationReason && selectedModel === recommendedModel && (
|
||||
<Box
|
||||
sx={{
|
||||
p: 1.5,
|
||||
borderRadius: 2,
|
||||
bgcolor: alpha('#f59e0b', 0.1),
|
||||
border: `1px solid ${alpha('#f59e0b', 0.3)}`,
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" spacing={1} alignItems="flex-start">
|
||||
<InfoIcon sx={{ fontSize: 18, color: '#f59e0b', mt: 0.25 }} />
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Typography variant="caption" fontWeight={600} color="#f59e0b">
|
||||
Recommended for you
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" display="block" mt={0.5}>
|
||||
{recommendationReason}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Selected Model Info */}
|
||||
{selectedModel && (
|
||||
<Box
|
||||
sx={{
|
||||
p: 1.5,
|
||||
borderRadius: 2,
|
||||
bgcolor: alpha('#667eea', 0.08),
|
||||
border: `1px solid ${alpha('#667eea', 0.2)}`,
|
||||
}}
|
||||
>
|
||||
{(() => {
|
||||
const model = models.find((m) => m.id === selectedModel);
|
||||
if (!model) return null;
|
||||
return (
|
||||
<Stack spacing={1}>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center">
|
||||
<Typography variant="subtitle2" fontWeight={600}>
|
||||
{model.name}
|
||||
</Typography>
|
||||
<Chip
|
||||
size="small"
|
||||
label={`$${model.cost.toFixed(3)}`}
|
||||
sx={{
|
||||
bgcolor: alpha('#667eea', 0.2),
|
||||
color: '#667eea',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{model.description}
|
||||
</Typography>
|
||||
{model.features.length > 0 && (
|
||||
<Stack direction="row" spacing={0.5} flexWrap="wrap" gap={0.5}>
|
||||
{model.features.slice(0, 3).map((feature, idx) => (
|
||||
<Chip
|
||||
key={idx}
|
||||
size="small"
|
||||
label={feature}
|
||||
sx={{
|
||||
height: 20,
|
||||
fontSize: '0.65rem',
|
||||
bgcolor: alpha('#667eea', 0.1),
|
||||
color: '#667eea',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
})()}
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -6,6 +6,9 @@ import TransformIcon from '@mui/icons-material/Transform';
|
||||
import ShareIcon from '@mui/icons-material/Share';
|
||||
import EditNoteIcon from '@mui/icons-material/EditNote';
|
||||
import LibraryBooksIcon from '@mui/icons-material/LibraryBooks';
|
||||
import SwapHorizIcon from '@mui/icons-material/SwapHoriz';
|
||||
import CompressIcon from '@mui/icons-material/Compress';
|
||||
import BuildIcon from '@mui/icons-material/Build';
|
||||
import { ModuleConfig } from './types';
|
||||
|
||||
export const studioModules: ModuleConfig[] = [
|
||||
@@ -84,6 +87,82 @@ export const studioModules: ModuleConfig[] = [
|
||||
eta: 'Fast = 1s · 4K = 6s',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'face-swap',
|
||||
title: 'Face Swap Studio',
|
||||
subtitle: 'AI face swapping',
|
||||
description:
|
||||
'Swap faces in photos with multiple AI models. Choose from budget to premium options with auto-detection and smart recommendations.',
|
||||
highlights: ['Multi-model support', 'Auto-detection', 'Group photos'],
|
||||
status: 'live',
|
||||
route: '/image-studio/face-swap',
|
||||
icon: <SwapHorizIcon />,
|
||||
help: 'Perfect for creative projects, content creation, and marketing campaigns.',
|
||||
pricing: {
|
||||
estimate: '$0.025 - $0.16 / swap',
|
||||
notes: 'Auto-selects best model based on your images and preferences.',
|
||||
},
|
||||
example: {
|
||||
title: 'Swap face in group photo',
|
||||
steps: [
|
||||
'Upload base image and face image',
|
||||
'System auto-selects best model (or choose manually)',
|
||||
'Preview and download swapped result',
|
||||
],
|
||||
eta: '~3-5s per swap',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'compress',
|
||||
title: 'Compression Studio',
|
||||
subtitle: 'Optimize file sizes',
|
||||
description:
|
||||
'Smart image compression for web, email, and social media. Convert formats, target specific file sizes, and strip metadata.',
|
||||
highlights: ['Format conversion', 'Size targeting', 'Metadata stripping'],
|
||||
status: 'live',
|
||||
route: '/image-studio/compress',
|
||||
icon: <CompressIcon />,
|
||||
help: 'Reduce file sizes without visible quality loss for faster loading.',
|
||||
pricing: {
|
||||
estimate: 'Free (local processing)',
|
||||
notes: 'No credits required - processed locally.',
|
||||
},
|
||||
example: {
|
||||
title: 'Optimize blog images for web',
|
||||
steps: [
|
||||
'Upload image → see original size',
|
||||
'Select WebP format + 80% quality',
|
||||
'Download optimized file (40-60% smaller)',
|
||||
],
|
||||
eta: '<1s per image',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'processing',
|
||||
title: 'Image Processing Studio',
|
||||
subtitle: 'All-in-one toolkit',
|
||||
description:
|
||||
'Unified studio for compression, format conversion, resizing, and watermarking. Complete image processing toolkit in one place.',
|
||||
highlights: ['Compression', 'Format Converter', 'Resizer (coming)', 'Watermark (coming)'],
|
||||
status: 'live',
|
||||
route: '/image-studio/processing',
|
||||
icon: <BuildIcon />,
|
||||
help: 'All image processing tools in one unified interface.',
|
||||
pricing: {
|
||||
estimate: 'Free (local processing)',
|
||||
notes: 'No credits required - processed locally.',
|
||||
},
|
||||
example: {
|
||||
title: 'Complete image workflow',
|
||||
steps: [
|
||||
'Upload image → Compress to reduce size',
|
||||
'Convert format (PNG → WebP)',
|
||||
'Resize for platform (coming soon)',
|
||||
'Add watermark (coming soon)',
|
||||
],
|
||||
eta: '<2s per operation',
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'transform',
|
||||
title: 'Transform Studio',
|
||||
|
||||
@@ -3,6 +3,13 @@ export { TemplateSelector } from './TemplateSelector';
|
||||
export { ImageResultsGallery } from './ImageResultsGallery';
|
||||
export { CostEstimator } from './CostEstimator';
|
||||
export { EditStudio } from './EditStudio';
|
||||
export { ModelSelector } from './ModelSelector';
|
||||
export { ModelInfoCard } from './ModelInfoCard';
|
||||
export { FaceSwapStudio } from './FaceSwapStudio';
|
||||
export { FaceSwapImageUploader } from './FaceSwapImageUploader';
|
||||
export { FaceSwapResultViewer } from './FaceSwapResultViewer';
|
||||
export { CompressionStudio } from './CompressionStudio';
|
||||
export { ImageProcessingStudio } from './ImageProcessingStudio';
|
||||
export { UpscaleStudio } from './UpscaleStudio';
|
||||
export { ControlStudio } from './ControlStudio';
|
||||
export { SocialOptimizer } from './SocialOptimizer';
|
||||
|
||||
@@ -9,6 +9,7 @@ import { addResearchHistory } from '../../utils/researchHistory';
|
||||
import { getResearchConfig, ProviderAvailability } from '../../api/researchConfig';
|
||||
import { ProviderChips } from './steps/components/ProviderChips';
|
||||
import { AdvancedChip } from './steps/components/AdvancedChip';
|
||||
import { SmartResearchInfo } from './steps/components/SmartResearchInfo';
|
||||
|
||||
export const ResearchWizard: React.FC<ResearchWizardProps> = ({
|
||||
onComplete,
|
||||
@@ -336,82 +337,105 @@ export const ResearchWizard: React.FC<ResearchWizardProps> = ({
|
||||
← Back
|
||||
</button>
|
||||
|
||||
{/* Intent-Driven Research Button (Primary) - Only show on Step 1 */}
|
||||
{wizard.state.currentStep === 1 && (
|
||||
<button
|
||||
onClick={async () => {
|
||||
// Analyze intent and execute if successful
|
||||
const analysis = await execution.analyzeIntent(wizard.state);
|
||||
if (analysis?.success) {
|
||||
// If high confidence, auto-execute
|
||||
if (analysis.intent.confidence >= 0.8 && !analysis.intent.needs_clarification) {
|
||||
const result = await execution.executeIntentResearch(wizard.state);
|
||||
{/* Research Button (Unified - enabled only after intent analysis on Step 1) */}
|
||||
<button
|
||||
onClick={() => {
|
||||
if (wizard.state.currentStep === 1) {
|
||||
// On Step 1: If intent is analyzed with high confidence, execute directly
|
||||
if (execution.intentAnalysis?.success &&
|
||||
execution.intentAnalysis.intent.confidence >= 0.7) {
|
||||
const queriesToUse = execution.intentAnalysis.suggested_queries?.slice(0, 5) || [];
|
||||
execution.executeIntentResearch(wizard.state, queriesToUse).then(result => {
|
||||
if (result?.success) {
|
||||
wizard.updateState({ currentStep: 3 }); // Skip to results
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// No intent or low confidence - go to progress step for traditional research
|
||||
wizard.nextStep();
|
||||
}
|
||||
}}
|
||||
disabled={!wizard.canGoNext() || execution.isAnalyzingIntent || execution.isExecuting}
|
||||
style={{
|
||||
padding: '10px 24px',
|
||||
background: wizard.canGoNext() && !execution.isAnalyzingIntent
|
||||
? 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)'
|
||||
: 'rgba(100, 116, 139, 0.2)',
|
||||
color: wizard.canGoNext() ? 'white' : '#94a3b8',
|
||||
border: 'none',
|
||||
borderRadius: '10px',
|
||||
cursor: wizard.canGoNext() && !execution.isAnalyzingIntent ? 'pointer' : 'not-allowed',
|
||||
fontSize: '13px',
|
||||
fontWeight: '600',
|
||||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
marginRight: '10px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
}}
|
||||
>
|
||||
{execution.isAnalyzingIntent ? (
|
||||
<>🧠 Analyzing...</>
|
||||
) : execution.isExecuting ? (
|
||||
<>🔍 Researching...</>
|
||||
) : (
|
||||
<>🧠 Smart Research</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={wizard.nextStep}
|
||||
disabled={!wizard.canGoNext()}
|
||||
} else {
|
||||
wizard.nextStep();
|
||||
}
|
||||
}}
|
||||
disabled={
|
||||
wizard.state.currentStep === 1
|
||||
? !wizard.canGoNext() || !execution.intentAnalysis || execution.isExecuting
|
||||
: !wizard.canGoNext()
|
||||
}
|
||||
style={{
|
||||
padding: '10px 24px',
|
||||
background: wizard.canGoNext()
|
||||
? 'linear-gradient(135deg, #0ea5e9 0%, #38bdf8 100%)'
|
||||
: 'rgba(100, 116, 139, 0.2)',
|
||||
color: wizard.canGoNext() ? 'white' : '#94a3b8',
|
||||
border: wizard.canGoNext() ? 'none' : '1px solid rgba(100, 116, 139, 0.2)',
|
||||
background: (() => {
|
||||
const canProceed = wizard.state.currentStep === 1
|
||||
? wizard.canGoNext() && execution.intentAnalysis && !execution.isExecuting
|
||||
: wizard.canGoNext();
|
||||
return canProceed
|
||||
? 'linear-gradient(135deg, #0ea5e9 0%, #38bdf8 100%)'
|
||||
: 'rgba(100, 116, 139, 0.2)';
|
||||
})(),
|
||||
color: (() => {
|
||||
const canProceed = wizard.state.currentStep === 1
|
||||
? wizard.canGoNext() && execution.intentAnalysis && !execution.isExecuting
|
||||
: wizard.canGoNext();
|
||||
return canProceed ? 'white' : '#94a3b8';
|
||||
})(),
|
||||
border: 'none',
|
||||
borderRadius: '10px',
|
||||
cursor: wizard.canGoNext() ? 'pointer' : 'not-allowed',
|
||||
cursor: (() => {
|
||||
const canProceed = wizard.state.currentStep === 1
|
||||
? wizard.canGoNext() && execution.intentAnalysis && !execution.isExecuting
|
||||
: wizard.canGoNext();
|
||||
return canProceed ? 'pointer' : 'not-allowed';
|
||||
})(),
|
||||
fontSize: '13px',
|
||||
fontWeight: '600',
|
||||
transition: 'all 0.2s ease',
|
||||
boxShadow: wizard.canGoNext() ? '0 2px 8px rgba(14, 165, 233, 0.3)' : 'none',
|
||||
boxShadow: (() => {
|
||||
const canProceed = wizard.state.currentStep === 1
|
||||
? wizard.canGoNext() && execution.intentAnalysis && !execution.isExecuting
|
||||
: wizard.canGoNext();
|
||||
return canProceed ? '0 2px 8px rgba(14, 165, 233, 0.3)' : 'none';
|
||||
})(),
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (wizard.canGoNext()) {
|
||||
const canProceed = wizard.state.currentStep === 1
|
||||
? wizard.canGoNext() && execution.intentAnalysis && !execution.isExecuting
|
||||
: wizard.canGoNext();
|
||||
if (canProceed) {
|
||||
e.currentTarget.style.transform = 'translateX(4px)';
|
||||
e.currentTarget.style.boxShadow = '0 4px 12px rgba(14, 165, 233, 0.4)';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (wizard.canGoNext()) {
|
||||
const canProceed = wizard.state.currentStep === 1
|
||||
? wizard.canGoNext() && execution.intentAnalysis && !execution.isExecuting
|
||||
: wizard.canGoNext();
|
||||
if (canProceed) {
|
||||
e.currentTarget.style.transform = 'translateX(0)';
|
||||
e.currentTarget.style.boxShadow = '0 2px 8px rgba(14, 165, 233, 0.3)';
|
||||
}
|
||||
}}
|
||||
title={
|
||||
wizard.state.currentStep === 1 && !execution.intentAnalysis
|
||||
? 'Click "Intent & Options" in the text area to analyze your research first'
|
||||
: wizard.isLastStep ? 'Complete research' : 'Start research'
|
||||
}
|
||||
>
|
||||
{wizard.isLastStep ? 'Finish' : 'Continue →'}
|
||||
{execution.isExecuting ? (
|
||||
<>
|
||||
<span style={{ animation: 'pulse 1.5s ease-in-out infinite' }}>🔍</span>
|
||||
Researching...
|
||||
</>
|
||||
) : wizard.isLastStep ? (
|
||||
'Finish'
|
||||
) : (
|
||||
<>
|
||||
🚀 Research
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -4,10 +4,11 @@ import { WizardState } from '../types/research.types';
|
||||
import { researchEngineApi, ResearchEngineRequest } from '../../../services/researchEngineApi';
|
||||
import { useResearchPolling } from '../../../hooks/usePolling';
|
||||
import { intentResearchApi } from '../../../api/intentResearchApi';
|
||||
import {
|
||||
import {
|
||||
ResearchIntent,
|
||||
IntentDrivenResearchResponse,
|
||||
AnalyzeIntentResponse
|
||||
AnalyzeIntentResponse,
|
||||
ResearchQuery,
|
||||
} from '../types/intent.types';
|
||||
|
||||
export const useResearchExecution = () => {
|
||||
@@ -133,6 +134,12 @@ export const useResearchExecution = () => {
|
||||
|
||||
try {
|
||||
const userInput = state.keywords.join(' ');
|
||||
if (!userInput.trim()) {
|
||||
setError('Please enter keywords or a research topic');
|
||||
setIsAnalyzingIntent(false);
|
||||
return null;
|
||||
}
|
||||
|
||||
const response = await intentResearchApi.analyzeIntent({
|
||||
user_input: userInput,
|
||||
keywords: state.keywords,
|
||||
@@ -140,20 +147,73 @@ export const useResearchExecution = () => {
|
||||
use_competitor_data: true,
|
||||
});
|
||||
|
||||
if (!response.success) {
|
||||
const errorMsg = response.error_message || 'Failed to analyze intent';
|
||||
setError(errorMsg);
|
||||
setIsAnalyzingIntent(false);
|
||||
return response; // Return response even if failed so UI can show error
|
||||
}
|
||||
|
||||
setIntentAnalysis(response);
|
||||
|
||||
// Auto-confirm if confidence is high and no clarification needed
|
||||
if (response.success && response.intent.confidence >= 0.85 && !response.intent.needs_clarification) {
|
||||
if (response.intent.confidence >= 0.85 && !response.intent.needs_clarification) {
|
||||
setConfirmedIntent(response.intent);
|
||||
}
|
||||
|
||||
setIsAnalyzingIntent(false);
|
||||
return response;
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to analyze intent';
|
||||
} catch (err: any) {
|
||||
console.error('[useResearchExecution] analyzeIntent error:', err);
|
||||
let errorMessage = 'Failed to analyze intent';
|
||||
|
||||
if (err.response) {
|
||||
// HTTP error response
|
||||
if (err.response.status === 404) {
|
||||
errorMessage = 'Smart Research endpoint not found. The feature may not be available yet. Please use the regular research flow.';
|
||||
} else if (err.response.status === 401) {
|
||||
errorMessage = 'Authentication required. Please log in again.';
|
||||
} else if (err.response.status >= 500) {
|
||||
errorMessage = 'Server error. Please try again later.';
|
||||
} else {
|
||||
errorMessage = err.response.data?.detail || err.response.data?.error_message || `Server error: ${err.response.status}`;
|
||||
}
|
||||
} else if (err.request) {
|
||||
// Network error
|
||||
errorMessage = 'Network error. Please check your connection and try again.';
|
||||
} else {
|
||||
errorMessage = err.message || 'Unknown error occurred';
|
||||
}
|
||||
|
||||
setError(errorMessage);
|
||||
setIsAnalyzingIntent(false);
|
||||
return null;
|
||||
|
||||
// Return a failed response so UI can show the error
|
||||
return {
|
||||
success: false,
|
||||
intent: {
|
||||
primary_question: state.keywords.join(' '),
|
||||
secondary_questions: [],
|
||||
purpose: 'learn',
|
||||
content_output: 'general',
|
||||
expected_deliverables: ['key_statistics'],
|
||||
depth: 'detailed',
|
||||
focus_areas: [],
|
||||
perspective: null,
|
||||
time_sensitivity: null,
|
||||
input_type: 'keywords',
|
||||
original_input: state.keywords.join(' '),
|
||||
confidence: 0,
|
||||
needs_clarification: true,
|
||||
clarifying_questions: [],
|
||||
},
|
||||
analysis_summary: '',
|
||||
suggested_queries: [],
|
||||
suggested_keywords: [],
|
||||
suggested_angles: [],
|
||||
quick_options: [],
|
||||
error_message: errorMessage,
|
||||
};
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -183,7 +243,10 @@ export const useResearchExecution = () => {
|
||||
/**
|
||||
* Execute research using intent-driven approach.
|
||||
*/
|
||||
const executeIntentResearch = useCallback(async (state: WizardState): Promise<IntentDrivenResearchResponse | null> => {
|
||||
const executeIntentResearch = useCallback(async (
|
||||
state: WizardState,
|
||||
selectedQueries?: ResearchQuery[]
|
||||
): Promise<IntentDrivenResearchResponse | null> => {
|
||||
// First analyze intent if not already done
|
||||
let intent = confirmedIntent;
|
||||
if (!intent) {
|
||||
@@ -198,13 +261,23 @@ export const useResearchExecution = () => {
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Use provided queries or fall back to intent analysis queries
|
||||
const queriesToUse = selectedQueries || intentAnalysis?.suggested_queries?.slice(0, 5) || [];
|
||||
|
||||
const response = await intentResearchApi.executeIntentResearch({
|
||||
user_input: state.keywords.join(' '),
|
||||
confirmed_intent: intent,
|
||||
selected_queries: intentAnalysis?.suggested_queries?.slice(0, 5),
|
||||
selected_queries: queriesToUse.map(q => ({
|
||||
query: q.query,
|
||||
purpose: q.purpose,
|
||||
provider: q.provider,
|
||||
priority: q.priority,
|
||||
expected_results: q.expected_results,
|
||||
})),
|
||||
max_sources: state.config.max_sources || 10,
|
||||
include_domains: state.config.exa_include_domains || state.config.tavily_include_domains || [],
|
||||
exclude_domains: state.config.exa_exclude_domains || state.config.tavily_exclude_domains || [],
|
||||
trends_config: intentAnalysis?.trends_config, // Include Google Trends configuration
|
||||
skip_inference: true,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { WizardStepProps } from '../types/research.types';
|
||||
import { ResearchProvider, ResearchMode } from '../../../services/blogWriterApi';
|
||||
import { getResearchConfig, ProviderAvailability } from '../../../api/researchConfig';
|
||||
@@ -6,13 +6,6 @@ import {
|
||||
getResearchHistory,
|
||||
ResearchHistoryEntry
|
||||
} from '../../../utils/researchHistory';
|
||||
import {
|
||||
expandKeywords,
|
||||
expandKeywordsWithPersona
|
||||
} from '../../../utils/keywordExpansion';
|
||||
import {
|
||||
generateResearchAngles
|
||||
} from '../../../utils/researchAngles';
|
||||
|
||||
// Utilities
|
||||
import { parseIntelligentInput } from './utils/inputParser';
|
||||
@@ -27,12 +20,15 @@ import { SmartInputIndicator } from './components/SmartInputIndicator';
|
||||
import { KeywordExpansion } from './components/KeywordExpansion';
|
||||
import { CurrentKeywords } from './components/CurrentKeywords';
|
||||
import { ResearchAngles } from './components/ResearchAngles';
|
||||
import { TavilyOptions } from './components/TavilyOptions';
|
||||
import { ExaOptions } from './components/ExaOptions';
|
||||
import { PersonalizationIndicator, PersonalizationBadge } from './components/PersonalizationIndicator';
|
||||
import { ResearchInputHeader } from './components/ResearchInputHeader';
|
||||
import { AdvancedOptionsSection } from './components/AdvancedOptionsSection';
|
||||
import { IntentConfirmationPanel } from './components/IntentConfirmationPanel';
|
||||
import { ResearchExecution } from '../types/research.types';
|
||||
|
||||
// Hooks
|
||||
import { useKeywordExpansion } from './hooks/useKeywordExpansion';
|
||||
import { useResearchAngles } from './hooks/useResearchAngles';
|
||||
|
||||
interface ResearchInputProps extends WizardStepProps {
|
||||
advanced?: boolean;
|
||||
onAdvancedChange?: (advanced: boolean) => void;
|
||||
@@ -45,12 +41,6 @@ export const ResearchInput: React.FC<ResearchInputProps> = ({ state, onUpdate, o
|
||||
const [loadingConfig, setLoadingConfig] = useState(true);
|
||||
const [suggestedMode, setSuggestedMode] = useState<ResearchMode | null>(null);
|
||||
const [researchHistory, setResearchHistory] = useState<ResearchHistoryEntry[]>([]);
|
||||
const [keywordExpansion, setKeywordExpansion] = useState<{
|
||||
original: string[];
|
||||
expanded: string[];
|
||||
suggestions: string[];
|
||||
} | null>(null);
|
||||
const [researchAngles, setResearchAngles] = useState<string[]>([]);
|
||||
const [researchPersona, setResearchPersona] = useState<{
|
||||
research_angles?: string[];
|
||||
recommended_presets?: Array<{
|
||||
@@ -355,71 +345,11 @@ export const ResearchInput: React.FC<ResearchInputProps> = ({ state, onUpdate, o
|
||||
}
|
||||
}, [state.industry, providerAvailability]);
|
||||
|
||||
// Expand keywords when keywords or industry changes
|
||||
// Enhanced to use research persona data if available
|
||||
useEffect(() => {
|
||||
if (state.keywords.length > 0) {
|
||||
let expansion;
|
||||
|
||||
// If we have research persona with keyword expansion patterns, use them
|
||||
if (researchPersona?.keyword_expansion_patterns && Object.keys(researchPersona.keyword_expansion_patterns).length > 0) {
|
||||
expansion = expandKeywordsWithPersona(state.keywords, researchPersona.keyword_expansion_patterns, researchPersona.suggested_keywords);
|
||||
} else if (state.industry !== 'General') {
|
||||
// Fallback to industry-based expansion
|
||||
expansion = expandKeywords(state.keywords, state.industry);
|
||||
} else {
|
||||
expansion = { original: state.keywords, expanded: state.keywords, suggestions: [] };
|
||||
}
|
||||
|
||||
setKeywordExpansion(expansion);
|
||||
} else {
|
||||
setKeywordExpansion(null);
|
||||
}
|
||||
}, [state.keywords, state.industry, researchPersona]);
|
||||
// Use keyword expansion hook
|
||||
const keywordExpansion = useKeywordExpansion(state.keywords, state.industry, researchPersona);
|
||||
|
||||
// Generate research angles when keywords change
|
||||
// Enhanced to prioritize research persona angles if available
|
||||
useEffect(() => {
|
||||
if (state.keywords.length > 0) {
|
||||
const query = state.keywords.join(' ');
|
||||
let angles: string[] = [];
|
||||
|
||||
// Priority 1: Use research persona angles if available and relevant
|
||||
if (researchPersona?.research_angles && researchPersona.research_angles.length > 0) {
|
||||
// Filter persona angles that are relevant to the current query
|
||||
const relevantPersonaAngles = researchPersona.research_angles
|
||||
.filter(angle => {
|
||||
const angleLower = angle.toLowerCase();
|
||||
const queryLower = query.toLowerCase();
|
||||
// Check if angle contains any keyword from query or vice versa
|
||||
return state.keywords.some(kw => angleLower.includes(kw.toLowerCase()) || queryLower.includes(kw.toLowerCase())) ||
|
||||
angleLower.includes(queryLower) || queryLower.includes(angleLower);
|
||||
})
|
||||
.slice(0, 3); // Use top 3 relevant persona angles
|
||||
|
||||
angles.push(...relevantPersonaAngles);
|
||||
}
|
||||
|
||||
// Priority 2: Generate additional angles using pattern matching
|
||||
const generatedAngles = generateResearchAngles(query, state.industry);
|
||||
|
||||
// Merge and deduplicate, prioritizing persona angles
|
||||
const allAngles = [...angles, ...generatedAngles];
|
||||
const uniqueAngles = Array.from(new Set(allAngles.map(a => a.toLowerCase())))
|
||||
.slice(0, 5) // Limit to 5 total
|
||||
.map(a => {
|
||||
// Find original casing from persona angles first, then generated
|
||||
const personaMatch = angles.find(pa => pa.toLowerCase() === a);
|
||||
if (personaMatch) return personaMatch;
|
||||
const generatedMatch = generatedAngles.find(ga => ga.toLowerCase() === a);
|
||||
return generatedMatch || a.charAt(0).toUpperCase() + a.slice(1);
|
||||
});
|
||||
|
||||
setResearchAngles(uniqueAngles);
|
||||
} else {
|
||||
setResearchAngles([]);
|
||||
}
|
||||
}, [state.keywords, state.industry, researchPersona]);
|
||||
// Use research angles hook
|
||||
const researchAngles = useResearchAngles(state.keywords, state.industry, researchPersona);
|
||||
|
||||
// Event handlers
|
||||
const handleKeywordsChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
@@ -511,130 +441,13 @@ export const ResearchInput: React.FC<ResearchInputProps> = ({ state, onUpdate, o
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
marginBottom: '12px',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
}}>
|
||||
<label style={{
|
||||
fontSize: '15px',
|
||||
fontWeight: '600',
|
||||
color: '#0c4a6e',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
flex: '1',
|
||||
}}>
|
||||
<span style={{
|
||||
fontSize: '20px',
|
||||
}}>🔍</span>
|
||||
Research Topic & Keywords
|
||||
<PersonalizationIndicator
|
||||
type="placeholder"
|
||||
hasPersona={!!researchPersona}
|
||||
source={researchPersona ? "from your research persona" : undefined}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{/* Advanced Toggle and Upload Button */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
}}>
|
||||
{/* Advanced Toggle */}
|
||||
<label
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
cursor: 'pointer',
|
||||
padding: '6px 10px',
|
||||
borderRadius: '8px',
|
||||
border: `1px solid ${advanced ? 'rgba(14, 165, 233, 0.3)' : 'rgba(15, 23, 42, 0.1)'}`,
|
||||
background: advanced
|
||||
? 'linear-gradient(135deg, rgba(14, 165, 233, 0.1) 0%, rgba(59, 130, 246, 0.1) 100%)'
|
||||
: '#ffffff',
|
||||
transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
fontSize: '11px',
|
||||
fontWeight: '600',
|
||||
color: advanced ? '#0369a1' : '#475569',
|
||||
boxShadow: advanced ? '0 1px 3px rgba(14, 165, 233, 0.12)' : '0 1px 2px rgba(0, 0, 0, 0.04)',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = advanced ? 'rgba(14, 165, 233, 0.4)' : 'rgba(15, 23, 42, 0.15)';
|
||||
e.currentTarget.style.boxShadow = advanced
|
||||
? '0 2px 4px rgba(14, 165, 233, 0.18)'
|
||||
: '0 1px 3px rgba(0, 0, 0, 0.06)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = advanced ? 'rgba(14, 165, 233, 0.3)' : 'rgba(15, 23, 42, 0.1)';
|
||||
e.currentTarget.style.boxShadow = advanced
|
||||
? '0 1px 3px rgba(14, 165, 233, 0.12)'
|
||||
: '0 1px 2px rgba(0, 0, 0, 0.04)';
|
||||
}}
|
||||
title="Enable advanced research options (Exa and Tavily configurations)"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={advanced}
|
||||
onChange={(e) => {
|
||||
if (onAdvancedChange) {
|
||||
onAdvancedChange(e.target.checked);
|
||||
} else {
|
||||
setLocalAdvanced(e.target.checked);
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
width: '14px',
|
||||
height: '14px',
|
||||
cursor: 'pointer',
|
||||
accentColor: '#0ea5e9',
|
||||
}}
|
||||
/>
|
||||
<span>Advanced</span>
|
||||
</label>
|
||||
|
||||
{/* Upload Button */}
|
||||
<button
|
||||
onClick={handleFileUpload}
|
||||
type="button"
|
||||
style={{
|
||||
padding: '6px 10px',
|
||||
background: 'linear-gradient(135deg, rgba(14, 165, 233, 0.1) 0%, rgba(59, 130, 246, 0.1) 100%)',
|
||||
border: '1px solid rgba(14, 165, 233, 0.25)',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '11px',
|
||||
fontWeight: '600',
|
||||
color: '#0369a1',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '5px',
|
||||
transition: 'all 0.25s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
boxShadow: '0 1px 2px rgba(14, 165, 233, 0.12)',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'linear-gradient(135deg, rgba(14, 165, 233, 0.15) 0%, rgba(59, 130, 246, 0.15) 100%)';
|
||||
e.currentTarget.style.borderColor = 'rgba(14, 165, 233, 0.35)';
|
||||
e.currentTarget.style.transform = 'translateY(-1px)';
|
||||
e.currentTarget.style.boxShadow = '0 2px 4px rgba(14, 165, 233, 0.18)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'linear-gradient(135deg, rgba(14, 165, 233, 0.1) 0%, rgba(59, 130, 246, 0.1) 100%)';
|
||||
e.currentTarget.style.borderColor = 'rgba(14, 165, 233, 0.25)';
|
||||
e.currentTarget.style.transform = 'translateY(0)';
|
||||
e.currentTarget.style.boxShadow = '0 1px 2px rgba(14, 165, 233, 0.12)';
|
||||
}}
|
||||
title="Upload Document"
|
||||
>
|
||||
<span style={{ fontSize: '13px' }}>📎</span>
|
||||
<span>Upload</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Header */}
|
||||
<ResearchInputHeader
|
||||
hasPersona={!!researchPersona}
|
||||
advanced={advanced}
|
||||
onAdvancedChange={setAdvanced}
|
||||
onFileUpload={handleFileUpload}
|
||||
/>
|
||||
|
||||
{/* Research History */}
|
||||
<ResearchHistory
|
||||
@@ -643,11 +456,55 @@ export const ResearchInput: React.FC<ResearchInputProps> = ({ state, onUpdate, o
|
||||
onHistoryCleared={handleHistoryCleared}
|
||||
/>
|
||||
|
||||
{/* Main Input Container */}
|
||||
{/* Main Input Container with Intent & Options button */}
|
||||
<ResearchInputContainer
|
||||
keywords={state.keywords}
|
||||
placeholder={placeholderExamples[currentPlaceholder]}
|
||||
onKeywordsChange={handleKeywordsChange}
|
||||
onIntentAndOptions={async () => {
|
||||
if (execution?.analyzeIntent) {
|
||||
try {
|
||||
const response = await execution.analyzeIntent(state);
|
||||
|
||||
// Apply optimized config from intent analysis (if available)
|
||||
if (response?.success && response.optimized_config) {
|
||||
const optConfig = response.optimized_config;
|
||||
const configUpdates: any = {};
|
||||
|
||||
// Apply recommended provider
|
||||
if (response.recommended_provider) {
|
||||
configUpdates.provider = response.recommended_provider;
|
||||
}
|
||||
|
||||
// Apply Exa settings (note: backend uses exa_type, but frontend state uses exa_search_type)
|
||||
if (optConfig.exa_category) configUpdates.exa_category = optConfig.exa_category;
|
||||
if (optConfig.exa_type) configUpdates.exa_search_type = optConfig.exa_type as 'auto' | 'keyword' | 'neural';
|
||||
if (optConfig.exa_include_domains) configUpdates.exa_include_domains = optConfig.exa_include_domains;
|
||||
if (optConfig.exa_num_results) configUpdates.exa_num_results = optConfig.exa_num_results;
|
||||
|
||||
// Apply Tavily settings
|
||||
if (optConfig.tavily_topic) configUpdates.tavily_topic = optConfig.tavily_topic;
|
||||
if (optConfig.tavily_search_depth) configUpdates.tavily_search_depth = optConfig.tavily_search_depth;
|
||||
if (optConfig.tavily_include_answer !== undefined) configUpdates.tavily_include_answer = optConfig.tavily_include_answer;
|
||||
if (optConfig.tavily_time_range) configUpdates.tavily_time_range = optConfig.tavily_time_range;
|
||||
|
||||
// Update state with optimized config
|
||||
if (Object.keys(configUpdates).length > 0) {
|
||||
console.log('[ResearchInput] Applying optimized config from intent:', configUpdates);
|
||||
onUpdate({ config: { ...state.config, ...configUpdates } });
|
||||
}
|
||||
}
|
||||
|
||||
// After analysis, show advanced options
|
||||
setAdvanced(true);
|
||||
} catch (error) {
|
||||
console.error('[ResearchInput] Intent analysis error:', error);
|
||||
}
|
||||
}
|
||||
}}
|
||||
isAnalyzingIntent={execution?.isAnalyzingIntent}
|
||||
hasIntentAnalysis={!!execution?.intentAnalysis}
|
||||
intentConfidence={execution?.intentAnalysis?.intent?.confidence || 0}
|
||||
/>
|
||||
|
||||
{/* Hidden File Input */}
|
||||
@@ -662,24 +519,71 @@ export const ResearchInput: React.FC<ResearchInputProps> = ({ state, onUpdate, o
|
||||
{/* Smart Input Detection Indicator */}
|
||||
<SmartInputIndicator keywords={state.keywords} />
|
||||
|
||||
{/* Intent Analysis Panel - Show when intent analysis is available */}
|
||||
{execution && (execution.isAnalyzingIntent || execution.intentAnalysis) && (
|
||||
<IntentConfirmationPanel
|
||||
isAnalyzing={execution.isAnalyzingIntent}
|
||||
intentAnalysis={execution.intentAnalysis}
|
||||
confirmedIntent={execution.confirmedIntent}
|
||||
onConfirm={execution.confirmIntent}
|
||||
onUpdateField={execution.updateIntentField}
|
||||
onExecute={async () => {
|
||||
const result = await execution.executeIntentResearch(state);
|
||||
if (result?.success) {
|
||||
// Skip to results step
|
||||
onUpdate({ currentStep: 3 });
|
||||
}
|
||||
}}
|
||||
onDismiss={execution.clearIntent}
|
||||
isExecuting={execution.isExecuting}
|
||||
/>
|
||||
{/* Error Display */}
|
||||
{execution && execution.error && (
|
||||
<div style={{
|
||||
padding: '16px',
|
||||
marginTop: '16px',
|
||||
backgroundColor: '#fee2e2',
|
||||
border: '1px solid #fca5a5',
|
||||
borderRadius: '8px',
|
||||
color: '#991b1b',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '8px' }}>
|
||||
<span style={{ fontSize: '20px' }}>⚠️</span>
|
||||
<strong>Smart Research Error</strong>
|
||||
</div>
|
||||
<p style={{ margin: 0, fontSize: '14px' }}>{execution.error}</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (execution.clearIntent) {
|
||||
execution.clearIntent();
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
marginTop: '12px',
|
||||
padding: '6px 12px',
|
||||
backgroundColor: '#dc2626',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Intent Analysis Panel - Always inline when available (Unified Design) */}
|
||||
{execution && execution.intentAnalysis && (
|
||||
<div style={{
|
||||
marginTop: '20px',
|
||||
animation: 'fadeIn 0.3s ease-out',
|
||||
}}>
|
||||
<IntentConfirmationPanel
|
||||
isAnalyzing={execution.isAnalyzingIntent}
|
||||
intentAnalysis={execution.intentAnalysis}
|
||||
confirmedIntent={execution.confirmedIntent}
|
||||
onConfirm={execution.confirmIntent}
|
||||
onUpdateField={execution.updateIntentField}
|
||||
onExecute={async (selectedQueries) => {
|
||||
const result = await execution.executeIntentResearch(state, selectedQueries);
|
||||
if (result?.success) {
|
||||
// Skip to results step
|
||||
onUpdate({ currentStep: 3 });
|
||||
}
|
||||
}}
|
||||
onDismiss={execution.clearIntent}
|
||||
isExecuting={execution.isExecuting}
|
||||
showAdvancedOptions={advanced}
|
||||
onAdvancedOptionsChange={setAdvanced}
|
||||
providerAvailability={providerAvailability}
|
||||
config={state.config}
|
||||
onConfigUpdate={handleConfigUpdate}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Keyword Expansion Suggestions */}
|
||||
@@ -708,26 +612,13 @@ export const ResearchInput: React.FC<ResearchInputProps> = ({ state, onUpdate, o
|
||||
</div>
|
||||
|
||||
|
||||
{/* Advanced Options - Show when Advanced toggle is ON */}
|
||||
{advanced && (
|
||||
<>
|
||||
{/* Tavily-Specific Options */}
|
||||
{providerAvailability?.tavily_available && (
|
||||
<TavilyOptions
|
||||
config={state.config}
|
||||
onConfigUpdate={handleConfigUpdate}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Exa-Specific Options */}
|
||||
{providerAvailability?.exa_available && (
|
||||
<ExaOptions
|
||||
config={state.config}
|
||||
onConfigUpdate={handleConfigUpdate}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{/* Advanced Options Section */}
|
||||
<AdvancedOptionsSection
|
||||
advanced={advanced}
|
||||
providerAvailability={providerAvailability}
|
||||
config={state.config}
|
||||
onConfigUpdate={handleConfigUpdate}
|
||||
/>
|
||||
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,27 +1,54 @@
|
||||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import { WizardStepProps, ResearchExecution } from '../types/research.types';
|
||||
import { ResearchResults } from '../../BlogWriter/ResearchResults';
|
||||
import { BlogResearchResponse } from '../../../services/blogWriterApi';
|
||||
import { IntentResultsDisplay } from './components/IntentResultsDisplay';
|
||||
import { IntentDrivenResearchResponse } from '../types/intent.types';
|
||||
|
||||
type ResultTab = 'summary' | 'deliverables' | 'sources' | 'analysis';
|
||||
|
||||
interface StepResultsProps extends WizardStepProps {
|
||||
execution?: ResearchExecution;
|
||||
}
|
||||
|
||||
export const StepResults: React.FC<StepResultsProps> = ({ state, onUpdate, onBack, execution }) => {
|
||||
const [activeTab, setActiveTab] = useState<ResultTab>('summary');
|
||||
|
||||
// Check if we have intent-driven results
|
||||
const intentResult: IntentDrivenResearchResponse | null =
|
||||
execution?.intentResult ||
|
||||
(state.results as any)?.intent_result ||
|
||||
null;
|
||||
if (!state.results) {
|
||||
|
||||
// Determine if we have both types of results
|
||||
const hasIntentResults = !!intentResult;
|
||||
const hasTraditionalResults = !!state.results && !intentResult;
|
||||
const hasAnyResults = hasIntentResults || hasTraditionalResults;
|
||||
|
||||
if (!hasAnyResults) {
|
||||
return (
|
||||
<div style={{ padding: '24px', textAlign: 'center' }}>
|
||||
<p style={{ color: '#666' }}>No results available</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Get counts for tab badges
|
||||
const getTabBadge = (tab: ResultTab): number | undefined => {
|
||||
if (!intentResult) return undefined;
|
||||
switch (tab) {
|
||||
case 'deliverables':
|
||||
return (intentResult.statistics?.length || 0) +
|
||||
(intentResult.expert_quotes?.length || 0) +
|
||||
(intentResult.case_studies?.length || 0) +
|
||||
(intentResult.trends?.length || 0) +
|
||||
(intentResult.best_practices?.length || 0);
|
||||
case 'sources':
|
||||
return intentResult.sources?.length || state.results?.sources?.length || 0;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const handleExport = () => {
|
||||
const dataStr = JSON.stringify(state.results, null, 2);
|
||||
@@ -105,19 +132,253 @@ export const StepResults: React.FC<StepResultsProps> = ({ state, onUpdate, onBac
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results Display */}
|
||||
{/* Unified Tabbed Results Display */}
|
||||
<div style={{
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '8px',
|
||||
borderRadius: '12px',
|
||||
border: '1px solid #e0e0e0',
|
||||
overflow: 'hidden',
|
||||
padding: intentResult ? '16px' : '0',
|
||||
}}>
|
||||
{intentResult ? (
|
||||
<IntentResultsDisplay result={intentResult} />
|
||||
) : (
|
||||
<ResearchResults research={state.results} />
|
||||
{/* Tab Navigation */}
|
||||
{hasIntentResults && (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
borderBottom: '2px solid #e5e7eb',
|
||||
backgroundColor: '#f8fafc',
|
||||
}}>
|
||||
{[
|
||||
{ id: 'summary', label: '📋 Summary', icon: '📋' },
|
||||
{ id: 'deliverables', label: '📊 Deliverables', icon: '📊' },
|
||||
{ id: 'sources', label: '🔗 Sources', icon: '🔗' },
|
||||
{ id: 'analysis', label: '📈 Analysis', icon: '📈' },
|
||||
].map((tab) => {
|
||||
const badge = getTabBadge(tab.id as ResultTab);
|
||||
const isActive = activeTab === tab.id;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id as ResultTab)}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '14px 16px',
|
||||
border: 'none',
|
||||
background: isActive
|
||||
? 'white'
|
||||
: 'transparent',
|
||||
borderBottom: isActive
|
||||
? '3px solid #0ea5e9'
|
||||
: '3px solid transparent',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
fontWeight: isActive ? '600' : '500',
|
||||
color: isActive ? '#0c4a6e' : '#64748b',
|
||||
transition: 'all 0.2s ease',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '8px',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isActive) {
|
||||
e.currentTarget.style.backgroundColor = '#f1f5f9';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isActive) {
|
||||
e.currentTarget.style.backgroundColor = 'transparent';
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span>{tab.label}</span>
|
||||
{badge !== undefined && badge > 0 && (
|
||||
<span style={{
|
||||
backgroundColor: isActive ? '#0ea5e9' : '#e2e8f0',
|
||||
color: isActive ? 'white' : '#64748b',
|
||||
padding: '2px 8px',
|
||||
borderRadius: '10px',
|
||||
fontSize: '11px',
|
||||
fontWeight: '600',
|
||||
}}>
|
||||
{badge}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tab Content */}
|
||||
<div style={{ padding: '20px' }}>
|
||||
{hasIntentResults ? (
|
||||
<>
|
||||
{/* Summary Tab */}
|
||||
{activeTab === 'summary' && (
|
||||
<div style={{ animation: 'fadeIn 0.3s ease' }}>
|
||||
{intentResult.executive_summary && (
|
||||
<div style={{
|
||||
backgroundColor: '#f0f9ff',
|
||||
border: '1px solid #bae6fd',
|
||||
borderRadius: '8px',
|
||||
padding: '16px',
|
||||
marginBottom: '20px',
|
||||
}}>
|
||||
<h4 style={{ margin: '0 0 8px 0', color: '#0c4a6e' }}>Executive Summary</h4>
|
||||
<p style={{ margin: 0, color: '#334155', lineHeight: 1.6 }}>
|
||||
{intentResult.executive_summary}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{intentResult.primary_answer && (
|
||||
<div style={{
|
||||
backgroundColor: '#f0fdf4',
|
||||
border: '1px solid #86efac',
|
||||
borderRadius: '8px',
|
||||
padding: '16px',
|
||||
marginBottom: '20px',
|
||||
}}>
|
||||
<h4 style={{ margin: '0 0 8px 0', color: '#166534' }}>Direct Answer</h4>
|
||||
<p style={{ margin: 0, color: '#334155', lineHeight: 1.6 }}>
|
||||
{intentResult.primary_answer}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{intentResult.key_takeaways && intentResult.key_takeaways.length > 0 && (
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<h4 style={{ margin: '0 0 12px 0', color: '#333' }}>Key Takeaways</h4>
|
||||
<ul style={{ margin: 0, paddingLeft: '20px' }}>
|
||||
{intentResult.key_takeaways.map((takeaway, idx) => (
|
||||
<li key={idx} style={{ color: '#334155', marginBottom: '8px', lineHeight: 1.5 }}>
|
||||
{takeaway}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Deliverables Tab - Uses IntentResultsDisplay */}
|
||||
{activeTab === 'deliverables' && (
|
||||
<div style={{ animation: 'fadeIn 0.3s ease' }}>
|
||||
<IntentResultsDisplay result={intentResult} hideHeader />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sources Tab - Shows traditional sources view */}
|
||||
{activeTab === 'sources' && (
|
||||
<div style={{ animation: 'fadeIn 0.3s ease' }}>
|
||||
{intentResult.sources && intentResult.sources.length > 0 ? (
|
||||
<div>
|
||||
<h4 style={{ margin: '0 0 16px 0', color: '#333' }}>
|
||||
{intentResult.sources.length} Sources Found
|
||||
</h4>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||
{intentResult.sources.map((source: any, idx: number) => (
|
||||
<div
|
||||
key={idx}
|
||||
style={{
|
||||
padding: '16px',
|
||||
backgroundColor: '#f8fafc',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #e2e8f0',
|
||||
}}
|
||||
>
|
||||
<a
|
||||
href={source.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{
|
||||
fontSize: '15px',
|
||||
fontWeight: '600',
|
||||
color: '#0ea5e9',
|
||||
textDecoration: 'none',
|
||||
}}
|
||||
>
|
||||
{source.title}
|
||||
</a>
|
||||
<div style={{
|
||||
fontSize: '12px',
|
||||
color: '#64748b',
|
||||
marginTop: '4px',
|
||||
}}>
|
||||
{source.url}
|
||||
</div>
|
||||
{source.excerpt && (
|
||||
<p style={{
|
||||
margin: '8px 0 0 0',
|
||||
fontSize: '13px',
|
||||
color: '#475569',
|
||||
lineHeight: 1.5,
|
||||
}}>
|
||||
{source.excerpt}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : state.results?.sources ? (
|
||||
<ResearchResults research={state.results} showSourcesOnly />
|
||||
) : (
|
||||
<p style={{ color: '#666' }}>No sources available</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Analysis Tab - Shows keyword analysis, angles, etc. */}
|
||||
{activeTab === 'analysis' && (
|
||||
<div style={{ animation: 'fadeIn 0.3s ease' }}>
|
||||
{state.results ? (
|
||||
<ResearchResults research={state.results} showAnalysisOnly />
|
||||
) : (
|
||||
<div>
|
||||
{intentResult.suggested_outline && intentResult.suggested_outline.length > 0 && (
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<h4 style={{ margin: '0 0 12px 0', color: '#333' }}>Suggested Outline</h4>
|
||||
<ol style={{ margin: 0, paddingLeft: '24px' }}>
|
||||
{intentResult.suggested_outline.map((item, idx) => (
|
||||
<li key={idx} style={{ color: '#334155', marginBottom: '8px' }}>
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{intentResult.gaps_identified && intentResult.gaps_identified.length > 0 && (
|
||||
<div style={{
|
||||
backgroundColor: '#fff7ed',
|
||||
border: '1px solid #fdba74',
|
||||
borderRadius: '8px',
|
||||
padding: '16px',
|
||||
}}>
|
||||
<h4 style={{ margin: '0 0 12px 0', color: '#9a3412' }}>Gaps Identified</h4>
|
||||
<ul style={{ margin: 0, paddingLeft: '20px' }}>
|
||||
{intentResult.gaps_identified.map((gap, idx) => (
|
||||
<li key={idx} style={{ color: '#9a3412', marginBottom: '4px' }}>
|
||||
{gap}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : state.results ? (
|
||||
// Traditional results display (no tabs)
|
||||
<ResearchResults research={state.results} />
|
||||
) : (
|
||||
<p style={{ color: '#666', textAlign: 'center', padding: '40px' }}>
|
||||
No results available
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Section */}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* AdvancedOptionsSection Component
|
||||
*
|
||||
* Displays advanced provider options (Exa/Tavily) when advanced mode is enabled
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { ProviderAvailability } from '../../../../api/researchConfig';
|
||||
import { ResearchConfig } from '../../../../services/blogWriterApi';
|
||||
import { ExaOptions } from './ExaOptions';
|
||||
import { TavilyOptions } from './TavilyOptions';
|
||||
|
||||
interface AdvancedOptionsSectionProps {
|
||||
advanced: boolean;
|
||||
providerAvailability: ProviderAvailability | null;
|
||||
config: ResearchConfig;
|
||||
onConfigUpdate: (updates: Partial<ResearchConfig>) => void;
|
||||
}
|
||||
|
||||
export const AdvancedOptionsSection: React.FC<AdvancedOptionsSectionProps> = ({
|
||||
advanced,
|
||||
providerAvailability,
|
||||
config,
|
||||
onConfigUpdate,
|
||||
}) => {
|
||||
if (!advanced) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Tavily-Specific Options */}
|
||||
{providerAvailability?.tavily_available && (
|
||||
<TavilyOptions
|
||||
config={config}
|
||||
onConfigUpdate={onConfigUpdate}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Exa-Specific Options */}
|
||||
{providerAvailability?.exa_available && (
|
||||
<ExaOptions
|
||||
config={config}
|
||||
onConfigUpdate={onConfigUpdate}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,381 +0,0 @@
|
||||
/**
|
||||
* IntentConfirmationPanel Component
|
||||
*
|
||||
* Shows the AI-inferred research intent and allows user to confirm or modify.
|
||||
* Embedded in the existing ResearchInput component.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Chip,
|
||||
Paper,
|
||||
Button,
|
||||
Alert,
|
||||
CircularProgress,
|
||||
Collapse,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
Grid,
|
||||
Card,
|
||||
CardContent,
|
||||
FormControl,
|
||||
Select,
|
||||
MenuItem,
|
||||
InputLabel,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Psychology as BrainIcon,
|
||||
CheckCircle as CheckIcon,
|
||||
Close as CloseIcon,
|
||||
ExpandMore as ExpandMoreIcon,
|
||||
ExpandLess as ExpandLessIcon,
|
||||
PlayArrow as PlayIcon,
|
||||
Edit as EditIcon,
|
||||
} from '@mui/icons-material';
|
||||
import {
|
||||
ResearchIntent,
|
||||
AnalyzeIntentResponse,
|
||||
ExpectedDeliverable,
|
||||
ResearchPurpose,
|
||||
ContentOutput,
|
||||
ResearchDepthLevel,
|
||||
DELIVERABLE_DISPLAY,
|
||||
PURPOSE_DISPLAY,
|
||||
DEPTH_DISPLAY,
|
||||
CONTENT_OUTPUT_DISPLAY,
|
||||
} from '../../types/intent.types';
|
||||
|
||||
interface IntentConfirmationPanelProps {
|
||||
isAnalyzing: boolean;
|
||||
intentAnalysis: AnalyzeIntentResponse | null;
|
||||
confirmedIntent: ResearchIntent | null;
|
||||
onConfirm: (intent: ResearchIntent) => void;
|
||||
onUpdateField: <K extends keyof ResearchIntent>(field: K, value: ResearchIntent[K]) => void;
|
||||
onExecute: () => void;
|
||||
onDismiss: () => void;
|
||||
isExecuting: boolean;
|
||||
}
|
||||
|
||||
export const IntentConfirmationPanel: React.FC<IntentConfirmationPanelProps> = ({
|
||||
isAnalyzing,
|
||||
intentAnalysis,
|
||||
confirmedIntent,
|
||||
onConfirm,
|
||||
onUpdateField,
|
||||
onExecute,
|
||||
onDismiss,
|
||||
isExecuting,
|
||||
}) => {
|
||||
const [showDetails, setShowDetails] = React.useState(false);
|
||||
const [isEditing, setIsEditing] = React.useState(false);
|
||||
|
||||
// Loading state
|
||||
if (isAnalyzing) {
|
||||
return (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 3,
|
||||
mt: 2,
|
||||
borderRadius: 2,
|
||||
border: '1px solid',
|
||||
borderColor: 'primary.light',
|
||||
background: 'linear-gradient(135deg, rgba(102, 126, 234, 0.05) 0%, rgba(118, 75, 162, 0.05) 100%)',
|
||||
}}
|
||||
>
|
||||
<Box display="flex" alignItems="center" gap={2}>
|
||||
<CircularProgress size={24} />
|
||||
<Box>
|
||||
<Typography variant="subtitle1" fontWeight={600}>
|
||||
🧠 Analyzing your research intent...
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
AI is understanding what you want to accomplish
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
// No analysis yet
|
||||
if (!intentAnalysis || !intentAnalysis.success) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const intent = intentAnalysis.intent;
|
||||
const confidence = intent.confidence;
|
||||
const isHighConfidence = confidence >= 0.8;
|
||||
|
||||
return (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
mt: 2,
|
||||
borderRadius: 2,
|
||||
border: '1px solid',
|
||||
borderColor: isHighConfidence ? 'success.light' : 'warning.light',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
background: isHighConfidence
|
||||
? 'linear-gradient(135deg, rgba(76, 175, 80, 0.1) 0%, rgba(67, 160, 71, 0.1) 100%)'
|
||||
: 'linear-gradient(135deg, rgba(255, 152, 0, 0.1) 0%, rgba(251, 140, 0, 0.1) 100%)',
|
||||
}}
|
||||
>
|
||||
<Box display="flex" alignItems="center" gap={1.5}>
|
||||
<BrainIcon color={isHighConfidence ? 'success' : 'warning'} />
|
||||
<Box>
|
||||
<Typography variant="subtitle1" fontWeight={600}>
|
||||
AI Understood Your Research
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{intentAnalysis.analysis_summary}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box display="flex" alignItems="center" gap={1}>
|
||||
<Chip
|
||||
size="small"
|
||||
label={`${Math.round(confidence * 100)}% confident`}
|
||||
color={isHighConfidence ? 'success' : 'warning'}
|
||||
variant="outlined"
|
||||
/>
|
||||
<IconButton size="small" onClick={onDismiss}>
|
||||
<CloseIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Main Content */}
|
||||
<Box sx={{ p: 2 }}>
|
||||
{/* Primary Question */}
|
||||
<Alert
|
||||
severity="info"
|
||||
sx={{ mb: 2 }}
|
||||
icon={<CheckIcon />}
|
||||
>
|
||||
<Typography variant="body2" fontWeight={500}>
|
||||
<strong>Main Question:</strong> {intent.primary_question}
|
||||
</Typography>
|
||||
</Alert>
|
||||
|
||||
{/* Quick Summary Grid */}
|
||||
<Grid container spacing={2} mb={2}>
|
||||
{/* Purpose */}
|
||||
<Grid item xs={6} sm={3}>
|
||||
<Card variant="outlined" sx={{ height: '100%' }}>
|
||||
<CardContent sx={{ py: 1, px: 1.5, '&:last-child': { pb: 1 } }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Purpose
|
||||
</Typography>
|
||||
{isEditing ? (
|
||||
<FormControl size="small" fullWidth sx={{ mt: 0.5 }}>
|
||||
<Select
|
||||
value={intent.purpose}
|
||||
onChange={(e) => onUpdateField('purpose', e.target.value as ResearchPurpose)}
|
||||
>
|
||||
{Object.entries(PURPOSE_DISPLAY).map(([key, label]) => (
|
||||
<MenuItem key={key} value={key}>{label}</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
) : (
|
||||
<Typography variant="body2" fontWeight={500}>
|
||||
{PURPOSE_DISPLAY[intent.purpose as ResearchPurpose] || intent.purpose}
|
||||
</Typography>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
{/* Content Type */}
|
||||
<Grid item xs={6} sm={3}>
|
||||
<Card variant="outlined" sx={{ height: '100%' }}>
|
||||
<CardContent sx={{ py: 1, px: 1.5, '&:last-child': { pb: 1 } }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Creating
|
||||
</Typography>
|
||||
{isEditing ? (
|
||||
<FormControl size="small" fullWidth sx={{ mt: 0.5 }}>
|
||||
<Select
|
||||
value={intent.content_output}
|
||||
onChange={(e) => onUpdateField('content_output', e.target.value as ContentOutput)}
|
||||
>
|
||||
{Object.entries(CONTENT_OUTPUT_DISPLAY).map(([key, label]) => (
|
||||
<MenuItem key={key} value={key}>{label}</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
) : (
|
||||
<Typography variant="body2" fontWeight={500}>
|
||||
{CONTENT_OUTPUT_DISPLAY[intent.content_output as ContentOutput] || intent.content_output}
|
||||
</Typography>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
{/* Depth */}
|
||||
<Grid item xs={6} sm={3}>
|
||||
<Card variant="outlined" sx={{ height: '100%' }}>
|
||||
<CardContent sx={{ py: 1, px: 1.5, '&:last-child': { pb: 1 } }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Depth
|
||||
</Typography>
|
||||
{isEditing ? (
|
||||
<FormControl size="small" fullWidth sx={{ mt: 0.5 }}>
|
||||
<Select
|
||||
value={intent.depth}
|
||||
onChange={(e) => onUpdateField('depth', e.target.value as ResearchDepthLevel)}
|
||||
>
|
||||
{Object.entries(DEPTH_DISPLAY).map(([key, label]) => (
|
||||
<MenuItem key={key} value={key}>{label}</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
) : (
|
||||
<Typography variant="body2" fontWeight={500}>
|
||||
{DEPTH_DISPLAY[intent.depth as ResearchDepthLevel] || intent.depth}
|
||||
</Typography>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
{/* Queries */}
|
||||
<Grid item xs={6} sm={3}>
|
||||
<Card variant="outlined" sx={{ height: '100%' }}>
|
||||
<CardContent sx={{ py: 1, px: 1.5, '&:last-child': { pb: 1 } }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Queries
|
||||
</Typography>
|
||||
<Typography variant="body2" fontWeight={500}>
|
||||
{intentAnalysis.suggested_queries?.length || 0} targeted
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
{/* What we'll find */}
|
||||
<Box mb={2}>
|
||||
<Typography variant="caption" color="text.secondary" gutterBottom display="block">
|
||||
What I'll find for you:
|
||||
</Typography>
|
||||
<Box display="flex" flexWrap="wrap" gap={0.5}>
|
||||
{intent.expected_deliverables.slice(0, 5).map((d) => (
|
||||
<Chip
|
||||
key={d}
|
||||
label={DELIVERABLE_DISPLAY[d as ExpectedDeliverable] || d}
|
||||
size="small"
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
/>
|
||||
))}
|
||||
{intent.expected_deliverables.length > 5 && (
|
||||
<Chip
|
||||
label={`+${intent.expected_deliverables.length - 5} more`}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Expandable Details */}
|
||||
<Collapse in={showDetails}>
|
||||
<Box sx={{ pt: 2, borderTop: '1px solid', borderColor: 'divider' }}>
|
||||
{/* Secondary Questions */}
|
||||
{intent.secondary_questions.length > 0 && (
|
||||
<Box mb={2}>
|
||||
<Typography variant="caption" color="text.secondary" gutterBottom display="block">
|
||||
Also answering:
|
||||
</Typography>
|
||||
{intent.secondary_questions.slice(0, 3).map((q, idx) => (
|
||||
<Typography key={idx} variant="body2" sx={{ ml: 1 }}>
|
||||
• {q}
|
||||
</Typography>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Focus Areas */}
|
||||
{intent.focus_areas.length > 0 && (
|
||||
<Box mb={2}>
|
||||
<Typography variant="caption" color="text.secondary" gutterBottom display="block">
|
||||
Focus areas:
|
||||
</Typography>
|
||||
<Box display="flex" flexWrap="wrap" gap={0.5}>
|
||||
{intent.focus_areas.map((area, idx) => (
|
||||
<Chip key={idx} label={area} size="small" variant="outlined" />
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Research Angles */}
|
||||
{intentAnalysis.suggested_angles?.length > 0 && (
|
||||
<Box mb={2}>
|
||||
<Typography variant="caption" color="text.secondary" gutterBottom display="block">
|
||||
Research angles:
|
||||
</Typography>
|
||||
{intentAnalysis.suggested_angles.slice(0, 3).map((angle, idx) => (
|
||||
<Typography key={idx} variant="body2" sx={{ ml: 1 }}>
|
||||
• {angle}
|
||||
</Typography>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Collapse>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<Box display="flex" justifyContent="space-between" alignItems="center" mt={2}>
|
||||
<Box>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => setShowDetails(!showDetails)}
|
||||
endIcon={showDetails ? <ExpandLessIcon /> : <ExpandMoreIcon />}
|
||||
>
|
||||
{showDetails ? 'Less details' : 'More details'}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
startIcon={<EditIcon />}
|
||||
onClick={() => setIsEditing(!isEditing)}
|
||||
sx={{ ml: 1 }}
|
||||
>
|
||||
{isEditing ? 'Done editing' : 'Edit'}
|
||||
</Button>
|
||||
</Box>
|
||||
<Box display="flex" gap={1}>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
startIcon={isExecuting ? <CircularProgress size={16} color="inherit" /> : <PlayIcon />}
|
||||
onClick={() => {
|
||||
onConfirm(intent);
|
||||
onExecute();
|
||||
}}
|
||||
disabled={isExecuting}
|
||||
>
|
||||
{isExecuting ? 'Researching...' : 'Start Research'}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
export default IntentConfirmationPanel;
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* ActionButtons Component
|
||||
*
|
||||
* Action buttons section (More details toggle and Start Research).
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
CircularProgress,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
ExpandMore as ExpandMoreIcon,
|
||||
ExpandLess as ExpandLessIcon,
|
||||
PlayArrow as PlayIcon,
|
||||
} from '@mui/icons-material';
|
||||
|
||||
interface ActionButtonsProps {
|
||||
showDetails: boolean;
|
||||
onToggleDetails: () => void;
|
||||
onExecute: () => void;
|
||||
isExecuting: boolean;
|
||||
canExecute: boolean;
|
||||
}
|
||||
|
||||
export const ActionButtons: React.FC<ActionButtonsProps> = ({
|
||||
showDetails,
|
||||
onToggleDetails,
|
||||
onExecute,
|
||||
isExecuting,
|
||||
canExecute,
|
||||
}) => {
|
||||
return (
|
||||
<Box
|
||||
display="flex"
|
||||
justifyContent="space-between"
|
||||
alignItems="center"
|
||||
mt={2}
|
||||
pt={2}
|
||||
borderTop="1px solid #e5e7eb"
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={onToggleDetails}
|
||||
endIcon={showDetails ? <ExpandLessIcon /> : <ExpandMoreIcon />}
|
||||
sx={{
|
||||
color: '#666',
|
||||
'&:hover': { backgroundColor: '#f3f4f6' },
|
||||
}}
|
||||
>
|
||||
{showDetails ? 'Less details' : 'More details'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
startIcon={isExecuting ? <CircularProgress size={16} color="inherit" /> : <PlayIcon />}
|
||||
onClick={onExecute}
|
||||
disabled={isExecuting || !canExecute}
|
||||
sx={{
|
||||
backgroundColor: '#0ea5e9',
|
||||
'&:hover': { backgroundColor: '#0284c7' },
|
||||
'&:disabled': { backgroundColor: '#d1d5db', color: '#9ca3af' },
|
||||
}}
|
||||
>
|
||||
{isExecuting ? 'Researching...' : 'Start Research'}
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,268 @@
|
||||
/**
|
||||
* AdvancedProviderOptionsSection Component
|
||||
*
|
||||
* Advanced provider options section with AI-optimized settings.
|
||||
* This is specific to IntentConfirmationPanel and includes AI justifications.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Chip,
|
||||
Tooltip,
|
||||
Button,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Info as InfoIcon,
|
||||
} from '@mui/icons-material';
|
||||
import { AnalyzeIntentResponse } from '../../../types/intent.types';
|
||||
import { ProviderAvailability } from '../../../../../api/researchConfig';
|
||||
import { ExaOptions } from '../ExaOptions';
|
||||
import { TavilyOptions } from '../TavilyOptions';
|
||||
import { ProviderChips } from '../ProviderChips';
|
||||
import { ResearchProvider } from '../../../../../services/blogWriterApi';
|
||||
|
||||
interface AdvancedProviderOptionsSectionProps {
|
||||
intentAnalysis: AnalyzeIntentResponse;
|
||||
providerAvailability: ProviderAvailability;
|
||||
config: any;
|
||||
onConfigUpdate: (updates: any) => void;
|
||||
showAdvancedOptions: boolean;
|
||||
onAdvancedOptionsChange: (show: boolean) => void;
|
||||
}
|
||||
|
||||
export const AdvancedProviderOptionsSection: React.FC<AdvancedProviderOptionsSectionProps> = ({
|
||||
intentAnalysis,
|
||||
providerAvailability,
|
||||
config,
|
||||
onConfigUpdate,
|
||||
showAdvancedOptions,
|
||||
onAdvancedOptionsChange,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
{/* Toggle Advanced Options Button */}
|
||||
<Box sx={{ mt: 2, display: 'flex', justifyContent: 'center' }}>
|
||||
<Button
|
||||
size="small"
|
||||
variant="text"
|
||||
onClick={() => onAdvancedOptionsChange(!showAdvancedOptions)}
|
||||
sx={{
|
||||
color: '#64748b',
|
||||
fontSize: '12px',
|
||||
'&:hover': {
|
||||
backgroundColor: '#f8fafc',
|
||||
color: '#0ea5e9',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{showAdvancedOptions ? '▲ Hide Advanced Options' : '▼ Show Advanced Options'}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{/* Advanced Options Section */}
|
||||
{showAdvancedOptions && (
|
||||
<Box sx={{
|
||||
mt: 2,
|
||||
pt: 2,
|
||||
borderTop: '1px dashed rgba(14, 165, 233, 0.3)',
|
||||
}}>
|
||||
<Box display="flex" alignItems="center" justifyContent="space-between" mb={2}>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
sx={{
|
||||
color: '#0c4a6e',
|
||||
fontWeight: 600,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
⚙️ AI-Optimized Settings
|
||||
</Typography>
|
||||
<Tooltip title="These settings were AI-configured based on your research intent. Hover over each for explanation." arrow>
|
||||
<InfoIcon sx={{ fontSize: 16, color: '#94a3b8', cursor: 'help' }} />
|
||||
</Tooltip>
|
||||
</Box>
|
||||
|
||||
{/* AI Justification Banner */}
|
||||
{intentAnalysis?.optimized_config?.provider_justification && (
|
||||
<Box sx={{
|
||||
mb: 2,
|
||||
p: 1.5,
|
||||
backgroundColor: '#f0fdf4',
|
||||
border: '1px solid #86efac',
|
||||
borderRadius: '8px',
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: 1,
|
||||
}}>
|
||||
<span style={{ fontSize: '14px' }}>🤖</span>
|
||||
<Typography variant="body2" sx={{ color: '#166534', fontSize: '12px' }}>
|
||||
<strong>AI Recommendation:</strong> {intentAnalysis.optimized_config.provider_justification}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Provider Selection with Justification */}
|
||||
<Box mb={2}>
|
||||
<Box display="flex" alignItems="center" gap={1} mb={1}>
|
||||
<Typography variant="caption" color="#666" fontWeight={600}>
|
||||
Research Provider
|
||||
</Typography>
|
||||
{intentAnalysis?.optimized_config?.provider_justification && (
|
||||
<Tooltip title={intentAnalysis.optimized_config.provider_justification} arrow>
|
||||
<Chip
|
||||
label="AI"
|
||||
size="small"
|
||||
sx={{
|
||||
height: '16px',
|
||||
fontSize: '9px',
|
||||
backgroundColor: '#dcfce7',
|
||||
color: '#166534',
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Box>
|
||||
<ProviderChips providerAvailability={providerAvailability} />
|
||||
{/* Provider Selector */}
|
||||
<Box sx={{ mt: 1 }}>
|
||||
<select
|
||||
value={config.provider || 'exa'}
|
||||
onChange={(e) => onConfigUpdate({ provider: e.target.value as ResearchProvider })}
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid #e2e8f0',
|
||||
fontSize: '13px',
|
||||
backgroundColor: 'white',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{providerAvailability.exa_available && <option value="exa">Exa</option>}
|
||||
{providerAvailability.tavily_available && <option value="tavily">Tavily</option>}
|
||||
<option value="google">Google</option>
|
||||
</select>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Provider-specific Options with AI tooltips */}
|
||||
{config.provider === 'exa' && providerAvailability.exa_available && (
|
||||
<>
|
||||
{/* AI Settings Summary for Exa */}
|
||||
{intentAnalysis?.optimized_config && (
|
||||
<Box sx={{
|
||||
mb: 2,
|
||||
p: 1.5,
|
||||
backgroundColor: '#f8fafc',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid #e2e8f0',
|
||||
}}>
|
||||
<Typography variant="caption" color="#666" fontWeight={600} gutterBottom display="block">
|
||||
AI-Selected Exa Settings
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, mt: 1 }}>
|
||||
{intentAnalysis.optimized_config.exa_type && (
|
||||
<Tooltip title={intentAnalysis.optimized_config.exa_type_justification || 'Search type'} arrow>
|
||||
<Chip
|
||||
label={`Type: ${intentAnalysis.optimized_config.exa_type}`}
|
||||
size="small"
|
||||
sx={{ fontSize: '11px', backgroundColor: '#e0f2fe', color: '#0369a1' }}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
{intentAnalysis.optimized_config.exa_category && (
|
||||
<Tooltip title={intentAnalysis.optimized_config.exa_category_justification || 'Category focus'} arrow>
|
||||
<Chip
|
||||
label={`Category: ${intentAnalysis.optimized_config.exa_category}`}
|
||||
size="small"
|
||||
sx={{ fontSize: '11px', backgroundColor: '#fef3c7', color: '#92400e' }}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
{intentAnalysis.optimized_config.exa_date_filter && (
|
||||
<Tooltip title={intentAnalysis.optimized_config.exa_date_justification || 'Date filter'} arrow>
|
||||
<Chip
|
||||
label={`Since: ${intentAnalysis.optimized_config.exa_date_filter.split('T')[0]}`}
|
||||
size="small"
|
||||
sx={{ fontSize: '11px', backgroundColor: '#f3e8ff', color: '#7c3aed' }}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
<ExaOptions
|
||||
config={config}
|
||||
onConfigUpdate={onConfigUpdate}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{config.provider === 'tavily' && providerAvailability.tavily_available && (
|
||||
<>
|
||||
{/* AI Settings Summary for Tavily */}
|
||||
{intentAnalysis?.optimized_config && (
|
||||
<Box sx={{
|
||||
mb: 2,
|
||||
p: 1.5,
|
||||
backgroundColor: '#f8fafc',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid #e2e8f0',
|
||||
}}>
|
||||
<Typography variant="caption" color="#666" fontWeight={600} gutterBottom display="block">
|
||||
AI-Selected Tavily Settings
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, mt: 1 }}>
|
||||
{intentAnalysis.optimized_config.tavily_topic && (
|
||||
<Tooltip title={intentAnalysis.optimized_config.tavily_topic_justification || 'Topic category'} arrow>
|
||||
<Chip
|
||||
label={`Topic: ${intentAnalysis.optimized_config.tavily_topic}`}
|
||||
size="small"
|
||||
sx={{ fontSize: '11px', backgroundColor: '#e0f2fe', color: '#0369a1' }}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
{intentAnalysis.optimized_config.tavily_search_depth && (
|
||||
<Tooltip title={intentAnalysis.optimized_config.tavily_search_depth_justification || 'Search depth'} arrow>
|
||||
<Chip
|
||||
label={`Depth: ${intentAnalysis.optimized_config.tavily_search_depth}`}
|
||||
size="small"
|
||||
sx={{ fontSize: '11px', backgroundColor: '#fef3c7', color: '#92400e' }}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
{intentAnalysis.optimized_config.tavily_time_range && (
|
||||
<Tooltip title={intentAnalysis.optimized_config.tavily_time_range_justification || 'Time filter'} arrow>
|
||||
<Chip
|
||||
label={`Time: ${intentAnalysis.optimized_config.tavily_time_range}`}
|
||||
size="small"
|
||||
sx={{ fontSize: '11px', backgroundColor: '#dcfce7', color: '#166534' }}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
{intentAnalysis.optimized_config.tavily_include_answer && (
|
||||
<Tooltip title={intentAnalysis.optimized_config.tavily_include_answer_justification || 'AI answer'} arrow>
|
||||
<Chip
|
||||
label="AI Answer ✓"
|
||||
size="small"
|
||||
sx={{ fontSize: '11px', backgroundColor: '#f3e8ff', color: '#7c3aed' }}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
<TavilyOptions
|
||||
config={config}
|
||||
onConfigUpdate={onConfigUpdate}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* DeliverablesSelector Component
|
||||
*
|
||||
* Allows user to select/remove expected deliverables.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Chip,
|
||||
Tooltip,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Info as InfoIcon,
|
||||
} from '@mui/icons-material';
|
||||
import {
|
||||
ResearchIntent,
|
||||
ExpectedDeliverable,
|
||||
DELIVERABLE_DISPLAY,
|
||||
} from '../../../types/intent.types';
|
||||
|
||||
interface DeliverablesSelectorProps {
|
||||
intent: ResearchIntent;
|
||||
onToggle: (deliverable: ExpectedDeliverable) => void;
|
||||
}
|
||||
|
||||
export const DeliverablesSelector: React.FC<DeliverablesSelectorProps> = ({
|
||||
intent,
|
||||
onToggle,
|
||||
}) => {
|
||||
return (
|
||||
<Box
|
||||
mb={2}
|
||||
sx={{
|
||||
p: 2,
|
||||
backgroundColor: '#f9fafb',
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: 1,
|
||||
}}
|
||||
>
|
||||
<Box display="flex" alignItems="center" justifyContent="space-between" mb={1}>
|
||||
<Typography variant="caption" color="#666" fontWeight={600}>
|
||||
What I'll find for you:
|
||||
</Typography>
|
||||
<Tooltip title="Click chips to select/remove deliverables">
|
||||
<InfoIcon sx={{ fontSize: 14, color: '#9ca3af' }} />
|
||||
</Tooltip>
|
||||
</Box>
|
||||
<Box display="flex" flexWrap="wrap" gap={0.5}>
|
||||
{Object.entries(DELIVERABLE_DISPLAY).map(([key, label]) => {
|
||||
const isSelected = intent.expected_deliverables.includes(key as ExpectedDeliverable);
|
||||
return (
|
||||
<Chip
|
||||
key={key}
|
||||
label={label}
|
||||
size="small"
|
||||
onClick={() => onToggle(key as ExpectedDeliverable)}
|
||||
sx={{
|
||||
backgroundColor: isSelected ? '#dbeafe' : '#ffffff',
|
||||
border: `1px solid ${isSelected ? '#3b82f6' : '#d1d5db'}`,
|
||||
color: isSelected ? '#1e40af' : '#374151',
|
||||
cursor: 'pointer',
|
||||
'&:hover': {
|
||||
backgroundColor: isSelected ? '#bfdbfe' : '#f3f4f6',
|
||||
borderColor: isSelected ? '#2563eb' : '#9ca3af',
|
||||
},
|
||||
fontWeight: isSelected ? 600 : 400,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* EditableField Component
|
||||
*
|
||||
* Reusable component for inline editing of intent fields.
|
||||
* Supports text input and select dropdown.
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
IconButton,
|
||||
TextField,
|
||||
FormControl,
|
||||
Select,
|
||||
MenuItem,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Edit as EditIcon,
|
||||
Save as SaveIcon,
|
||||
Cancel as CancelIcon,
|
||||
} from '@mui/icons-material';
|
||||
|
||||
interface EditableFieldProps {
|
||||
field: string;
|
||||
value: any;
|
||||
displayValue: string;
|
||||
options?: Array<{ key: string; label: string }>;
|
||||
onSave: (newValue: any) => void;
|
||||
}
|
||||
|
||||
export const EditableField: React.FC<EditableFieldProps> = ({
|
||||
field,
|
||||
value,
|
||||
displayValue,
|
||||
options,
|
||||
onSave,
|
||||
}) => {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editValue, setEditValue] = useState(value);
|
||||
|
||||
const handleSave = () => {
|
||||
onSave(editValue);
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setEditValue(value);
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box display="flex" alignItems="center" gap={0.5}>
|
||||
{isEditing ? (
|
||||
<Box display="flex" alignItems="center" gap={0.5} flex={1}>
|
||||
{options ? (
|
||||
<FormControl size="small" fullWidth>
|
||||
<Select
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
sx={{ backgroundColor: '#ffffff' }}
|
||||
autoFocus
|
||||
>
|
||||
{options.map(opt => (
|
||||
<MenuItem key={opt.key} value={opt.key}>{opt.label}</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
) : (
|
||||
<TextField
|
||||
size="small"
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
fullWidth
|
||||
sx={{ backgroundColor: '#ffffff' }}
|
||||
autoFocus
|
||||
/>
|
||||
)}
|
||||
<IconButton size="small" onClick={handleSave} color="primary">
|
||||
<SaveIcon fontSize="small" />
|
||||
</IconButton>
|
||||
<IconButton size="small" onClick={handleCancel} color="inherit">
|
||||
<CancelIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
) : (
|
||||
<>
|
||||
<Typography variant="body2" fontWeight={500} color="#333" sx={{ flex: 1 }}>
|
||||
{displayValue}
|
||||
</Typography>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => setIsEditing(true)}
|
||||
sx={{
|
||||
color: '#666',
|
||||
'&:hover': {
|
||||
backgroundColor: '#f3f4f6',
|
||||
color: '#0ea5e9',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<EditIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* ExpandableDetails Component
|
||||
*
|
||||
* Collapsible section showing secondary questions, focus areas, and research angles.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Chip,
|
||||
Collapse,
|
||||
} from '@mui/material';
|
||||
import { AnalyzeIntentResponse } from '../../../types/intent.types';
|
||||
|
||||
interface ExpandableDetailsProps {
|
||||
intentAnalysis: AnalyzeIntentResponse;
|
||||
expanded: boolean;
|
||||
}
|
||||
|
||||
export const ExpandableDetails: React.FC<ExpandableDetailsProps> = ({
|
||||
intentAnalysis,
|
||||
expanded,
|
||||
}) => {
|
||||
const intent = intentAnalysis.intent;
|
||||
|
||||
return (
|
||||
<Collapse in={expanded}>
|
||||
<Box sx={{ pt: 2, borderTop: '1px solid #e5e7eb', mt: 2 }}>
|
||||
{/* Secondary Questions */}
|
||||
{intent.secondary_questions.length > 0 && (
|
||||
<Box mb={2}>
|
||||
<Typography variant="caption" color="#666" fontWeight={600} gutterBottom display="block">
|
||||
Also answering:
|
||||
</Typography>
|
||||
{intent.secondary_questions.slice(0, 3).map((q, idx) => (
|
||||
<Typography key={idx} variant="body2" color="#333" sx={{ ml: 1, mb: 0.5 }}>
|
||||
• {q}
|
||||
</Typography>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Focus Areas */}
|
||||
{intent.focus_areas.length > 0 && (
|
||||
<Box mb={2}>
|
||||
<Typography variant="caption" color="#666" fontWeight={600} gutterBottom display="block">
|
||||
Focus areas:
|
||||
</Typography>
|
||||
<Box display="flex" flexWrap="wrap" gap={0.5}>
|
||||
{intent.focus_areas.map((area, idx) => (
|
||||
<Chip
|
||||
key={idx}
|
||||
label={area}
|
||||
size="small"
|
||||
sx={{
|
||||
backgroundColor: '#f3f4f6',
|
||||
border: '1px solid #d1d5db',
|
||||
color: '#374151',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Research Angles */}
|
||||
{intentAnalysis.suggested_angles?.length > 0 && (
|
||||
<Box mb={2}>
|
||||
<Typography variant="caption" color="#666" fontWeight={600} gutterBottom display="block">
|
||||
Research angles:
|
||||
</Typography>
|
||||
{intentAnalysis.suggested_angles.slice(0, 3).map((angle, idx) => (
|
||||
<Typography key={idx} variant="body2" color="#333" sx={{ ml: 1, mb: 0.5 }}>
|
||||
• {angle}
|
||||
</Typography>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Collapse>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* IntentConfirmationHeader Component
|
||||
*
|
||||
* Header section showing confidence level and analysis summary.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Chip,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Psychology as BrainIcon,
|
||||
Close as CloseIcon,
|
||||
Info as InfoIcon,
|
||||
} from '@mui/icons-material';
|
||||
import { AnalyzeIntentResponse } from '../../../types/intent.types';
|
||||
|
||||
interface IntentConfirmationHeaderProps {
|
||||
intentAnalysis: AnalyzeIntentResponse;
|
||||
onDismiss: () => void;
|
||||
}
|
||||
|
||||
export const IntentConfirmationHeader: React.FC<IntentConfirmationHeaderProps> = ({
|
||||
intentAnalysis,
|
||||
onDismiss,
|
||||
}) => {
|
||||
const intent = intentAnalysis.intent;
|
||||
const confidence = intent.confidence;
|
||||
const isHighConfidence = confidence >= 0.8;
|
||||
const confidenceReason = (intentAnalysis as any).confidence_reason || '';
|
||||
const greatExample = (intentAnalysis as any).great_example || '';
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
backgroundColor: isHighConfidence ? '#f0f9ff' : '#fff7ed',
|
||||
borderBottom: '1px solid #e0e0e0',
|
||||
}}
|
||||
>
|
||||
<Box display="flex" alignItems="center" gap={1.5}>
|
||||
<BrainIcon sx={{ color: isHighConfidence ? '#10b981' : '#f59e0b' }} />
|
||||
<Box>
|
||||
<Typography variant="subtitle1" fontWeight={600} color="#333">
|
||||
Help ALwrity understand Your Research
|
||||
</Typography>
|
||||
<Typography variant="caption" color="#666">
|
||||
{intentAnalysis.analysis_summary}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box display="flex" alignItems="center" gap={1}>
|
||||
<Tooltip
|
||||
title={
|
||||
<Box sx={{ p: 1, maxWidth: 300 }}>
|
||||
<Typography variant="caption" fontWeight={600} display="block" gutterBottom>
|
||||
Confidence: {Math.round(confidence * 100)}%
|
||||
</Typography>
|
||||
{confidenceReason && (
|
||||
<Typography variant="caption" display="block" sx={{ mb: 1 }}>
|
||||
{confidenceReason}
|
||||
</Typography>
|
||||
)}
|
||||
{greatExample && (
|
||||
<>
|
||||
<Typography variant="caption" fontWeight={600} display="block" gutterBottom>
|
||||
Great example would be:
|
||||
</Typography>
|
||||
<Typography variant="caption" display="block">
|
||||
{greatExample}
|
||||
</Typography>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
}
|
||||
arrow
|
||||
>
|
||||
<Chip
|
||||
size="small"
|
||||
label={`${Math.round(confidence * 100)}% confident`}
|
||||
color={isHighConfidence ? 'success' : 'warning'}
|
||||
variant="outlined"
|
||||
sx={{
|
||||
backgroundColor: '#ffffff',
|
||||
cursor: 'help',
|
||||
}}
|
||||
icon={<InfoIcon fontSize="small" />}
|
||||
/>
|
||||
</Tooltip>
|
||||
<IconButton size="small" onClick={onDismiss} sx={{ color: '#666' }}>
|
||||
<CloseIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* IntentConfirmationPanel Component (Refactored)
|
||||
*
|
||||
* Main orchestrator component that composes smaller, focused components.
|
||||
* Shows the AI-inferred research intent and allows user to confirm or modify.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Paper, Box } from '@mui/material';
|
||||
import {
|
||||
ResearchIntent,
|
||||
AnalyzeIntentResponse,
|
||||
ResearchQuery,
|
||||
ExpectedDeliverable,
|
||||
} from '../../../types/intent.types';
|
||||
import { ProviderAvailability } from '../../../../../api/researchConfig';
|
||||
|
||||
// Sub-components
|
||||
import { LoadingState } from './LoadingState';
|
||||
import { IntentConfirmationHeader } from './IntentConfirmationHeader';
|
||||
import { PrimaryQuestionEditor } from './PrimaryQuestionEditor';
|
||||
import { IntentSummaryGrid } from './IntentSummaryGrid';
|
||||
import { DeliverablesSelector } from './DeliverablesSelector';
|
||||
import { ResearchQueriesSection } from './ResearchQueriesSection';
|
||||
import { TrendsConfigSection } from './TrendsConfigSection';
|
||||
import { AdvancedProviderOptionsSection } from './AdvancedProviderOptionsSection';
|
||||
import { ExpandableDetails } from './ExpandableDetails';
|
||||
import { ActionButtons } from './ActionButtons';
|
||||
|
||||
export interface IntentConfirmationPanelProps {
|
||||
isAnalyzing: boolean;
|
||||
intentAnalysis: AnalyzeIntentResponse | null;
|
||||
confirmedIntent: ResearchIntent | null;
|
||||
onConfirm: (intent: ResearchIntent) => void;
|
||||
onUpdateField: <K extends keyof ResearchIntent>(field: K, value: ResearchIntent[K]) => void;
|
||||
onExecute: (selectedQueries?: ResearchQuery[]) => void;
|
||||
onDismiss: () => void;
|
||||
isExecuting: boolean;
|
||||
showAdvancedOptions?: boolean;
|
||||
onAdvancedOptionsChange?: (show: boolean) => void;
|
||||
providerAvailability?: ProviderAvailability | null;
|
||||
config?: any;
|
||||
onConfigUpdate?: (updates: any) => void;
|
||||
}
|
||||
|
||||
export const IntentConfirmationPanel: React.FC<IntentConfirmationPanelProps> = ({
|
||||
isAnalyzing,
|
||||
intentAnalysis,
|
||||
confirmedIntent,
|
||||
onConfirm,
|
||||
onUpdateField,
|
||||
onExecute,
|
||||
onDismiss,
|
||||
isExecuting,
|
||||
showAdvancedOptions = false,
|
||||
onAdvancedOptionsChange,
|
||||
providerAvailability,
|
||||
config,
|
||||
onConfigUpdate,
|
||||
}) => {
|
||||
const [showDetails, setShowDetails] = useState(false);
|
||||
const [selectedQueries, setSelectedQueries] = useState<Set<number>>(
|
||||
new Set(intentAnalysis?.suggested_queries?.map((_, idx) => idx) || [])
|
||||
);
|
||||
const [editedQueries, setEditedQueries] = useState<ResearchQuery[]>(
|
||||
intentAnalysis?.suggested_queries || []
|
||||
);
|
||||
|
||||
// Update edited queries when intentAnalysis changes
|
||||
useEffect(() => {
|
||||
if (intentAnalysis?.suggested_queries) {
|
||||
setEditedQueries(intentAnalysis.suggested_queries);
|
||||
setSelectedQueries(new Set(intentAnalysis.suggested_queries.map((_, idx) => idx)));
|
||||
}
|
||||
}, [intentAnalysis]);
|
||||
|
||||
// Loading state
|
||||
if (isAnalyzing) {
|
||||
return <LoadingState />;
|
||||
}
|
||||
|
||||
// No analysis yet
|
||||
if (!intentAnalysis || !intentAnalysis.success) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const intent = intentAnalysis.intent;
|
||||
|
||||
const handleDeliverableToggle = (deliverable: ExpectedDeliverable) => {
|
||||
const current = intent.expected_deliverables || [];
|
||||
const updated = current.includes(deliverable)
|
||||
? current.filter(d => d !== deliverable)
|
||||
: [...current, deliverable];
|
||||
onUpdateField('expected_deliverables', updated);
|
||||
};
|
||||
|
||||
const handleExecute = () => {
|
||||
const updatedIntent = { ...intent };
|
||||
onConfirm(updatedIntent);
|
||||
const queriesToUse = Array.from(selectedQueries)
|
||||
.sort((a, b) => a - b)
|
||||
.map(idx => editedQueries[idx])
|
||||
.filter(q => q && q.query.trim().length > 0);
|
||||
onExecute(queriesToUse);
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
mt: 2,
|
||||
borderRadius: 2,
|
||||
border: '1px solid #e0e0e0',
|
||||
backgroundColor: '#ffffff',
|
||||
overflow: 'hidden',
|
||||
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)',
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<IntentConfirmationHeader
|
||||
intentAnalysis={intentAnalysis}
|
||||
onDismiss={onDismiss}
|
||||
/>
|
||||
|
||||
{/* Main Content */}
|
||||
<Box sx={{ p: 2, backgroundColor: '#ffffff' }}>
|
||||
{/* Primary Question */}
|
||||
<PrimaryQuestionEditor
|
||||
intent={intent}
|
||||
onUpdate={(value) => onUpdateField('primary_question', value)}
|
||||
/>
|
||||
|
||||
{/* Quick Summary Grid */}
|
||||
<IntentSummaryGrid
|
||||
intent={intent}
|
||||
queriesCount={editedQueries.length}
|
||||
onUpdateField={onUpdateField}
|
||||
/>
|
||||
|
||||
{/* Deliverables Selector */}
|
||||
<DeliverablesSelector
|
||||
intent={intent}
|
||||
onToggle={handleDeliverableToggle}
|
||||
/>
|
||||
|
||||
{/* Research Queries Section */}
|
||||
<ResearchQueriesSection
|
||||
queries={editedQueries}
|
||||
selectedQueries={selectedQueries}
|
||||
onQueriesChange={setEditedQueries}
|
||||
onSelectionChange={setSelectedQueries}
|
||||
/>
|
||||
|
||||
{/* Google Trends Section */}
|
||||
{intentAnalysis.trends_config && (
|
||||
<TrendsConfigSection trendsConfig={intentAnalysis.trends_config} />
|
||||
)}
|
||||
|
||||
{/* Advanced Options Section */}
|
||||
{providerAvailability && config && onConfigUpdate && onAdvancedOptionsChange && (
|
||||
<AdvancedProviderOptionsSection
|
||||
intentAnalysis={intentAnalysis}
|
||||
providerAvailability={providerAvailability}
|
||||
config={config}
|
||||
onConfigUpdate={onConfigUpdate}
|
||||
showAdvancedOptions={showAdvancedOptions}
|
||||
onAdvancedOptionsChange={onAdvancedOptionsChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Expandable Details */}
|
||||
<ExpandableDetails
|
||||
intentAnalysis={intentAnalysis}
|
||||
expanded={showDetails}
|
||||
/>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<ActionButtons
|
||||
showDetails={showDetails}
|
||||
onToggleDetails={() => setShowDetails(!showDetails)}
|
||||
onExecute={handleExecute}
|
||||
isExecuting={isExecuting}
|
||||
canExecute={selectedQueries.size > 0}
|
||||
/>
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
export default IntentConfirmationPanel;
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* IntentConfirmationPanelHeader Component
|
||||
*
|
||||
* Header section with title, confidence indicator, and close button.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Chip,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Psychology as BrainIcon,
|
||||
Close as CloseIcon,
|
||||
Info as InfoIcon,
|
||||
} from '@mui/icons-material';
|
||||
import { AnalyzeIntentResponse } from '../../../types/intent.types';
|
||||
|
||||
interface IntentConfirmationPanelHeaderProps {
|
||||
intentAnalysis: AnalyzeIntentResponse;
|
||||
onDismiss: () => void;
|
||||
}
|
||||
|
||||
export const IntentConfirmationPanelHeader: React.FC<IntentConfirmationPanelHeaderProps> = ({
|
||||
intentAnalysis,
|
||||
onDismiss,
|
||||
}) => {
|
||||
const intent = intentAnalysis.intent;
|
||||
const confidence = intent.confidence;
|
||||
const isHighConfidence = confidence >= 0.8;
|
||||
const confidenceReason = (intentAnalysis as any).confidence_reason || '';
|
||||
const greatExample = (intentAnalysis as any).great_example || '';
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
backgroundColor: isHighConfidence ? '#f0f9ff' : '#fff7ed',
|
||||
borderBottom: '1px solid #e0e0e0',
|
||||
}}
|
||||
>
|
||||
<Box display="flex" alignItems="center" gap={1.5}>
|
||||
<BrainIcon sx={{ color: isHighConfidence ? '#10b981' : '#f59e0b' }} />
|
||||
<Box>
|
||||
<Typography variant="subtitle1" fontWeight={600} color="#333">
|
||||
Help ALwrity understand Your Research
|
||||
</Typography>
|
||||
<Typography variant="caption" color="#666">
|
||||
{intentAnalysis.analysis_summary}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box display="flex" alignItems="center" gap={1}>
|
||||
<Tooltip
|
||||
title={
|
||||
<Box sx={{ p: 1, maxWidth: 300 }}>
|
||||
<Typography variant="caption" fontWeight={600} display="block" gutterBottom>
|
||||
Confidence: {Math.round(confidence * 100)}%
|
||||
</Typography>
|
||||
{confidenceReason && (
|
||||
<Typography variant="caption" display="block" sx={{ mb: 1 }}>
|
||||
{confidenceReason}
|
||||
</Typography>
|
||||
)}
|
||||
{greatExample && (
|
||||
<>
|
||||
<Typography variant="caption" fontWeight={600} display="block" gutterBottom>
|
||||
Great example would be:
|
||||
</Typography>
|
||||
<Typography variant="caption" display="block">
|
||||
{greatExample}
|
||||
</Typography>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
}
|
||||
arrow
|
||||
>
|
||||
<Chip
|
||||
size="small"
|
||||
label={`${Math.round(confidence * 100)}% confident`}
|
||||
color={isHighConfidence ? 'success' : 'warning'}
|
||||
variant="outlined"
|
||||
sx={{
|
||||
backgroundColor: '#ffffff',
|
||||
cursor: 'help',
|
||||
}}
|
||||
icon={<InfoIcon fontSize="small" />}
|
||||
/>
|
||||
</Tooltip>
|
||||
<IconButton size="small" onClick={onDismiss} sx={{ color: '#666' }}>
|
||||
<CloseIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* IntentSummaryGrid Component
|
||||
*
|
||||
* Quick summary grid showing Purpose, Content Type, Depth, and Queries Count.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Grid,
|
||||
Card,
|
||||
CardContent,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
ResearchIntent,
|
||||
ResearchPurpose,
|
||||
ContentOutput,
|
||||
ResearchDepthLevel,
|
||||
PURPOSE_DISPLAY,
|
||||
CONTENT_OUTPUT_DISPLAY,
|
||||
DEPTH_DISPLAY,
|
||||
} from '../../../types/intent.types';
|
||||
import { EditableField } from './EditableField';
|
||||
|
||||
interface IntentSummaryGridProps {
|
||||
intent: ResearchIntent;
|
||||
queriesCount: number;
|
||||
onUpdateField: <K extends keyof ResearchIntent>(field: K, value: ResearchIntent[K]) => void;
|
||||
}
|
||||
|
||||
export const IntentSummaryGrid: React.FC<IntentSummaryGridProps> = ({
|
||||
intent,
|
||||
queriesCount,
|
||||
onUpdateField,
|
||||
}) => {
|
||||
return (
|
||||
<Grid container spacing={2} mb={2}>
|
||||
{/* Purpose */}
|
||||
<Grid item xs={6} sm={3}>
|
||||
<Card variant="outlined" sx={{ height: '100%', backgroundColor: '#ffffff', border: '1px solid #e5e7eb' }}>
|
||||
<CardContent sx={{ py: 1.5, px: 1.5, '&:last-child': { pb: 1.5 } }}>
|
||||
<Typography variant="caption" color="#666" fontWeight={500} display="block" mb={0.5}>
|
||||
Purpose
|
||||
</Typography>
|
||||
<EditableField
|
||||
field="purpose"
|
||||
value={intent.purpose}
|
||||
displayValue={PURPOSE_DISPLAY[intent.purpose as ResearchPurpose] || intent.purpose}
|
||||
options={Object.entries(PURPOSE_DISPLAY).map(([key, label]) => ({ key, label }))}
|
||||
onSave={(val) => onUpdateField('purpose', val as ResearchPurpose)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
{/* Content Type */}
|
||||
<Grid item xs={6} sm={3}>
|
||||
<Card variant="outlined" sx={{ height: '100%', backgroundColor: '#ffffff', border: '1px solid #e5e7eb' }}>
|
||||
<CardContent sx={{ py: 1.5, px: 1.5, '&:last-child': { pb: 1.5 } }}>
|
||||
<Typography variant="caption" color="#666" fontWeight={500} display="block" mb={0.5}>
|
||||
Creating
|
||||
</Typography>
|
||||
<EditableField
|
||||
field="content_output"
|
||||
value={intent.content_output}
|
||||
displayValue={CONTENT_OUTPUT_DISPLAY[intent.content_output as ContentOutput] || intent.content_output}
|
||||
options={Object.entries(CONTENT_OUTPUT_DISPLAY).map(([key, label]) => ({ key, label }))}
|
||||
onSave={(val) => onUpdateField('content_output', val as ContentOutput)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
{/* Depth */}
|
||||
<Grid item xs={6} sm={3}>
|
||||
<Card variant="outlined" sx={{ height: '100%', backgroundColor: '#ffffff', border: '1px solid #e5e7eb' }}>
|
||||
<CardContent sx={{ py: 1.5, px: 1.5, '&:last-child': { pb: 1.5 } }}>
|
||||
<Typography variant="caption" color="#666" fontWeight={500} display="block" mb={0.5}>
|
||||
Depth
|
||||
</Typography>
|
||||
<EditableField
|
||||
field="depth"
|
||||
value={intent.depth}
|
||||
displayValue={DEPTH_DISPLAY[intent.depth as ResearchDepthLevel] || intent.depth}
|
||||
options={Object.entries(DEPTH_DISPLAY).map(([key, label]) => ({ key, label }))}
|
||||
onSave={(val) => onUpdateField('depth', val as ResearchDepthLevel)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
{/* Queries Count */}
|
||||
<Grid item xs={6} sm={3}>
|
||||
<Card variant="outlined" sx={{ height: '100%', backgroundColor: '#ffffff', border: '1px solid #e5e7eb' }}>
|
||||
<CardContent sx={{ py: 1.5, px: 1.5, '&:last-child': { pb: 1.5 } }}>
|
||||
<Typography variant="caption" color="#666" fontWeight={500} display="block" mb={0.5}>
|
||||
Queries
|
||||
</Typography>
|
||||
<Typography variant="body2" fontWeight={500} color="#333">
|
||||
{queriesCount} targeted
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* LoadingState Component
|
||||
*
|
||||
* Displays loading indicator while intent is being analyzed.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Box, Typography, Paper, CircularProgress } from '@mui/material';
|
||||
|
||||
interface LoadingStateProps {
|
||||
message?: string;
|
||||
subMessage?: string;
|
||||
}
|
||||
|
||||
export const LoadingState: React.FC<LoadingStateProps> = ({
|
||||
message = '🧠 Analyzing your research intent...',
|
||||
subMessage = 'AI is understanding what you want to accomplish',
|
||||
}) => {
|
||||
return (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 3,
|
||||
mt: 2,
|
||||
borderRadius: 2,
|
||||
border: '1px solid #e0e0e0',
|
||||
backgroundColor: '#ffffff',
|
||||
}}
|
||||
>
|
||||
<Box display="flex" alignItems="center" gap={2}>
|
||||
<CircularProgress size={24} />
|
||||
<Box>
|
||||
<Typography variant="subtitle1" fontWeight={600} color="#333">
|
||||
{message}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="#666">
|
||||
{subMessage}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* PrimaryQuestionEditor Component
|
||||
*
|
||||
* Editable primary question section.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
TextField,
|
||||
IconButton,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Edit as EditIcon,
|
||||
Save as SaveIcon,
|
||||
Cancel as CancelIcon,
|
||||
} from '@mui/icons-material';
|
||||
import { ResearchIntent } from '../../../types/intent.types';
|
||||
|
||||
interface PrimaryQuestionEditorProps {
|
||||
intent: ResearchIntent;
|
||||
onUpdate: (value: string) => void;
|
||||
}
|
||||
|
||||
export const PrimaryQuestionEditor: React.FC<PrimaryQuestionEditorProps> = ({
|
||||
intent,
|
||||
onUpdate,
|
||||
}) => {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [value, setValue] = useState(intent.primary_question);
|
||||
|
||||
useEffect(() => {
|
||||
setValue(intent.primary_question);
|
||||
}, [intent.primary_question]);
|
||||
|
||||
const handleSave = () => {
|
||||
if (value.trim()) {
|
||||
onUpdate(value.trim());
|
||||
}
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setValue(intent.primary_question);
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
mb: 2,
|
||||
p: 2,
|
||||
backgroundColor: '#f0f9ff',
|
||||
border: '1px solid #bae6fd',
|
||||
borderRadius: 1,
|
||||
}}
|
||||
>
|
||||
<Box display="flex" alignItems="center" justifyContent="space-between" mb={1}>
|
||||
<Typography variant="caption" fontWeight={600} color="#0c4a6e">
|
||||
Main Question:
|
||||
</Typography>
|
||||
{!isEditing && (
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => setIsEditing(true)}
|
||||
sx={{
|
||||
color: '#666',
|
||||
'&:hover': {
|
||||
backgroundColor: '#e0f2fe',
|
||||
color: '#0ea5e9',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<EditIcon fontSize="small" />
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
{isEditing ? (
|
||||
<Box display="flex" alignItems="flex-start" gap={1}>
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline
|
||||
rows={2}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
sx={{ backgroundColor: '#ffffff' }}
|
||||
autoFocus
|
||||
/>
|
||||
<Box display="flex" flexDirection="column" gap={0.5}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={handleSave}
|
||||
color="primary"
|
||||
sx={{ backgroundColor: '#e0f2fe' }}
|
||||
>
|
||||
<SaveIcon fontSize="small" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={handleCancel}
|
||||
sx={{ color: '#666' }}
|
||||
>
|
||||
<CancelIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
) : (
|
||||
<Typography variant="body2" fontWeight={500} color="#0c4a6e">
|
||||
{intent.primary_question}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* QueryEditor Component
|
||||
*
|
||||
* Individual query editor with provider, purpose, priority, and expected results.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
TextField,
|
||||
FormControl,
|
||||
Select,
|
||||
MenuItem,
|
||||
Checkbox,
|
||||
IconButton,
|
||||
ListItem,
|
||||
ListItemSecondaryAction,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Delete as DeleteIcon,
|
||||
} from '@mui/icons-material';
|
||||
import {
|
||||
ResearchQuery,
|
||||
ExpectedDeliverable,
|
||||
DELIVERABLE_DISPLAY,
|
||||
} from '../../../types/intent.types';
|
||||
|
||||
interface QueryEditorProps {
|
||||
query: ResearchQuery;
|
||||
index: number;
|
||||
isSelected: boolean;
|
||||
onToggle: () => void;
|
||||
onEdit: (field: keyof ResearchQuery, value: any) => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
export const QueryEditor: React.FC<QueryEditorProps> = ({
|
||||
query,
|
||||
index,
|
||||
isSelected,
|
||||
onToggle,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}) => {
|
||||
return (
|
||||
<ListItem
|
||||
sx={{
|
||||
backgroundColor: isSelected ? '#e0f2fe' : '#ffffff',
|
||||
borderLeft: isSelected ? '3px solid #0ea5e9' : '3px solid transparent',
|
||||
'&:hover': { backgroundColor: isSelected ? '#bae6fd' : '#f9fafb' },
|
||||
py: 1.5,
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onChange={onToggle}
|
||||
size="small"
|
||||
sx={{ mr: 1 }}
|
||||
/>
|
||||
<Box flex={1}>
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
value={query.query}
|
||||
onChange={(e) => onEdit('query', e.target.value)}
|
||||
placeholder="Enter research query"
|
||||
sx={{
|
||||
mb: 1,
|
||||
backgroundColor: '#ffffff',
|
||||
'& .MuiOutlinedInput-root': {
|
||||
'&:hover fieldset': { borderColor: '#0ea5e9' },
|
||||
'&.Mui-focused fieldset': { borderColor: '#0ea5e9' },
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Box display="flex" gap={1} flexWrap="wrap">
|
||||
<FormControl size="small" sx={{ minWidth: 100 }}>
|
||||
<Select
|
||||
value={query.provider}
|
||||
onChange={(e) => onEdit('provider', e.target.value)}
|
||||
sx={{
|
||||
backgroundColor: '#ffffff',
|
||||
fontSize: '0.75rem',
|
||||
}}
|
||||
>
|
||||
<MenuItem value="exa">Exa</MenuItem>
|
||||
<MenuItem value="tavily">Tavily</MenuItem>
|
||||
<MenuItem value="google">Google</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl size="small" sx={{ minWidth: 140 }}>
|
||||
<Select
|
||||
value={query.purpose}
|
||||
onChange={(e) => onEdit('purpose', e.target.value)}
|
||||
sx={{
|
||||
backgroundColor: '#ffffff',
|
||||
fontSize: '0.75rem',
|
||||
}}
|
||||
>
|
||||
{Object.entries(DELIVERABLE_DISPLAY).map(([key, label]) => (
|
||||
<MenuItem key={key} value={key}>{label}</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<TextField
|
||||
size="small"
|
||||
type="number"
|
||||
value={query.priority}
|
||||
onChange={(e) => onEdit('priority', parseInt(e.target.value) || 1)}
|
||||
inputProps={{ min: 1, max: 5 }}
|
||||
sx={{
|
||||
width: 90,
|
||||
backgroundColor: '#ffffff',
|
||||
fontSize: '0.75rem',
|
||||
}}
|
||||
label="Priority"
|
||||
/>
|
||||
</Box>
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
value={query.expected_results}
|
||||
onChange={(e) => onEdit('expected_results', e.target.value)}
|
||||
placeholder="What we expect to find"
|
||||
sx={{
|
||||
mt: 1,
|
||||
backgroundColor: '#ffffff',
|
||||
fontSize: '0.75rem',
|
||||
'& .MuiOutlinedInput-root': {
|
||||
'&:hover fieldset': { borderColor: '#0ea5e9' },
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<ListItemSecondaryAction>
|
||||
<IconButton
|
||||
edge="end"
|
||||
size="small"
|
||||
onClick={onDelete}
|
||||
sx={{
|
||||
color: '#dc2626',
|
||||
'&:hover': { backgroundColor: '#fee2e2' },
|
||||
}}
|
||||
>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</ListItemSecondaryAction>
|
||||
</ListItem>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* ResearchQueriesSection Component
|
||||
*
|
||||
* Accordion section for managing research queries (add, edit, delete, select).
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Accordion,
|
||||
AccordionSummary,
|
||||
AccordionDetails,
|
||||
List,
|
||||
Button,
|
||||
Divider,
|
||||
Chip,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
ExpandMore as ExpandMoreIcon,
|
||||
Search as SearchIcon,
|
||||
} from '@mui/icons-material';
|
||||
import {
|
||||
ResearchQuery,
|
||||
} from '../../../types/intent.types';
|
||||
import { QueryEditor } from './QueryEditor';
|
||||
|
||||
interface ResearchQueriesSectionProps {
|
||||
queries: ResearchQuery[];
|
||||
selectedQueries: Set<number>;
|
||||
onQueriesChange: (queries: ResearchQuery[]) => void;
|
||||
onSelectionChange: (selected: Set<number>) => void;
|
||||
}
|
||||
|
||||
export const ResearchQueriesSection: React.FC<ResearchQueriesSectionProps> = ({
|
||||
queries,
|
||||
selectedQueries,
|
||||
onQueriesChange,
|
||||
onSelectionChange,
|
||||
}) => {
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
|
||||
const handleQueryToggle = (index: number) => {
|
||||
const newSelected = new Set(selectedQueries);
|
||||
if (newSelected.has(index)) {
|
||||
newSelected.delete(index);
|
||||
} else {
|
||||
newSelected.add(index);
|
||||
}
|
||||
onSelectionChange(newSelected);
|
||||
};
|
||||
|
||||
const handleQueryEdit = (index: number, field: keyof ResearchQuery, value: any) => {
|
||||
const updated = [...queries];
|
||||
updated[index] = { ...updated[index], [field]: value };
|
||||
onQueriesChange(updated);
|
||||
};
|
||||
|
||||
const handleDeleteQuery = (index: number) => {
|
||||
const updated = queries.filter((_, idx) => idx !== index);
|
||||
onQueriesChange(updated);
|
||||
|
||||
const newSelected = new Set(selectedQueries);
|
||||
newSelected.delete(index);
|
||||
const adjusted = new Set<number>();
|
||||
newSelected.forEach(idx => {
|
||||
if (idx > index) {
|
||||
adjusted.add(idx - 1);
|
||||
} else if (idx < index) {
|
||||
adjusted.add(idx);
|
||||
}
|
||||
});
|
||||
onSelectionChange(adjusted);
|
||||
};
|
||||
|
||||
const handleAddQuery = () => {
|
||||
const newQuery: ResearchQuery = {
|
||||
query: '',
|
||||
purpose: 'key_statistics',
|
||||
provider: 'exa',
|
||||
priority: 3,
|
||||
expected_results: '',
|
||||
};
|
||||
onQueriesChange([...queries, newQuery]);
|
||||
const newSelected = new Set(selectedQueries);
|
||||
newSelected.add(queries.length);
|
||||
onSelectionChange(newSelected);
|
||||
};
|
||||
|
||||
return (
|
||||
<Accordion
|
||||
expanded={expanded}
|
||||
onChange={() => setExpanded(!expanded)}
|
||||
sx={{
|
||||
mb: 2,
|
||||
backgroundColor: '#ffffff',
|
||||
border: '1px solid #e5e7eb',
|
||||
'&:before': { display: 'none' },
|
||||
boxShadow: 'none',
|
||||
}}
|
||||
>
|
||||
<AccordionSummary
|
||||
expandIcon={<ExpandMoreIcon sx={{ color: '#666' }} />}
|
||||
sx={{
|
||||
backgroundColor: '#f9fafb',
|
||||
'&:hover': { backgroundColor: '#f3f4f6' },
|
||||
}}
|
||||
>
|
||||
<Box display="flex" alignItems="center" gap={1} flex={1}>
|
||||
<SearchIcon sx={{ color: '#666', fontSize: 20 }} />
|
||||
<Typography variant="subtitle2" fontWeight={600} color="#333">
|
||||
Research Queries ({queries.length})
|
||||
</Typography>
|
||||
<Chip
|
||||
size="small"
|
||||
label={`${selectedQueries.size} selected`}
|
||||
sx={{
|
||||
ml: 1,
|
||||
backgroundColor: '#e0f2fe',
|
||||
color: '#0369a1',
|
||||
fontSize: '0.7rem',
|
||||
height: 20,
|
||||
fontWeight: 500,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails sx={{ p: 0, backgroundColor: '#ffffff' }}>
|
||||
<List dense sx={{ backgroundColor: '#fafafa' }}>
|
||||
{queries.map((query, idx) => (
|
||||
<React.Fragment key={idx}>
|
||||
<QueryEditor
|
||||
query={query}
|
||||
index={idx}
|
||||
isSelected={selectedQueries.has(idx)}
|
||||
onToggle={() => handleQueryToggle(idx)}
|
||||
onEdit={(field, value) => handleQueryEdit(idx, field, value)}
|
||||
onDelete={() => handleDeleteQuery(idx)}
|
||||
/>
|
||||
{idx < queries.length - 1 && <Divider />}
|
||||
</React.Fragment>
|
||||
))}
|
||||
<Box sx={{ p: 1 }}>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onClick={handleAddQuery}
|
||||
sx={{
|
||||
borderStyle: 'dashed',
|
||||
borderColor: '#d1d5db',
|
||||
color: '#666',
|
||||
'&:hover': {
|
||||
borderColor: '#0ea5e9',
|
||||
backgroundColor: '#f0f9ff',
|
||||
},
|
||||
}}
|
||||
>
|
||||
+ Add Query
|
||||
</Button>
|
||||
</Box>
|
||||
</List>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* TrendsConfigSection Component
|
||||
*
|
||||
* Google Trends configuration section with keywords, expected insights, and settings.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Accordion,
|
||||
AccordionSummary,
|
||||
AccordionDetails,
|
||||
TextField,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Grid,
|
||||
Chip,
|
||||
Tooltip,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
ExpandMore as ExpandMoreIcon,
|
||||
TrendingUp as TrendIcon,
|
||||
CheckCircle as CheckIcon,
|
||||
Info as InfoIcon,
|
||||
} from '@mui/icons-material';
|
||||
import { TrendsConfig } from '../../../types/intent.types';
|
||||
|
||||
interface TrendsConfigSectionProps {
|
||||
trendsConfig: TrendsConfig;
|
||||
}
|
||||
|
||||
export const TrendsConfigSection: React.FC<TrendsConfigSectionProps> = ({
|
||||
trendsConfig,
|
||||
}) => {
|
||||
if (!trendsConfig.enabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Accordion
|
||||
defaultExpanded={true}
|
||||
sx={{
|
||||
mb: 2,
|
||||
backgroundColor: '#ffffff',
|
||||
border: '1px solid #e5e7eb',
|
||||
'&:before': { display: 'none' },
|
||||
boxShadow: 'none',
|
||||
}}
|
||||
>
|
||||
<AccordionSummary
|
||||
expandIcon={<ExpandMoreIcon sx={{ color: '#666' }} />}
|
||||
sx={{
|
||||
backgroundColor: '#f0fdf4',
|
||||
'&:hover': { backgroundColor: '#dcfce7' },
|
||||
}}
|
||||
>
|
||||
<Box display="flex" alignItems="center" gap={1} flex={1}>
|
||||
<TrendIcon sx={{ color: '#10b981', fontSize: 20 }} />
|
||||
<Typography variant="subtitle2" fontWeight={600} color="#333">
|
||||
Google Trends Analysis
|
||||
</Typography>
|
||||
<Chip
|
||||
size="small"
|
||||
label="Auto-enabled"
|
||||
sx={{
|
||||
ml: 1,
|
||||
backgroundColor: '#dcfce7',
|
||||
color: '#166534',
|
||||
fontSize: '0.7rem',
|
||||
height: 20,
|
||||
fontWeight: 500,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails sx={{ p: 2, backgroundColor: '#ffffff' }}>
|
||||
{/* Trends Keywords */}
|
||||
<Box mb={2}>
|
||||
<Typography variant="caption" color="#666" fontWeight={600} gutterBottom display="block">
|
||||
Trends Keywords
|
||||
</Typography>
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
value={trendsConfig.keywords.join(', ')}
|
||||
disabled
|
||||
helperText={trendsConfig.keywords_justification}
|
||||
sx={{
|
||||
backgroundColor: '#ffffff',
|
||||
'& .MuiOutlinedInput-root': {
|
||||
'&:hover fieldset': { borderColor: '#10b981' },
|
||||
'&.Mui-focused fieldset': { borderColor: '#10b981' },
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Expected Insights Preview */}
|
||||
{trendsConfig.expected_insights.length > 0 && (
|
||||
<Box mb={2}>
|
||||
<Typography variant="caption" color="#666" fontWeight={600} gutterBottom display="block">
|
||||
What Trends Will Uncover:
|
||||
</Typography>
|
||||
<List dense sx={{ backgroundColor: '#f9fafb', borderRadius: 1, p: 1 }}>
|
||||
{trendsConfig.expected_insights.map((insight, idx) => (
|
||||
<ListItem key={idx} sx={{ py: 0.5, px: 1 }}>
|
||||
<ListItemIcon sx={{ minWidth: 24 }}>
|
||||
<CheckIcon color="success" fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={insight}
|
||||
primaryTypographyProps={{ variant: 'caption', color: '#374151' }}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Settings with Justifications */}
|
||||
<Box
|
||||
sx={{
|
||||
p: 1.5,
|
||||
backgroundColor: '#f9fafb',
|
||||
borderRadius: 1,
|
||||
border: '1px solid #e5e7eb',
|
||||
}}
|
||||
>
|
||||
<Grid container spacing={1}>
|
||||
<Grid item xs={6}>
|
||||
<Typography variant="caption" color="#666" fontWeight={500} display="block" gutterBottom>
|
||||
Timeframe
|
||||
</Typography>
|
||||
<Box display="flex" alignItems="center" gap={0.5}>
|
||||
<Typography variant="body2" fontWeight={500} color="#333">
|
||||
{trendsConfig.timeframe}
|
||||
</Typography>
|
||||
<Tooltip title={trendsConfig.timeframe_justification} arrow>
|
||||
<InfoIcon sx={{ fontSize: 14, color: '#9ca3af', cursor: 'help' }} />
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid item xs={6}>
|
||||
<Typography variant="caption" color="#666" fontWeight={500} display="block" gutterBottom>
|
||||
Region
|
||||
</Typography>
|
||||
<Box display="flex" alignItems="center" gap={0.5}>
|
||||
<Typography variant="body2" fontWeight={500} color="#333">
|
||||
{trendsConfig.geo}
|
||||
</Typography>
|
||||
<Tooltip title={trendsConfig.geo_justification} arrow>
|
||||
<InfoIcon sx={{ fontSize: 14, color: '#9ca3af', cursor: 'help' }} />
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* DeliverablesSelector Component
|
||||
*
|
||||
* Allows users to select/remove expected deliverables.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Chip,
|
||||
Tooltip,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Info as InfoIcon,
|
||||
} from '@mui/icons-material';
|
||||
import {
|
||||
ResearchIntent,
|
||||
ExpectedDeliverable,
|
||||
DELIVERABLE_DISPLAY,
|
||||
} from '../../../../types/intent.types';
|
||||
|
||||
interface DeliverablesSelectorProps {
|
||||
intent: ResearchIntent;
|
||||
onToggle: (deliverable: ExpectedDeliverable) => void;
|
||||
}
|
||||
|
||||
export const DeliverablesSelector: React.FC<DeliverablesSelectorProps> = ({
|
||||
intent,
|
||||
onToggle,
|
||||
}) => {
|
||||
return (
|
||||
<Box
|
||||
mb={2}
|
||||
sx={{
|
||||
p: 2,
|
||||
backgroundColor: '#f9fafb',
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: 1,
|
||||
}}
|
||||
>
|
||||
<Box display="flex" alignItems="center" justifyContent="space-between" mb={1}>
|
||||
<Typography variant="caption" color="#666" fontWeight={600}>
|
||||
What I'll find for you:
|
||||
</Typography>
|
||||
<Tooltip title="Click chips to select/remove deliverables">
|
||||
<InfoIcon sx={{ fontSize: 14, color: '#9ca3af' }} />
|
||||
</Tooltip>
|
||||
</Box>
|
||||
<Box display="flex" flexWrap="wrap" gap={0.5}>
|
||||
{Object.entries(DELIVERABLE_DISPLAY).map(([key, label]) => {
|
||||
const isSelected = intent.expected_deliverables.includes(key as ExpectedDeliverable);
|
||||
return (
|
||||
<Chip
|
||||
key={key}
|
||||
label={label}
|
||||
size="small"
|
||||
onClick={() => onToggle(key as ExpectedDeliverable)}
|
||||
sx={{
|
||||
backgroundColor: isSelected ? '#dbeafe' : '#ffffff',
|
||||
border: `1px solid ${isSelected ? '#3b82f6' : '#d1d5db'}`,
|
||||
color: isSelected ? '#1e40af' : '#374151',
|
||||
cursor: 'pointer',
|
||||
'&:hover': {
|
||||
backgroundColor: isSelected ? '#bfdbfe' : '#f3f4f6',
|
||||
borderColor: isSelected ? '#2563eb' : '#9ca3af',
|
||||
},
|
||||
fontWeight: isSelected ? 600 : 400,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* IntentHeader Component
|
||||
*
|
||||
* Header section of IntentConfirmationPanel with title, confidence badge, and close button.
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Chip,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Psychology as BrainIcon,
|
||||
Close as CloseIcon,
|
||||
Info as InfoIcon,
|
||||
} from '@mui/icons-material';
|
||||
import { AnalyzeIntentResponse } from '../../../../types/intent.types';
|
||||
|
||||
interface IntentHeaderProps {
|
||||
intentAnalysis: AnalyzeIntentResponse;
|
||||
onDismiss: () => void;
|
||||
}
|
||||
|
||||
export const IntentHeader: React.FC<IntentHeaderProps> = ({
|
||||
intentAnalysis,
|
||||
onDismiss,
|
||||
}) => {
|
||||
const intent = intentAnalysis.intent;
|
||||
const confidence = intent.confidence;
|
||||
const isHighConfidence = confidence >= 0.8;
|
||||
const confidenceReason = (intentAnalysis as any).confidence_reason || '';
|
||||
const greatExample = (intentAnalysis as any).great_example || '';
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
backgroundColor: isHighConfidence ? '#f0f9ff' : '#fff7ed',
|
||||
borderBottom: '1px solid #e0e0e0',
|
||||
}}
|
||||
>
|
||||
<Box display="flex" alignItems="center" gap={1.5}>
|
||||
<BrainIcon sx={{ color: isHighConfidence ? '#10b981' : '#f59e0b' }} />
|
||||
<Box>
|
||||
<Typography variant="subtitle1" fontWeight={600} color="#333">
|
||||
Help ALwrity understand Your Research
|
||||
</Typography>
|
||||
<Typography variant="caption" color="#666">
|
||||
{intentAnalysis.analysis_summary}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box display="flex" alignItems="center" gap={1}>
|
||||
<Tooltip
|
||||
title={
|
||||
<Box sx={{ p: 1, maxWidth: 300 }}>
|
||||
<Typography variant="caption" fontWeight={600} display="block" gutterBottom>
|
||||
Confidence: {Math.round(confidence * 100)}%
|
||||
</Typography>
|
||||
{confidenceReason && (
|
||||
<Typography variant="caption" display="block" sx={{ mb: 1 }}>
|
||||
{confidenceReason}
|
||||
</Typography>
|
||||
)}
|
||||
{greatExample && (
|
||||
<>
|
||||
<Typography variant="caption" fontWeight={600} display="block" gutterBottom>
|
||||
Great example would be:
|
||||
</Typography>
|
||||
<Typography variant="caption" display="block">
|
||||
{greatExample}
|
||||
</Typography>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
}
|
||||
arrow
|
||||
>
|
||||
<Chip
|
||||
size="small"
|
||||
label={`${Math.round(confidence * 100)}% confident`}
|
||||
color={isHighConfidence ? 'success' : 'warning'}
|
||||
variant="outlined"
|
||||
sx={{
|
||||
backgroundColor: '#ffffff',
|
||||
cursor: 'help',
|
||||
}}
|
||||
icon={<InfoIcon fontSize="small" />}
|
||||
/>
|
||||
</Tooltip>
|
||||
<IconButton size="small" onClick={onDismiss} sx={{ color: '#666' }}>
|
||||
<CloseIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* IntentSummaryGrid Component
|
||||
*
|
||||
* Quick summary grid showing Purpose, Content Type, Depth, and Queries Count.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Grid,
|
||||
Card,
|
||||
CardContent,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
ResearchIntent,
|
||||
ResearchPurpose,
|
||||
ContentOutput,
|
||||
ResearchDepthLevel,
|
||||
PURPOSE_DISPLAY,
|
||||
CONTENT_OUTPUT_DISPLAY,
|
||||
DEPTH_DISPLAY,
|
||||
} from '../../../../types/intent.types';
|
||||
import { EditableField } from '../shared/EditableField';
|
||||
|
||||
interface IntentSummaryGridProps {
|
||||
intent: ResearchIntent;
|
||||
queriesCount: number;
|
||||
onUpdateField: <K extends keyof ResearchIntent>(field: K, value: ResearchIntent[K]) => void;
|
||||
}
|
||||
|
||||
export const IntentSummaryGrid: React.FC<IntentSummaryGridProps> = ({
|
||||
intent,
|
||||
queriesCount,
|
||||
onUpdateField,
|
||||
}) => {
|
||||
return (
|
||||
<Grid container spacing={2} mb={2}>
|
||||
{/* Purpose */}
|
||||
<Grid item xs={6} sm={3}>
|
||||
<Card variant="outlined" sx={{ height: '100%', backgroundColor: '#ffffff', border: '1px solid #e5e7eb' }}>
|
||||
<CardContent sx={{ py: 1.5, px: 1.5, '&:last-child': { pb: 1.5 } }}>
|
||||
<Typography variant="caption" color="#666" fontWeight={500} display="block" mb={0.5}>
|
||||
Purpose
|
||||
</Typography>
|
||||
<EditableField
|
||||
field="purpose"
|
||||
value={intent.purpose}
|
||||
displayValue={PURPOSE_DISPLAY[intent.purpose as ResearchPurpose] || intent.purpose}
|
||||
options={Object.entries(PURPOSE_DISPLAY).map(([key, label]) => ({ key, label }))}
|
||||
onSave={(val) => onUpdateField('purpose', val as ResearchPurpose)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
{/* Content Type */}
|
||||
<Grid item xs={6} sm={3}>
|
||||
<Card variant="outlined" sx={{ height: '100%', backgroundColor: '#ffffff', border: '1px solid #e5e7eb' }}>
|
||||
<CardContent sx={{ py: 1.5, px: 1.5, '&:last-child': { pb: 1.5 } }}>
|
||||
<Typography variant="caption" color="#666" fontWeight={500} display="block" mb={0.5}>
|
||||
Creating
|
||||
</Typography>
|
||||
<EditableField
|
||||
field="content_output"
|
||||
value={intent.content_output}
|
||||
displayValue={CONTENT_OUTPUT_DISPLAY[intent.content_output as ContentOutput] || intent.content_output}
|
||||
options={Object.entries(CONTENT_OUTPUT_DISPLAY).map(([key, label]) => ({ key, label }))}
|
||||
onSave={(val) => onUpdateField('content_output', val as ContentOutput)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
{/* Depth */}
|
||||
<Grid item xs={6} sm={3}>
|
||||
<Card variant="outlined" sx={{ height: '100%', backgroundColor: '#ffffff', border: '1px solid #e5e7eb' }}>
|
||||
<CardContent sx={{ py: 1.5, px: 1.5, '&:last-child': { pb: 1.5 } }}>
|
||||
<Typography variant="caption" color="#666" fontWeight={500} display="block" mb={0.5}>
|
||||
Depth
|
||||
</Typography>
|
||||
<EditableField
|
||||
field="depth"
|
||||
value={intent.depth}
|
||||
displayValue={DEPTH_DISPLAY[intent.depth as ResearchDepthLevel] || intent.depth}
|
||||
options={Object.entries(DEPTH_DISPLAY).map(([key, label]) => ({ key, label }))}
|
||||
onSave={(val) => onUpdateField('depth', val as ResearchDepthLevel)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
{/* Queries Count */}
|
||||
<Grid item xs={6} sm={3}>
|
||||
<Card variant="outlined" sx={{ height: '100%', backgroundColor: '#ffffff', border: '1px solid #e5e7eb' }}>
|
||||
<CardContent sx={{ py: 1.5, px: 1.5, '&:last-child': { pb: 1.5 } }}>
|
||||
<Typography variant="caption" color="#666" fontWeight={500} display="block" mb={0.5}>
|
||||
Queries
|
||||
</Typography>
|
||||
<Typography variant="body2" fontWeight={500} color="#333">
|
||||
{queriesCount} targeted
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* PrimaryQuestionEditor Component
|
||||
*
|
||||
* Editable primary question section.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
TextField,
|
||||
IconButton,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Edit as EditIcon,
|
||||
Save as SaveIcon,
|
||||
Cancel as CancelIcon,
|
||||
} from '@mui/icons-material';
|
||||
import { ResearchIntent } from '../../../../types/intent.types';
|
||||
|
||||
interface PrimaryQuestionEditorProps {
|
||||
intent: ResearchIntent;
|
||||
onUpdate: (value: string) => void;
|
||||
}
|
||||
|
||||
export const PrimaryQuestionEditor: React.FC<PrimaryQuestionEditorProps> = ({
|
||||
intent,
|
||||
onUpdate,
|
||||
}) => {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [value, setValue] = useState(intent.primary_question);
|
||||
|
||||
useEffect(() => {
|
||||
setValue(intent.primary_question);
|
||||
}, [intent.primary_question]);
|
||||
|
||||
const handleSave = () => {
|
||||
if (value.trim()) {
|
||||
onUpdate(value.trim());
|
||||
}
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setValue(intent.primary_question);
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
mb: 2,
|
||||
p: 2,
|
||||
backgroundColor: '#f0f9ff',
|
||||
border: '1px solid #bae6fd',
|
||||
borderRadius: 1,
|
||||
}}
|
||||
>
|
||||
<Box display="flex" alignItems="center" justifyContent="space-between" mb={1}>
|
||||
<Typography variant="caption" fontWeight={600} color="#0c4a6e">
|
||||
Main Question:
|
||||
</Typography>
|
||||
{!isEditing && (
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => setIsEditing(true)}
|
||||
sx={{
|
||||
color: '#666',
|
||||
'&:hover': {
|
||||
backgroundColor: '#e0f2fe',
|
||||
color: '#0ea5e9',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<EditIcon fontSize="small" />
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
{isEditing ? (
|
||||
<Box display="flex" alignItems="flex-start" gap={1}>
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline
|
||||
rows={2}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
sx={{ backgroundColor: '#ffffff' }}
|
||||
autoFocus
|
||||
/>
|
||||
<Box display="flex" flexDirection="column" gap={0.5}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={handleSave}
|
||||
color="primary"
|
||||
sx={{ backgroundColor: '#e0f2fe' }}
|
||||
>
|
||||
<SaveIcon fontSize="small" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={handleCancel}
|
||||
sx={{ color: '#666' }}
|
||||
>
|
||||
<CancelIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
) : (
|
||||
<Typography variant="body2" fontWeight={500} color="#0c4a6e">
|
||||
{intent.primary_question}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,284 @@
|
||||
/**
|
||||
* ResearchQueriesSection Component
|
||||
*
|
||||
* Manages research queries with selection, editing, adding, and deleting.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Accordion,
|
||||
AccordionSummary,
|
||||
AccordionDetails,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemSecondaryAction,
|
||||
Checkbox,
|
||||
TextField,
|
||||
FormControl,
|
||||
Select,
|
||||
MenuItem,
|
||||
Button,
|
||||
IconButton,
|
||||
Divider,
|
||||
Chip,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
ExpandMore as ExpandMoreIcon,
|
||||
Search as SearchIcon,
|
||||
Delete as DeleteIcon,
|
||||
} from '@mui/icons-material';
|
||||
import {
|
||||
ResearchQuery,
|
||||
ExpectedDeliverable,
|
||||
DELIVERABLE_DISPLAY,
|
||||
} from '../../../../types/intent.types';
|
||||
|
||||
interface ResearchQueriesSectionProps {
|
||||
queries: ResearchQuery[];
|
||||
onQueriesChange: (queries: ResearchQuery[]) => void;
|
||||
onSelectionChange: (selectedIndices: Set<number>) => void;
|
||||
}
|
||||
|
||||
export const ResearchQueriesSection: React.FC<ResearchQueriesSectionProps> = ({
|
||||
queries: initialQueries,
|
||||
onQueriesChange,
|
||||
onSelectionChange,
|
||||
}) => {
|
||||
const [showQueries, setShowQueries] = useState(true);
|
||||
const [editedQueries, setEditedQueries] = useState<ResearchQuery[]>(initialQueries);
|
||||
const [selectedQueries, setSelectedQueries] = useState<Set<number>>(
|
||||
new Set(initialQueries.map((_, idx) => idx))
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setEditedQueries(initialQueries);
|
||||
setSelectedQueries(new Set(initialQueries.map((_, idx) => idx)));
|
||||
}, [initialQueries]);
|
||||
|
||||
useEffect(() => {
|
||||
onQueriesChange(editedQueries);
|
||||
}, [editedQueries, onQueriesChange]);
|
||||
|
||||
useEffect(() => {
|
||||
onSelectionChange(selectedQueries);
|
||||
}, [selectedQueries, onSelectionChange]);
|
||||
|
||||
const handleQueryToggle = (index: number) => {
|
||||
const newSelected = new Set(selectedQueries);
|
||||
if (newSelected.has(index)) {
|
||||
newSelected.delete(index);
|
||||
} else {
|
||||
newSelected.add(index);
|
||||
}
|
||||
setSelectedQueries(newSelected);
|
||||
};
|
||||
|
||||
const handleQueryEdit = (index: number, field: keyof ResearchQuery, value: any) => {
|
||||
const updated = [...editedQueries];
|
||||
updated[index] = { ...updated[index], [field]: value };
|
||||
setEditedQueries(updated);
|
||||
};
|
||||
|
||||
const handleDeleteQuery = (index: number) => {
|
||||
const updated = editedQueries.filter((_, idx) => idx !== index);
|
||||
setEditedQueries(updated);
|
||||
const newSelected = new Set(selectedQueries);
|
||||
newSelected.delete(index);
|
||||
const adjusted = new Set<number>();
|
||||
newSelected.forEach(idx => {
|
||||
if (idx > index) {
|
||||
adjusted.add(idx - 1);
|
||||
} else if (idx < index) {
|
||||
adjusted.add(idx);
|
||||
}
|
||||
});
|
||||
setSelectedQueries(adjusted);
|
||||
};
|
||||
|
||||
const handleAddQuery = () => {
|
||||
const newQuery: ResearchQuery = {
|
||||
query: '',
|
||||
purpose: 'key_statistics',
|
||||
provider: 'exa',
|
||||
priority: 3,
|
||||
expected_results: '',
|
||||
};
|
||||
setEditedQueries([...editedQueries, newQuery]);
|
||||
setSelectedQueries(new Set([...selectedQueries, editedQueries.length]));
|
||||
};
|
||||
|
||||
return (
|
||||
<Accordion
|
||||
expanded={showQueries}
|
||||
onChange={() => setShowQueries(!showQueries)}
|
||||
sx={{
|
||||
mb: 2,
|
||||
backgroundColor: '#ffffff',
|
||||
border: '1px solid #e5e7eb',
|
||||
'&:before': { display: 'none' },
|
||||
boxShadow: 'none',
|
||||
}}
|
||||
>
|
||||
<AccordionSummary
|
||||
expandIcon={<ExpandMoreIcon sx={{ color: '#666' }} />}
|
||||
sx={{
|
||||
backgroundColor: '#f9fafb',
|
||||
'&:hover': { backgroundColor: '#f3f4f6' },
|
||||
}}
|
||||
>
|
||||
<Box display="flex" alignItems="center" gap={1} flex={1}>
|
||||
<SearchIcon sx={{ color: '#666', fontSize: 20 }} />
|
||||
<Typography variant="subtitle2" fontWeight={600} color="#333">
|
||||
Research Queries ({editedQueries.length})
|
||||
</Typography>
|
||||
<Chip
|
||||
size="small"
|
||||
label={`${selectedQueries.size} selected`}
|
||||
sx={{
|
||||
ml: 1,
|
||||
backgroundColor: '#e0f2fe',
|
||||
color: '#0369a1',
|
||||
fontSize: '0.7rem',
|
||||
height: 20,
|
||||
fontWeight: 500,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails sx={{ p: 0, backgroundColor: '#ffffff' }}>
|
||||
<List dense sx={{ backgroundColor: '#fafafa' }}>
|
||||
{editedQueries.map((query, idx) => (
|
||||
<React.Fragment key={idx}>
|
||||
<ListItem
|
||||
sx={{
|
||||
backgroundColor: selectedQueries.has(idx) ? '#e0f2fe' : '#ffffff',
|
||||
borderLeft: selectedQueries.has(idx) ? '3px solid #0ea5e9' : '3px solid transparent',
|
||||
'&:hover': { backgroundColor: selectedQueries.has(idx) ? '#bae6fd' : '#f9fafb' },
|
||||
py: 1.5,
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
checked={selectedQueries.has(idx)}
|
||||
onChange={() => handleQueryToggle(idx)}
|
||||
size="small"
|
||||
sx={{ mr: 1 }}
|
||||
/>
|
||||
<Box flex={1}>
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
value={query.query}
|
||||
onChange={(e) => handleQueryEdit(idx, 'query', e.target.value)}
|
||||
placeholder="Enter research query"
|
||||
sx={{
|
||||
mb: 1,
|
||||
backgroundColor: '#ffffff',
|
||||
'& .MuiOutlinedInput-root': {
|
||||
'&:hover fieldset': { borderColor: '#0ea5e9' },
|
||||
'&.Mui-focused fieldset': { borderColor: '#0ea5e9' },
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Box display="flex" gap={1} flexWrap="wrap">
|
||||
<FormControl size="small" sx={{ minWidth: 100 }}>
|
||||
<Select
|
||||
value={query.provider}
|
||||
onChange={(e) => handleQueryEdit(idx, 'provider', e.target.value)}
|
||||
sx={{
|
||||
backgroundColor: '#ffffff',
|
||||
fontSize: '0.75rem',
|
||||
}}
|
||||
>
|
||||
<MenuItem value="exa">Exa</MenuItem>
|
||||
<MenuItem value="tavily">Tavily</MenuItem>
|
||||
<MenuItem value="google">Google</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl size="small" sx={{ minWidth: 140 }}>
|
||||
<Select
|
||||
value={query.purpose}
|
||||
onChange={(e) => handleQueryEdit(idx, 'purpose', e.target.value)}
|
||||
sx={{
|
||||
backgroundColor: '#ffffff',
|
||||
fontSize: '0.75rem',
|
||||
}}
|
||||
>
|
||||
{Object.entries(DELIVERABLE_DISPLAY).map(([key, label]) => (
|
||||
<MenuItem key={key} value={key}>{label}</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<TextField
|
||||
size="small"
|
||||
type="number"
|
||||
value={query.priority}
|
||||
onChange={(e) => handleQueryEdit(idx, 'priority', parseInt(e.target.value) || 1)}
|
||||
inputProps={{ min: 1, max: 5 }}
|
||||
sx={{
|
||||
width: 90,
|
||||
backgroundColor: '#ffffff',
|
||||
fontSize: '0.75rem',
|
||||
}}
|
||||
label="Priority"
|
||||
/>
|
||||
</Box>
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
value={query.expected_results}
|
||||
onChange={(e) => handleQueryEdit(idx, 'expected_results', e.target.value)}
|
||||
placeholder="What we expect to find"
|
||||
sx={{
|
||||
mt: 1,
|
||||
backgroundColor: '#ffffff',
|
||||
fontSize: '0.75rem',
|
||||
'& .MuiOutlinedInput-root': {
|
||||
'&:hover fieldset': { borderColor: '#0ea5e9' },
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<ListItemSecondaryAction>
|
||||
<IconButton
|
||||
edge="end"
|
||||
size="small"
|
||||
onClick={() => handleDeleteQuery(idx)}
|
||||
sx={{
|
||||
color: '#dc2626',
|
||||
'&:hover': { backgroundColor: '#fee2e2' },
|
||||
}}
|
||||
>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</ListItemSecondaryAction>
|
||||
</ListItem>
|
||||
{idx < editedQueries.length - 1 && <Divider />}
|
||||
</React.Fragment>
|
||||
))}
|
||||
<ListItem>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onClick={handleAddQuery}
|
||||
sx={{
|
||||
mt: 1,
|
||||
borderStyle: 'dashed',
|
||||
borderColor: '#d1d5db',
|
||||
color: '#666',
|
||||
'&:hover': {
|
||||
borderColor: '#0ea5e9',
|
||||
backgroundColor: '#f0f9ff',
|
||||
},
|
||||
}}
|
||||
>
|
||||
+ Add Query
|
||||
</Button>
|
||||
</ListItem>
|
||||
</List>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* TrendsConfigSection Component
|
||||
*
|
||||
* Google Trends configuration section with keywords, expected insights, and settings.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Accordion,
|
||||
AccordionSummary,
|
||||
AccordionDetails,
|
||||
TextField,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Grid,
|
||||
Chip,
|
||||
Tooltip,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
ExpandMore as ExpandMoreIcon,
|
||||
TrendingUp as TrendIcon,
|
||||
CheckCircle as CheckIcon,
|
||||
Info as InfoIcon,
|
||||
} from '@mui/icons-material';
|
||||
import { AnalyzeIntentResponse } from '../../../../types/intent.types';
|
||||
|
||||
interface TrendsConfigSectionProps {
|
||||
trendsConfig: NonNullable<AnalyzeIntentResponse['trends_config']>;
|
||||
}
|
||||
|
||||
export const TrendsConfigSection: React.FC<TrendsConfigSectionProps> = ({
|
||||
trendsConfig,
|
||||
}) => {
|
||||
return (
|
||||
<Accordion
|
||||
defaultExpanded={true}
|
||||
sx={{
|
||||
mb: 2,
|
||||
backgroundColor: '#ffffff',
|
||||
border: '1px solid #e5e7eb',
|
||||
'&:before': { display: 'none' },
|
||||
boxShadow: 'none',
|
||||
}}
|
||||
>
|
||||
<AccordionSummary
|
||||
expandIcon={<ExpandMoreIcon sx={{ color: '#666' }} />}
|
||||
sx={{
|
||||
backgroundColor: '#f0fdf4',
|
||||
'&:hover': { backgroundColor: '#dcfce7' },
|
||||
}}
|
||||
>
|
||||
<Box display="flex" alignItems="center" gap={1} flex={1}>
|
||||
<TrendIcon sx={{ color: '#10b981', fontSize: 20 }} />
|
||||
<Typography variant="subtitle2" fontWeight={600} color="#333">
|
||||
Google Trends Analysis
|
||||
</Typography>
|
||||
<Chip
|
||||
size="small"
|
||||
label="Auto-enabled"
|
||||
sx={{
|
||||
ml: 1,
|
||||
backgroundColor: '#dcfce7',
|
||||
color: '#166534',
|
||||
fontSize: '0.7rem',
|
||||
height: 20,
|
||||
fontWeight: 500,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails sx={{ p: 2, backgroundColor: '#ffffff' }}>
|
||||
{/* Trends Keywords */}
|
||||
<Box mb={2}>
|
||||
<Typography variant="caption" color="#666" fontWeight={600} gutterBottom display="block">
|
||||
Trends Keywords
|
||||
</Typography>
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
value={trendsConfig.keywords.join(', ')}
|
||||
disabled
|
||||
helperText={trendsConfig.keywords_justification}
|
||||
sx={{
|
||||
backgroundColor: '#f9fafb',
|
||||
'& .MuiOutlinedInput-root': {
|
||||
'&:hover fieldset': { borderColor: '#10b981' },
|
||||
'&.Mui-focused fieldset': { borderColor: '#10b981' },
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Expected Insights Preview */}
|
||||
{trendsConfig.expected_insights.length > 0 && (
|
||||
<Box mb={2}>
|
||||
<Typography variant="caption" color="#666" fontWeight={600} gutterBottom display="block">
|
||||
What Trends Will Uncover:
|
||||
</Typography>
|
||||
<List dense sx={{ backgroundColor: '#f9fafb', borderRadius: 1, p: 1 }}>
|
||||
{trendsConfig.expected_insights.map((insight, idx) => (
|
||||
<ListItem key={idx} sx={{ py: 0.5, px: 1 }}>
|
||||
<ListItemIcon sx={{ minWidth: 24 }}>
|
||||
<CheckIcon color="success" fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={insight}
|
||||
primaryTypographyProps={{ variant: 'caption', color: '#374151' }}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Settings with Justifications */}
|
||||
<Box
|
||||
sx={{
|
||||
p: 1.5,
|
||||
backgroundColor: '#f9fafb',
|
||||
borderRadius: 1,
|
||||
border: '1px solid #e5e7eb',
|
||||
}}
|
||||
>
|
||||
<Grid container spacing={1}>
|
||||
<Grid item xs={6}>
|
||||
<Typography variant="caption" color="#666" fontWeight={500} display="block" gutterBottom>
|
||||
Timeframe
|
||||
</Typography>
|
||||
<Box display="flex" alignItems="center" gap={0.5}>
|
||||
<Typography variant="body2" fontWeight={500} color="#333">
|
||||
{trendsConfig.timeframe}
|
||||
</Typography>
|
||||
<Tooltip title={trendsConfig.timeframe_justification} arrow>
|
||||
<InfoIcon sx={{ fontSize: 14, color: '#9ca3af', cursor: 'help' }} />
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid item xs={6}>
|
||||
<Typography variant="caption" color="#666" fontWeight={500} display="block" gutterBottom>
|
||||
Region
|
||||
</Typography>
|
||||
<Box display="flex" alignItems="center" gap={0.5}>
|
||||
<Typography variant="body2" fontWeight={500} color="#333">
|
||||
{trendsConfig.geo}
|
||||
</Typography>
|
||||
<Tooltip title={trendsConfig.geo_justification} arrow>
|
||||
<InfoIcon sx={{ fontSize: 14, color: '#9ca3af', cursor: 'help' }} />
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* IntentConfirmationPanel Module
|
||||
*
|
||||
* Refactored modular components for intent confirmation panel.
|
||||
* Each component handles a specific responsibility.
|
||||
*/
|
||||
|
||||
export { IntentConfirmationPanel } from './IntentConfirmationPanel';
|
||||
export type { IntentConfirmationPanelProps } from './IntentConfirmationPanel';
|
||||
|
||||
// Export sub-components for potential reuse
|
||||
export { LoadingState } from './LoadingState';
|
||||
export { EditableField } from './EditableField';
|
||||
export { IntentConfirmationHeader } from './IntentConfirmationHeader';
|
||||
export { PrimaryQuestionEditor } from './PrimaryQuestionEditor';
|
||||
export { IntentSummaryGrid } from './IntentSummaryGrid';
|
||||
export { DeliverablesSelector } from './DeliverablesSelector';
|
||||
export { QueryEditor } from './QueryEditor';
|
||||
export { ResearchQueriesSection } from './ResearchQueriesSection';
|
||||
export { TrendsConfigSection } from './TrendsConfigSection';
|
||||
export { AdvancedProviderOptionsSection } from './AdvancedProviderOptionsSection';
|
||||
export { ExpandableDetails } from './ExpandableDetails';
|
||||
export { ActionButtons } from './ActionButtons';
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* EditableField Component
|
||||
*
|
||||
* Reusable component for inline editing of fields with save/cancel functionality.
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
IconButton,
|
||||
TextField,
|
||||
FormControl,
|
||||
Select,
|
||||
MenuItem,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Edit as EditIcon,
|
||||
Save as SaveIcon,
|
||||
Cancel as CancelIcon,
|
||||
} from '@mui/icons-material';
|
||||
|
||||
interface EditableFieldProps {
|
||||
field: string;
|
||||
value: any;
|
||||
displayValue: string;
|
||||
options?: Array<{ key: string; label: string }>;
|
||||
onSave: (newValue: any) => void;
|
||||
}
|
||||
|
||||
export const EditableField: React.FC<EditableFieldProps> = ({
|
||||
field,
|
||||
value,
|
||||
displayValue,
|
||||
options,
|
||||
onSave,
|
||||
}) => {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editValue, setEditValue] = useState(value);
|
||||
|
||||
const handleSave = () => {
|
||||
onSave(editValue);
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setEditValue(value);
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box display="flex" alignItems="center" gap={0.5}>
|
||||
{isEditing ? (
|
||||
<Box display="flex" alignItems="center" gap={0.5} flex={1}>
|
||||
{options ? (
|
||||
<FormControl size="small" fullWidth>
|
||||
<Select
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
sx={{ backgroundColor: '#ffffff' }}
|
||||
autoFocus
|
||||
>
|
||||
{options.map(opt => (
|
||||
<MenuItem key={opt.key} value={opt.key}>{opt.label}</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
) : (
|
||||
<TextField
|
||||
size="small"
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
fullWidth
|
||||
sx={{ backgroundColor: '#ffffff' }}
|
||||
autoFocus
|
||||
/>
|
||||
)}
|
||||
<IconButton size="small" onClick={handleSave} color="primary">
|
||||
<SaveIcon fontSize="small" />
|
||||
</IconButton>
|
||||
<IconButton size="small" onClick={handleCancel} color="inherit">
|
||||
<CancelIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
) : (
|
||||
<>
|
||||
<Typography variant="body2" fontWeight={500} color="#333" sx={{ flex: 1 }}>
|
||||
{displayValue}
|
||||
</Typography>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => setIsEditing(true)}
|
||||
sx={{
|
||||
color: '#666',
|
||||
'&:hover': {
|
||||
backgroundColor: '#f3f4f6',
|
||||
color: '#0ea5e9',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<EditIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* LoadingState Component
|
||||
*
|
||||
* Loading indicator for intent analysis.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Paper,
|
||||
CircularProgress,
|
||||
} from '@mui/material';
|
||||
|
||||
export const LoadingState: React.FC = () => {
|
||||
return (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 3,
|
||||
mt: 2,
|
||||
borderRadius: 2,
|
||||
border: '1px solid #e0e0e0',
|
||||
backgroundColor: '#ffffff',
|
||||
}}
|
||||
>
|
||||
<Box display="flex" alignItems="center" gap={2}>
|
||||
<CircularProgress size={24} />
|
||||
<Box>
|
||||
<Typography variant="subtitle1" fontWeight={600} color="#333">
|
||||
🧠 Analyzing your research intent...
|
||||
</Typography>
|
||||
<Typography variant="body2" color="#666">
|
||||
AI is understanding what you want to accomplish
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
@@ -26,6 +26,12 @@ import {
|
||||
AccordionSummary,
|
||||
AccordionDetails,
|
||||
Paper,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
CheckCircle as CheckIcon,
|
||||
@@ -37,18 +43,27 @@ import {
|
||||
OpenInNew as OpenIcon,
|
||||
ExpandMore as ExpandMoreIcon,
|
||||
Warning as WarningIcon,
|
||||
Public as PublicIcon,
|
||||
Search as SearchIcon,
|
||||
ArrowUpward as ArrowUpIcon,
|
||||
ArrowDownward as ArrowDownIcon,
|
||||
} from '@mui/icons-material';
|
||||
import {
|
||||
IntentDrivenResearchResponse,
|
||||
DELIVERABLE_DISPLAY,
|
||||
} from '../../types/intent.types';
|
||||
import { TrendsChart } from './TrendsChart';
|
||||
import { TrendsExport } from './TrendsExport';
|
||||
|
||||
interface IntentResultsDisplayProps {
|
||||
result: IntentDrivenResearchResponse;
|
||||
hideHeader?: boolean;
|
||||
}
|
||||
|
||||
export const IntentResultsDisplay: React.FC<IntentResultsDisplayProps> = ({ result }) => {
|
||||
export const IntentResultsDisplay: React.FC<IntentResultsDisplayProps> = ({ result, hideHeader = false }) => {
|
||||
const [tabIndex, setTabIndex] = useState(0);
|
||||
const [topicsTabIndex, setTopicsTabIndex] = useState(0);
|
||||
const [queriesTabIndex, setQueriesTabIndex] = useState(0);
|
||||
|
||||
// Build available tabs based on what we have
|
||||
const tabs = [
|
||||
@@ -316,49 +331,299 @@ export const IntentResultsDisplay: React.FC<IntentResultsDisplayProps> = ({ resu
|
||||
|
||||
{/* Trends Tab */}
|
||||
{currentTab === 'trends' && (
|
||||
<Grid container spacing={2}>
|
||||
{result.trends.map((trend, idx) => (
|
||||
<Grid item xs={12} md={6} key={idx}>
|
||||
<Card variant="outlined" sx={{ height: '100%' }}>
|
||||
<CardContent>
|
||||
<Box display="flex" alignItems="center" gap={1} mb={1}>
|
||||
<TrendIcon
|
||||
color={trend.direction === 'growing' ? 'success' : trend.direction === 'declining' ? 'error' : 'info'}
|
||||
/>
|
||||
<Typography variant="subtitle1" fontWeight={500}>
|
||||
{trend.trend}
|
||||
<Box>
|
||||
{/* Google Trends Data Section */}
|
||||
{result.google_trends_data && (
|
||||
<Box mb={3}>
|
||||
<Box display="flex" alignItems="center" justifyContent="space-between" mb={2}>
|
||||
<Typography variant="h6" sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<TrendIcon color="primary" />
|
||||
Google Trends Analysis
|
||||
</Typography>
|
||||
<TrendsExport
|
||||
trendsData={result.google_trends_data}
|
||||
aiTrends={result.trends}
|
||||
keywords={result.google_trends_data.keywords}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Interest Over Time - Advanced Chart */}
|
||||
{result.google_trends_data.interest_over_time.length > 0 && (
|
||||
<Card variant="outlined" sx={{ mb: 2 }}>
|
||||
<CardContent>
|
||||
<Box display="flex" alignItems="center" justifyContent="space-between" mb={2}>
|
||||
<Typography variant="subtitle2" fontWeight={600}>
|
||||
Interest Over Time
|
||||
</Typography>
|
||||
<Chip
|
||||
size="small"
|
||||
label={`${result.google_trends_data.timeframe} • ${result.google_trends_data.geo}`}
|
||||
sx={{ backgroundColor: '#f0f9ff', color: '#0369a1' }}
|
||||
/>
|
||||
</Box>
|
||||
<Box data-trends-chart>
|
||||
<TrendsChart
|
||||
data={result.google_trends_data}
|
||||
height={300}
|
||||
/>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Interest by Region */}
|
||||
{result.google_trends_data.interest_by_region.length > 0 && (
|
||||
<Card variant="outlined" sx={{ mb: 2 }}>
|
||||
<CardContent>
|
||||
<Typography variant="subtitle2" fontWeight={600} gutterBottom sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<PublicIcon fontSize="small" />
|
||||
Interest by Region
|
||||
</Typography>
|
||||
<Chip
|
||||
size="small"
|
||||
label={trend.direction}
|
||||
color={trend.direction === 'growing' ? 'success' : trend.direction === 'declining' ? 'error' : 'info'}
|
||||
/>
|
||||
</Box>
|
||||
{trend.impact && (
|
||||
<Typography variant="body2" color="text.secondary" mb={1}>
|
||||
Impact: {trend.impact}
|
||||
<TableContainer>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Region</TableCell>
|
||||
<TableCell align="right">Interest</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{result.google_trends_data.interest_by_region.slice(0, 10).map((region: any, idx: number) => {
|
||||
const geoKey = Object.keys(region).find(k => k.includes('geo') || k.includes('name'));
|
||||
const regionName = region.geoName || (geoKey ? region[geoKey] : null) || 'Unknown';
|
||||
const value = Object.values(region).find(v => typeof v === 'number' && v !== null) as number || 0;
|
||||
|
||||
return (
|
||||
<TableRow key={idx}>
|
||||
<TableCell>{regionName}</TableCell>
|
||||
<TableCell align="right">
|
||||
<Box display="flex" alignItems="center" justifyContent="flex-end" gap={1}>
|
||||
<Box
|
||||
sx={{
|
||||
width: 60,
|
||||
height: 8,
|
||||
backgroundColor: '#e5e7eb',
|
||||
borderRadius: 1,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
width: `${value}%`,
|
||||
height: '100%',
|
||||
backgroundColor: '#10b981',
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Typography variant="body2" fontWeight={500}>
|
||||
{value}
|
||||
</Typography>
|
||||
</Box>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Related Topics */}
|
||||
{(result.google_trends_data.related_topics.top.length > 0 || result.google_trends_data.related_topics.rising.length > 0) && (
|
||||
<Card variant="outlined" sx={{ mb: 2 }}>
|
||||
<CardContent>
|
||||
<Typography variant="subtitle2" fontWeight={600} gutterBottom>
|
||||
Related Topics
|
||||
</Typography>
|
||||
)}
|
||||
{trend.timeline && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Timeline: {trend.timeline}
|
||||
<Tabs
|
||||
value={topicsTabIndex}
|
||||
onChange={(_, newValue) => setTopicsTabIndex(newValue)}
|
||||
sx={{ mb: 2, borderBottom: 1, borderColor: 'divider' }}
|
||||
>
|
||||
<Tab label={`Top (${result.google_trends_data.related_topics.top.length})`} />
|
||||
<Tab label={`Rising (${result.google_trends_data.related_topics.rising.length})`} />
|
||||
</Tabs>
|
||||
{topicsTabIndex === 0 && (
|
||||
<Box display="flex" flexWrap="wrap" gap={1}>
|
||||
{result.google_trends_data.related_topics.top.slice(0, 15).map((topic: any, idx: number) => {
|
||||
const topicTitle = topic.topic_title || topic.title || topic[Object.keys(topic)[0]] || 'Unknown';
|
||||
const value = topic.value || '';
|
||||
return (
|
||||
<Chip
|
||||
key={idx}
|
||||
label={value ? `${topicTitle} (${value})` : topicTitle}
|
||||
size="small"
|
||||
sx={{ backgroundColor: '#e0f2fe', color: '#0369a1' }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
{topicsTabIndex === 1 && (
|
||||
<Box display="flex" flexWrap="wrap" gap={1}>
|
||||
{result.google_trends_data.related_topics.rising.slice(0, 15).map((topic: any, idx: number) => {
|
||||
const topicTitle = topic.topic_title || topic.title || topic[Object.keys(topic)[0]] || 'Unknown';
|
||||
const value = topic.value || '';
|
||||
return (
|
||||
<Chip
|
||||
key={idx}
|
||||
label={value ? `${topicTitle} (${value})` : topicTitle}
|
||||
size="small"
|
||||
icon={<ArrowUpIcon />}
|
||||
sx={{ backgroundColor: '#dcfce7', color: '#166534' }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Related Queries */}
|
||||
{(result.google_trends_data.related_queries.top.length > 0 || result.google_trends_data.related_queries.rising.length > 0) && (
|
||||
<Card variant="outlined" sx={{ mb: 2 }}>
|
||||
<CardContent>
|
||||
<Typography variant="subtitle2" fontWeight={600} gutterBottom sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<SearchIcon fontSize="small" />
|
||||
Related Queries
|
||||
</Typography>
|
||||
)}
|
||||
<Box mt={1}>
|
||||
<Typography variant="caption" color="text.secondary">Evidence:</Typography>
|
||||
<List dense>
|
||||
{trend.evidence.slice(0, 3).map((e, i) => (
|
||||
<ListItem key={i} sx={{ py: 0, pl: 1 }}>
|
||||
<ListItemText primary={`• ${e}`} primaryTypographyProps={{ variant: 'caption' }} />
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
<Tabs
|
||||
value={queriesTabIndex}
|
||||
onChange={(_, newValue) => setQueriesTabIndex(newValue)}
|
||||
sx={{ mb: 2, borderBottom: 1, borderColor: 'divider' }}
|
||||
>
|
||||
<Tab label={`Top (${result.google_trends_data.related_queries.top.length})`} />
|
||||
<Tab label={`Rising (${result.google_trends_data.related_queries.rising.length})`} />
|
||||
</Tabs>
|
||||
{queriesTabIndex === 0 && (
|
||||
<List dense>
|
||||
{result.google_trends_data.related_queries.top.slice(0, 15).map((query: any, idx: number) => {
|
||||
const queryText = query.query || query[Object.keys(query)[0]] || 'Unknown';
|
||||
return (
|
||||
<ListItem key={idx} sx={{ py: 0.5, '&:hover': { backgroundColor: '#f9fafb' } }}>
|
||||
<ListItemText
|
||||
primary={queryText}
|
||||
primaryTypographyProps={{ variant: 'body2' }}
|
||||
/>
|
||||
</ListItem>
|
||||
);
|
||||
})}
|
||||
</List>
|
||||
)}
|
||||
{queriesTabIndex === 1 && (
|
||||
<List dense>
|
||||
{result.google_trends_data.related_queries.rising.slice(0, 15).map((query: any, idx: number) => {
|
||||
const queryText = query.query || query[Object.keys(query)[0]] || 'Unknown';
|
||||
return (
|
||||
<ListItem key={idx} sx={{ py: 0.5, '&:hover': { backgroundColor: '#f9fafb' } }}>
|
||||
<ListItemIcon sx={{ minWidth: 24 }}>
|
||||
<ArrowUpIcon color="success" fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={queryText}
|
||||
primaryTypographyProps={{ variant: 'body2' }}
|
||||
/>
|
||||
</ListItem>
|
||||
);
|
||||
})}
|
||||
</List>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* AI-Extracted Trends */}
|
||||
{result.trends.length > 0 && (
|
||||
<Box>
|
||||
<Typography variant="h6" gutterBottom sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<IdeaIcon color="primary" />
|
||||
AI-Extracted Trends
|
||||
</Typography>
|
||||
<Grid container spacing={2}>
|
||||
{result.trends.map((trend, idx) => (
|
||||
<Grid item xs={12} md={6} key={idx}>
|
||||
<Card variant="outlined" sx={{ height: '100%' }}>
|
||||
<CardContent>
|
||||
<Box display="flex" alignItems="center" gap={1} mb={1}>
|
||||
<TrendIcon
|
||||
color={trend.direction === 'growing' ? 'success' : trend.direction === 'declining' ? 'error' : 'info'}
|
||||
/>
|
||||
<Typography variant="subtitle1" fontWeight={500}>
|
||||
{trend.trend}
|
||||
</Typography>
|
||||
<Chip
|
||||
size="small"
|
||||
label={trend.direction}
|
||||
color={trend.direction === 'growing' ? 'success' : trend.direction === 'declining' ? 'error' : 'info'}
|
||||
/>
|
||||
{trend.interest_score !== undefined && (
|
||||
<Chip
|
||||
size="small"
|
||||
label={`Interest: ${Math.round(trend.interest_score)}`}
|
||||
sx={{ backgroundColor: '#fef3c7', color: '#92400e' }}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
{trend.impact && (
|
||||
<Typography variant="body2" color="text.secondary" mb={1}>
|
||||
Impact: {trend.impact}
|
||||
</Typography>
|
||||
)}
|
||||
{trend.timeline && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Timeline: {trend.timeline}
|
||||
</Typography>
|
||||
)}
|
||||
{trend.regional_interest && Object.keys(trend.regional_interest).length > 0 && (
|
||||
<Box mt={1}>
|
||||
<Typography variant="caption" color="text.secondary" display="block" gutterBottom>
|
||||
Top Regions:
|
||||
</Typography>
|
||||
<Box display="flex" flexWrap="wrap" gap={0.5}>
|
||||
{Object.entries(trend.regional_interest)
|
||||
.sort(([, a], [, b]) => b - a)
|
||||
.slice(0, 5)
|
||||
.map(([region, score]) => (
|
||||
<Chip
|
||||
key={region}
|
||||
label={`${region}: ${Math.round(score)}`}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
<Box mt={1}>
|
||||
<Typography variant="caption" color="text.secondary">Evidence:</Typography>
|
||||
<List dense>
|
||||
{trend.evidence.slice(0, 3).map((e, i) => (
|
||||
<ListItem key={i} sx={{ py: 0, pl: 1 }}>
|
||||
<ListItemText primary={`• ${e}`} primaryTypographyProps={{ variant: 'caption' }} />
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* No trends message */}
|
||||
{result.trends.length === 0 && !result.google_trends_data && (
|
||||
<Alert severity="info">
|
||||
No trends data available. Trends will appear here when your research includes trend analysis.
|
||||
</Alert>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Sources Tab */}
|
||||
|
||||
@@ -1,19 +1,31 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Tooltip, CircularProgress } from '@mui/material';
|
||||
import { Psychology as BrainIcon, Settings as SettingsIcon, Info as InfoIcon } from '@mui/icons-material';
|
||||
|
||||
interface ResearchInputContainerProps {
|
||||
keywords: string[];
|
||||
placeholder: string;
|
||||
onKeywordsChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void;
|
||||
// New props for unified intent & options
|
||||
onIntentAndOptions?: () => void;
|
||||
isAnalyzingIntent?: boolean;
|
||||
hasIntentAnalysis?: boolean;
|
||||
intentConfidence?: number;
|
||||
}
|
||||
|
||||
export const ResearchInputContainer: React.FC<ResearchInputContainerProps> = ({
|
||||
keywords,
|
||||
placeholder,
|
||||
onKeywordsChange,
|
||||
onIntentAndOptions,
|
||||
isAnalyzingIntent = false,
|
||||
hasIntentAnalysis = false,
|
||||
intentConfidence = 0,
|
||||
}) => {
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [wordCount, setWordCount] = useState(0);
|
||||
const MAX_WORDS = 1000;
|
||||
const MIN_WORDS_FOR_INTENT = 2; // Enable button after 2+ words
|
||||
|
||||
// Initialize input value from keywords only on mount or when keywords are cleared
|
||||
useEffect(() => {
|
||||
@@ -112,17 +124,96 @@ export const ResearchInputContainer: React.FC<ResearchInputContainerProps> = ({
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Word count indicator */}
|
||||
{/* Bottom bar with word count and Intent & Options button */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingTop: '8px',
|
||||
fontSize: '12px',
|
||||
color: wordCount >= MAX_WORDS ? '#ef4444' : '#64748b',
|
||||
fontWeight: wordCount >= MAX_WORDS ? '600' : '400',
|
||||
paddingTop: '12px',
|
||||
borderTop: '1px solid rgba(14, 165, 233, 0.1)',
|
||||
marginTop: '8px',
|
||||
}}>
|
||||
{wordCount} / {MAX_WORDS} words
|
||||
{/* Word count indicator */}
|
||||
<div style={{
|
||||
fontSize: '12px',
|
||||
color: wordCount >= MAX_WORDS ? '#ef4444' : '#64748b',
|
||||
fontWeight: wordCount >= MAX_WORDS ? '600' : '400',
|
||||
}}>
|
||||
{wordCount} / {MAX_WORDS} words
|
||||
</div>
|
||||
|
||||
{/* Intent & Options Button */}
|
||||
<Tooltip
|
||||
title={
|
||||
wordCount < MIN_WORDS_FOR_INTENT
|
||||
? `Enter at least ${MIN_WORDS_FOR_INTENT} words to analyze intent`
|
||||
: hasIntentAnalysis
|
||||
? `Intent analyzed with ${Math.round(intentConfidence * 100)}% confidence. Click to re-analyze.`
|
||||
: 'Let AI understand what you want to accomplish and configure optimal settings'
|
||||
}
|
||||
arrow
|
||||
placement="top"
|
||||
>
|
||||
<span>
|
||||
<button
|
||||
onClick={onIntentAndOptions}
|
||||
disabled={wordCount < MIN_WORDS_FOR_INTENT || isAnalyzingIntent}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
padding: '8px 14px',
|
||||
background: wordCount >= MIN_WORDS_FOR_INTENT && !isAnalyzingIntent
|
||||
? hasIntentAnalysis
|
||||
? 'linear-gradient(135deg, #10b981 0%, #059669 100%)'
|
||||
: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)'
|
||||
: 'rgba(100, 116, 139, 0.15)',
|
||||
color: wordCount >= MIN_WORDS_FOR_INTENT && !isAnalyzingIntent ? 'white' : '#94a3b8',
|
||||
border: 'none',
|
||||
borderRadius: '10px',
|
||||
cursor: wordCount >= MIN_WORDS_FOR_INTENT && !isAnalyzingIntent ? 'pointer' : 'not-allowed',
|
||||
fontSize: '13px',
|
||||
fontWeight: '600',
|
||||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
boxShadow: wordCount >= MIN_WORDS_FOR_INTENT && !isAnalyzingIntent
|
||||
? '0 2px 8px rgba(102, 126, 234, 0.3)'
|
||||
: 'none',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (wordCount >= MIN_WORDS_FOR_INTENT && !isAnalyzingIntent) {
|
||||
e.currentTarget.style.transform = 'translateY(-1px)';
|
||||
e.currentTarget.style.boxShadow = '0 4px 12px rgba(102, 126, 234, 0.4)';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (wordCount >= MIN_WORDS_FOR_INTENT && !isAnalyzingIntent) {
|
||||
e.currentTarget.style.transform = 'translateY(0)';
|
||||
e.currentTarget.style.boxShadow = '0 2px 8px rgba(102, 126, 234, 0.3)';
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isAnalyzingIntent ? (
|
||||
<>
|
||||
<CircularProgress size={14} sx={{ color: 'inherit' }} />
|
||||
Analyzing...
|
||||
</>
|
||||
) : hasIntentAnalysis ? (
|
||||
<>
|
||||
<BrainIcon sx={{ fontSize: 16 }} />
|
||||
✓ Intent Ready
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<BrainIcon sx={{ fontSize: 16 }} />
|
||||
Intent & Options
|
||||
<Tooltip title="AI analyzes your research goals and configures optimal Exa/Tavily settings automatically" arrow>
|
||||
<InfoIcon sx={{ fontSize: 12, opacity: 0.7 }} />
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* ResearchInputHeader Component
|
||||
*
|
||||
* Header section with title, personalization indicator, and action buttons (Advanced toggle, Upload)
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { PersonalizationIndicator } from './PersonalizationIndicator';
|
||||
|
||||
interface ResearchInputHeaderProps {
|
||||
hasPersona: boolean;
|
||||
advanced: boolean;
|
||||
onAdvancedChange: (advanced: boolean) => void;
|
||||
onFileUpload: () => void;
|
||||
}
|
||||
|
||||
export const ResearchInputHeader: React.FC<ResearchInputHeaderProps> = ({
|
||||
hasPersona,
|
||||
advanced,
|
||||
onAdvancedChange,
|
||||
onFileUpload,
|
||||
}) => {
|
||||
return (
|
||||
<div style={{
|
||||
marginBottom: '12px',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
}}>
|
||||
<label style={{
|
||||
fontSize: '15px',
|
||||
fontWeight: '600',
|
||||
color: '#0c4a6e',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
flex: '1',
|
||||
}}>
|
||||
<span style={{ fontSize: '20px' }}>🔍</span>
|
||||
Research Topic & Keywords
|
||||
<PersonalizationIndicator
|
||||
type="placeholder"
|
||||
hasPersona={hasPersona}
|
||||
source={hasPersona ? "from your research persona" : undefined}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{/* Advanced Toggle and Upload Button */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
}}>
|
||||
{/* Advanced Toggle */}
|
||||
<label
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
cursor: 'pointer',
|
||||
padding: '6px 10px',
|
||||
borderRadius: '8px',
|
||||
border: `1px solid ${advanced ? 'rgba(14, 165, 233, 0.3)' : 'rgba(15, 23, 42, 0.1)'}`,
|
||||
background: advanced
|
||||
? 'linear-gradient(135deg, rgba(14, 165, 233, 0.1) 0%, rgba(59, 130, 246, 0.1) 100%)'
|
||||
: '#ffffff',
|
||||
transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
fontSize: '11px',
|
||||
fontWeight: '600',
|
||||
color: advanced ? '#0369a1' : '#475569',
|
||||
boxShadow: advanced ? '0 1px 3px rgba(14, 165, 233, 0.12)' : '0 1px 2px rgba(0, 0, 0, 0.04)',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = advanced ? 'rgba(14, 165, 233, 0.4)' : 'rgba(15, 23, 42, 0.15)';
|
||||
e.currentTarget.style.boxShadow = advanced
|
||||
? '0 2px 4px rgba(14, 165, 233, 0.18)'
|
||||
: '0 1px 3px rgba(0, 0, 0, 0.06)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = advanced ? 'rgba(14, 165, 233, 0.3)' : 'rgba(15, 23, 42, 0.1)';
|
||||
e.currentTarget.style.boxShadow = advanced
|
||||
? '0 1px 3px rgba(14, 165, 233, 0.12)'
|
||||
: '0 1px 2px rgba(0, 0, 0, 0.04)';
|
||||
}}
|
||||
title="Enable advanced research options (Exa and Tavily configurations)"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={advanced}
|
||||
onChange={(e) => onAdvancedChange(e.target.checked)}
|
||||
style={{
|
||||
width: '14px',
|
||||
height: '14px',
|
||||
cursor: 'pointer',
|
||||
accentColor: '#0ea5e9',
|
||||
}}
|
||||
/>
|
||||
<span>Advanced</span>
|
||||
</label>
|
||||
|
||||
{/* Upload Button */}
|
||||
<button
|
||||
onClick={onFileUpload}
|
||||
type="button"
|
||||
style={{
|
||||
padding: '6px 10px',
|
||||
background: 'linear-gradient(135deg, rgba(14, 165, 233, 0.1) 0%, rgba(59, 130, 246, 0.1) 100%)',
|
||||
border: '1px solid rgba(14, 165, 233, 0.25)',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '11px',
|
||||
fontWeight: '600',
|
||||
color: '#0369a1',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '5px',
|
||||
transition: 'all 0.25s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
boxShadow: '0 1px 2px rgba(14, 165, 233, 0.12)',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'linear-gradient(135deg, rgba(14, 165, 233, 0.15) 0%, rgba(59, 130, 246, 0.15) 100%)';
|
||||
e.currentTarget.style.borderColor = 'rgba(14, 165, 233, 0.35)';
|
||||
e.currentTarget.style.transform = 'translateY(-1px)';
|
||||
e.currentTarget.style.boxShadow = '0 2px 4px rgba(14, 165, 233, 0.18)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'linear-gradient(135deg, rgba(14, 165, 233, 0.1) 0%, rgba(59, 130, 246, 0.1) 100%)';
|
||||
e.currentTarget.style.borderColor = 'rgba(14, 165, 233, 0.25)';
|
||||
e.currentTarget.style.transform = 'translateY(0)';
|
||||
e.currentTarget.style.boxShadow = '0 1px 2px rgba(14, 165, 233, 0.12)';
|
||||
}}
|
||||
title="Upload Document"
|
||||
>
|
||||
<span style={{ fontSize: '13px' }}>📎</span>
|
||||
<span>Upload</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* SmartResearchInfo Component
|
||||
*
|
||||
* Tooltip/modal explaining what Smart Research does and why it's useful.
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Tooltip,
|
||||
IconButton,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogContentText,
|
||||
DialogActions,
|
||||
Button,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Chip,
|
||||
Divider,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Info as InfoIcon,
|
||||
Psychology as BrainIcon,
|
||||
CheckCircle as CheckIcon,
|
||||
TrendingUp as TrendIcon,
|
||||
FormatQuote as QuoteIcon,
|
||||
BarChart as StatsIcon,
|
||||
School as CaseStudyIcon,
|
||||
Close as CloseIcon,
|
||||
} from '@mui/icons-material';
|
||||
|
||||
interface SmartResearchInfoProps {
|
||||
variant?: 'tooltip' | 'button' | 'icon';
|
||||
}
|
||||
|
||||
export const SmartResearchInfo: React.FC<SmartResearchInfoProps> = ({ variant = 'icon' }) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const handleOpen = () => setOpen(true);
|
||||
const handleClose = () => setOpen(false);
|
||||
|
||||
const tooltipContent = (
|
||||
<Box sx={{ maxWidth: 400 }}>
|
||||
<Typography variant="subtitle2" fontWeight={600} gutterBottom>
|
||||
🧠 Smart Research
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
AI understands what you want to accomplish and finds exactly what you need:
|
||||
statistics, expert quotes, case studies, trends, and more — all organized
|
||||
by deliverable type instead of generic search results.
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
|
||||
if (variant === 'tooltip') {
|
||||
return (
|
||||
<>
|
||||
<Tooltip title={tooltipContent} arrow placement="top">
|
||||
<IconButton size="small" onClick={handleOpen} sx={{ ml: 1 }}>
|
||||
<InfoIcon fontSize="small" color="primary" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Dialog open={open} onClose={handleClose} maxWidth="md" fullWidth>
|
||||
<DialogTitle>
|
||||
<Box display="flex" alignItems="center" gap={1}>
|
||||
<BrainIcon color="primary" />
|
||||
<Typography variant="h6">What is Smart Research?</Typography>
|
||||
</Box>
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>
|
||||
{tooltipContent}
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleClose}>Got it</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tooltip title="Learn about Smart Research" arrow>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={handleOpen}
|
||||
sx={{
|
||||
ml: 0.5,
|
||||
color: 'primary.main',
|
||||
'&:hover': { bgcolor: 'primary.light', color: 'white' },
|
||||
}}
|
||||
>
|
||||
<InfoIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
<Dialog open={open} onClose={handleClose} maxWidth="md" fullWidth>
|
||||
<DialogTitle>
|
||||
<Box display="flex" alignItems="center" justifyContent="space-between">
|
||||
<Box display="flex" alignItems="center" gap={1}>
|
||||
<BrainIcon color="primary" sx={{ fontSize: 32 }} />
|
||||
<Typography variant="h5" fontWeight={600}>
|
||||
🧠 Smart Research
|
||||
</Typography>
|
||||
</Box>
|
||||
<IconButton onClick={handleClose} size="small">
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography variant="body1" color="text.secondary" paragraph>
|
||||
Traditional research gives you links to sift through. Smart Research understands
|
||||
what you want to accomplish and delivers exactly what you need — organized and ready to use.
|
||||
</Typography>
|
||||
|
||||
<Divider sx={{ my: 3 }} />
|
||||
|
||||
<Typography variant="h6" gutterBottom fontWeight={600}>
|
||||
How it works:
|
||||
</Typography>
|
||||
<List>
|
||||
<ListItem>
|
||||
<ListItemIcon>
|
||||
<BrainIcon color="primary" />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary="1. AI Analyzes Your Intent"
|
||||
secondary="Understands what you want to accomplish, what questions need answering, and what deliverables you expect"
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<ListItemIcon>
|
||||
<TrendIcon color="primary" />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary="2. Generates Targeted Queries"
|
||||
secondary="Creates multiple focused queries, each targeting a specific deliverable (statistics, quotes, case studies, etc.)"
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<ListItemIcon>
|
||||
<CheckIcon color="primary" />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary="3. Extracts Exactly What You Need"
|
||||
secondary="Analyzes results through the lens of your intent, extracting statistics, expert quotes, case studies, trends, and more"
|
||||
/>
|
||||
</ListItem>
|
||||
</List>
|
||||
|
||||
<Divider sx={{ my: 3 }} />
|
||||
|
||||
<Typography variant="h6" gutterBottom fontWeight={600}>
|
||||
What you'll get:
|
||||
</Typography>
|
||||
<Box display="flex" flexWrap="wrap" gap={1} mb={2}>
|
||||
<Chip icon={<StatsIcon />} label="Statistics with Citations" color="primary" variant="outlined" />
|
||||
<Chip icon={<QuoteIcon />} label="Expert Quotes" color="primary" variant="outlined" />
|
||||
<Chip icon={<CaseStudyIcon />} label="Case Studies" color="primary" variant="outlined" />
|
||||
<Chip icon={<TrendIcon />} label="Trends Analysis" color="primary" variant="outlined" />
|
||||
<Chip label="Best Practices" color="primary" variant="outlined" />
|
||||
<Chip label="Comparisons" color="primary" variant="outlined" />
|
||||
<Chip label="Step-by-Step Guides" color="primary" variant="outlined" />
|
||||
<Chip label="Pros & Cons" color="primary" variant="outlined" />
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ my: 3 }} />
|
||||
|
||||
<Typography variant="h6" gutterBottom fontWeight={600}>
|
||||
Benefits:
|
||||
</Typography>
|
||||
<List dense>
|
||||
<ListItem>
|
||||
<ListItemIcon>
|
||||
<CheckIcon color="success" fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="No more sifting through generic search results" />
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<ListItemIcon>
|
||||
<CheckIcon color="success" fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="Get exactly what you need, organized by deliverable type" />
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<ListItemIcon>
|
||||
<CheckIcon color="success" fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="Save time with AI-powered extraction and analysis" />
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<ListItemIcon>
|
||||
<CheckIcon color="success" fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="Content-ready outputs: statistics, quotes, case studies, trends" />
|
||||
</ListItem>
|
||||
</List>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleClose} variant="contained" color="primary">
|
||||
Got it!
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SmartResearchInfo;
|
||||
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* TrendsChart Component
|
||||
*
|
||||
* Advanced chart visualization for Google Trends data using Recharts.
|
||||
* Displays interest over time with interactive tooltips and zoom capabilities.
|
||||
*/
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
Legend,
|
||||
ResponsiveContainer,
|
||||
ReferenceLine,
|
||||
} from 'recharts';
|
||||
import { Box, Typography, useTheme } from '@mui/material';
|
||||
import { GoogleTrendsData } from '../../types/intent.types';
|
||||
|
||||
interface TrendsChartProps {
|
||||
data: GoogleTrendsData;
|
||||
height?: number;
|
||||
showAverage?: boolean;
|
||||
}
|
||||
|
||||
export const TrendsChart: React.FC<TrendsChartProps> = ({
|
||||
data,
|
||||
height = 300,
|
||||
showAverage = true,
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
|
||||
// Transform data for Recharts
|
||||
const chartData = useMemo(() => {
|
||||
if (!data.interest_over_time || data.interest_over_time.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return data.interest_over_time.map((point: any) => {
|
||||
const result: any = {};
|
||||
|
||||
// Extract date
|
||||
const dateKey = Object.keys(point).find(k =>
|
||||
k.toLowerCase().includes('date') || k === 'date'
|
||||
);
|
||||
if (dateKey && point[dateKey]) {
|
||||
const dateValue = point[dateKey];
|
||||
result.date = typeof dateValue === 'string'
|
||||
? new Date(dateValue).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
|
||||
: dateValue;
|
||||
result.fullDate = typeof dateValue === 'string' ? dateValue : dateValue;
|
||||
}
|
||||
|
||||
// Extract interest values for each keyword
|
||||
data.keywords.forEach((keyword, idx) => {
|
||||
// Find the value column for this keyword
|
||||
const valueKey = Object.keys(point).find(k => {
|
||||
const val = point[k];
|
||||
return typeof val === 'number' && val !== null && val !== undefined && k !== 'isPartial';
|
||||
});
|
||||
|
||||
if (valueKey) {
|
||||
result[keyword] = point[valueKey];
|
||||
} else {
|
||||
// Try to find by index if keywords match column order
|
||||
const numericKeys = Object.keys(point).filter(k => {
|
||||
const val = point[k];
|
||||
return typeof val === 'number' && val !== null && val !== undefined && k !== 'isPartial';
|
||||
});
|
||||
if (numericKeys[idx]) {
|
||||
result[keyword] = point[numericKeys[idx]];
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Add isPartial flag if available
|
||||
if (point.isPartial !== undefined) {
|
||||
result.isPartial = point.isPartial;
|
||||
}
|
||||
|
||||
return result;
|
||||
}).filter(item => item.date); // Filter out items without dates
|
||||
}, [data.interest_over_time, data.keywords]);
|
||||
|
||||
// Calculate average if needed
|
||||
const averageValue = useMemo(() => {
|
||||
if (!showAverage || chartData.length === 0) return null;
|
||||
|
||||
const allValues: number[] = [];
|
||||
chartData.forEach((point: any) => {
|
||||
data.keywords.forEach(keyword => {
|
||||
if (point[keyword] !== undefined && point[keyword] !== null) {
|
||||
allValues.push(point[keyword]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (allValues.length === 0) return null;
|
||||
return allValues.reduce((sum, val) => sum + val, 0) / allValues.length;
|
||||
}, [chartData, data.keywords, showAverage]);
|
||||
|
||||
// Color palette for multiple keywords
|
||||
const colors = [
|
||||
theme.palette.primary.main,
|
||||
theme.palette.secondary.main,
|
||||
'#10b981', // green
|
||||
'#f59e0b', // amber
|
||||
'#ef4444', // red
|
||||
'#8b5cf6', // purple
|
||||
];
|
||||
|
||||
if (chartData.length === 0) {
|
||||
return (
|
||||
<Box sx={{ height, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
No trend data available
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ width: '100%', height }}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart
|
||||
data={chartData}
|
||||
margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={theme.palette.divider} />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
stroke={theme.palette.text.secondary}
|
||||
style={{ fontSize: '12px' }}
|
||||
angle={-45}
|
||||
textAnchor="end"
|
||||
height={60}
|
||||
interval="preserveStartEnd"
|
||||
/>
|
||||
<YAxis
|
||||
stroke={theme.palette.text.secondary}
|
||||
style={{ fontSize: '12px' }}
|
||||
domain={[0, 100]}
|
||||
label={{
|
||||
value: 'Interest (0-100)',
|
||||
angle: -90,
|
||||
position: 'insideLeft',
|
||||
style: { fontSize: '12px', fill: theme.palette.text.secondary }
|
||||
}}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: theme.palette.background.paper,
|
||||
border: `1px solid ${theme.palette.divider}`,
|
||||
borderRadius: '8px',
|
||||
}}
|
||||
formatter={(value: any, name: string) => {
|
||||
if (typeof value === 'number') {
|
||||
return [`${Math.round(value)}`, name];
|
||||
}
|
||||
return [value, name];
|
||||
}}
|
||||
labelFormatter={(label) => `Date: ${label}`}
|
||||
/>
|
||||
{data.keywords.length > 1 && (
|
||||
<Legend
|
||||
wrapperStyle={{ paddingTop: '20px' }}
|
||||
iconType="line"
|
||||
/>
|
||||
)}
|
||||
{showAverage && averageValue !== null && (
|
||||
<ReferenceLine
|
||||
y={averageValue}
|
||||
stroke={theme.palette.text.secondary}
|
||||
strokeDasharray="5 5"
|
||||
label={{
|
||||
value: `Avg: ${Math.round(averageValue)}`,
|
||||
position: 'right',
|
||||
style: { fontSize: '11px', fill: theme.palette.text.secondary }
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{data.keywords.map((keyword, idx) => (
|
||||
<Line
|
||||
key={keyword}
|
||||
type="monotone"
|
||||
dataKey={keyword}
|
||||
stroke={colors[idx % colors.length]}
|
||||
strokeWidth={2}
|
||||
dot={{ r: 3 }}
|
||||
activeDot={{ r: 5 }}
|
||||
name={keyword}
|
||||
/>
|
||||
))}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mt: 1, display: 'block', textAlign: 'center' }}>
|
||||
Values are normalized (0-100) where 100 is peak popularity
|
||||
{data.timeframe && ` • Timeframe: ${data.timeframe}`}
|
||||
{data.geo && ` • Region: ${data.geo}`}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,284 @@
|
||||
/**
|
||||
* TrendsExport Component
|
||||
*
|
||||
* Provides export functionality for Google Trends data.
|
||||
* Supports CSV export and image export (chart screenshot).
|
||||
*/
|
||||
|
||||
import React, { useRef, useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Menu,
|
||||
MenuItem,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Tooltip,
|
||||
CircularProgress,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Download as DownloadIcon,
|
||||
FileDownload as FileDownloadIcon,
|
||||
Image as ImageIcon,
|
||||
TableChart as TableChartIcon,
|
||||
} from '@mui/icons-material';
|
||||
import { GoogleTrendsData, TrendAnalysis } from '../../types/intent.types';
|
||||
|
||||
interface TrendsExportProps {
|
||||
trendsData: GoogleTrendsData;
|
||||
aiTrends?: TrendAnalysis[];
|
||||
keywords: string[];
|
||||
}
|
||||
|
||||
export const TrendsExport: React.FC<TrendsExportProps> = ({
|
||||
trendsData,
|
||||
aiTrends = [],
|
||||
keywords,
|
||||
}) => {
|
||||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const open = Boolean(anchorEl);
|
||||
|
||||
const handleClick = (event: React.MouseEvent<HTMLElement>) => {
|
||||
setAnchorEl(event.currentTarget);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setAnchorEl(null);
|
||||
};
|
||||
|
||||
// Export to CSV
|
||||
const exportToCSV = () => {
|
||||
setExporting(true);
|
||||
try {
|
||||
// Prepare CSV data
|
||||
const csvRows: string[] = [];
|
||||
|
||||
// Header
|
||||
csvRows.push('Google Trends Data Export');
|
||||
csvRows.push(`Keywords: ${keywords.join(', ')}`);
|
||||
csvRows.push(`Timeframe: ${trendsData.timeframe}`);
|
||||
csvRows.push(`Region: ${trendsData.geo}`);
|
||||
csvRows.push(`Exported: ${new Date().toISOString()}`);
|
||||
csvRows.push('');
|
||||
|
||||
// Interest Over Time
|
||||
if (trendsData.interest_over_time.length > 0) {
|
||||
csvRows.push('Interest Over Time');
|
||||
const headers = ['Date', ...keywords];
|
||||
csvRows.push(headers.join(','));
|
||||
|
||||
trendsData.interest_over_time.forEach((point: any) => {
|
||||
const dateKey = Object.keys(point).find(k =>
|
||||
k.toLowerCase().includes('date') || k === 'date'
|
||||
);
|
||||
const date = dateKey ? point[dateKey] : '';
|
||||
const values = keywords.map(keyword => {
|
||||
const valueKey = Object.keys(point).find(k => {
|
||||
const val = point[k];
|
||||
return typeof val === 'number' && val !== null && val !== undefined && k !== 'isPartial';
|
||||
});
|
||||
return valueKey ? point[valueKey] : '';
|
||||
});
|
||||
csvRows.push([date, ...values].join(','));
|
||||
});
|
||||
csvRows.push('');
|
||||
}
|
||||
|
||||
// Interest by Region
|
||||
if (trendsData.interest_by_region.length > 0) {
|
||||
csvRows.push('Interest by Region');
|
||||
csvRows.push('Region,Interest');
|
||||
|
||||
trendsData.interest_by_region.forEach((region: any) => {
|
||||
const geoKey = Object.keys(region).find(k => k.includes('geo') || k.includes('name'));
|
||||
const regionName = region.geoName || (geoKey ? region[geoKey] : null) || 'Unknown';
|
||||
const value = Object.values(region).find(v => typeof v === 'number' && v !== null) as number || 0;
|
||||
csvRows.push(`${regionName},${value}`);
|
||||
});
|
||||
csvRows.push('');
|
||||
}
|
||||
|
||||
// Related Topics
|
||||
if (trendsData.related_topics.top.length > 0 || trendsData.related_topics.rising.length > 0) {
|
||||
csvRows.push('Related Topics');
|
||||
csvRows.push('Type,Topic,Value');
|
||||
|
||||
trendsData.related_topics.top.forEach((topic: any) => {
|
||||
const topicTitle = topic.topic_title || topic.title || topic[Object.keys(topic)[0]] || 'Unknown';
|
||||
const value = topic.value || '';
|
||||
csvRows.push(`Top,${topicTitle},${value}`);
|
||||
});
|
||||
|
||||
trendsData.related_topics.rising.forEach((topic: any) => {
|
||||
const topicTitle = topic.topic_title || topic.title || topic[Object.keys(topic)[0]] || 'Unknown';
|
||||
const value = topic.value || '';
|
||||
csvRows.push(`Rising,${topicTitle},${value}`);
|
||||
});
|
||||
csvRows.push('');
|
||||
}
|
||||
|
||||
// Related Queries
|
||||
if (trendsData.related_queries.top.length > 0 || trendsData.related_queries.rising.length > 0) {
|
||||
csvRows.push('Related Queries');
|
||||
csvRows.push('Type,Query');
|
||||
|
||||
trendsData.related_queries.top.forEach((query: any) => {
|
||||
const queryText = query.query || query[Object.keys(query)[0]] || 'Unknown';
|
||||
csvRows.push(`Top,${queryText}`);
|
||||
});
|
||||
|
||||
trendsData.related_queries.rising.forEach((query: any) => {
|
||||
const queryText = query.query || query[Object.keys(query)[0]] || 'Unknown';
|
||||
csvRows.push(`Rising,${queryText}`);
|
||||
});
|
||||
csvRows.push('');
|
||||
}
|
||||
|
||||
// AI-Extracted Trends
|
||||
if (aiTrends.length > 0) {
|
||||
csvRows.push('AI-Extracted Trends');
|
||||
csvRows.push('Trend,Direction,Impact,Timeline,Interest Score');
|
||||
|
||||
aiTrends.forEach(trend => {
|
||||
csvRows.push([
|
||||
trend.trend,
|
||||
trend.direction,
|
||||
trend.impact || '',
|
||||
trend.timeline || '',
|
||||
trend.interest_score ? Math.round(trend.interest_score).toString() : '',
|
||||
].join(','));
|
||||
});
|
||||
}
|
||||
|
||||
// Create and download CSV
|
||||
const csvContent = csvRows.join('\n');
|
||||
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||
const link = document.createElement('a');
|
||||
const url = URL.createObjectURL(blob);
|
||||
link.setAttribute('href', url);
|
||||
link.setAttribute('download', `google-trends-${keywords.join('-')}-${new Date().toISOString().split('T')[0]}.csv`);
|
||||
link.style.visibility = 'hidden';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
} catch (error) {
|
||||
console.error('Error exporting CSV:', error);
|
||||
alert('Failed to export CSV. Please try again.');
|
||||
} finally {
|
||||
setExporting(false);
|
||||
handleClose();
|
||||
}
|
||||
};
|
||||
|
||||
// Export chart as image (requires html2canvas)
|
||||
const exportChartAsImage = async () => {
|
||||
setExporting(true);
|
||||
try {
|
||||
// Dynamically import html2canvas if available
|
||||
let html2canvas: ((element: HTMLElement, options?: any) => Promise<HTMLCanvasElement>);
|
||||
try {
|
||||
// Dynamic import - html2canvas may not be installed, we handle this gracefully
|
||||
const html2canvasModule = await import('html2canvas');
|
||||
html2canvas = html2canvasModule.default as (element: HTMLElement, options?: any) => Promise<HTMLCanvasElement>;
|
||||
} catch (importError) {
|
||||
alert('Image export requires html2canvas package. Please install it: npm install html2canvas');
|
||||
setExporting(false);
|
||||
handleClose();
|
||||
return;
|
||||
}
|
||||
|
||||
const chartElement = document.querySelector('[data-trends-chart]');
|
||||
if (!chartElement) {
|
||||
alert('Chart not found. Please ensure the chart is visible.');
|
||||
setExporting(false);
|
||||
handleClose();
|
||||
return;
|
||||
}
|
||||
|
||||
const canvas = await html2canvas(chartElement as HTMLElement, {
|
||||
backgroundColor: '#ffffff',
|
||||
scale: 2,
|
||||
logging: false,
|
||||
});
|
||||
|
||||
canvas.toBlob((blob: Blob | null) => {
|
||||
if (!blob) {
|
||||
alert('Failed to generate image.');
|
||||
return;
|
||||
}
|
||||
|
||||
const link = document.createElement('a');
|
||||
const url = URL.createObjectURL(blob);
|
||||
link.setAttribute('href', url);
|
||||
link.setAttribute('download', `trends-chart-${keywords.join('-')}-${new Date().toISOString().split('T')[0]}.png`);
|
||||
link.style.visibility = 'hidden';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error exporting image:', error);
|
||||
// If html2canvas is not installed, show helpful message
|
||||
if (error instanceof Error && error.message.includes('Cannot find module')) {
|
||||
alert('Image export requires html2canvas package. Please install it: npm install html2canvas');
|
||||
} else {
|
||||
alert('Failed to export image. Please try again.');
|
||||
}
|
||||
} finally {
|
||||
setExporting(false);
|
||||
handleClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Tooltip title="Export trends data">
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
startIcon={exporting ? <CircularProgress size={16} /> : <DownloadIcon />}
|
||||
onClick={handleClick}
|
||||
disabled={exporting}
|
||||
sx={{
|
||||
borderColor: '#e5e7eb',
|
||||
color: '#374151',
|
||||
'&:hover': {
|
||||
borderColor: '#0ea5e9',
|
||||
backgroundColor: '#f0f9ff',
|
||||
},
|
||||
}}
|
||||
>
|
||||
Export
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Menu
|
||||
anchorEl={anchorEl}
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
anchorOrigin={{
|
||||
vertical: 'bottom',
|
||||
horizontal: 'right',
|
||||
}}
|
||||
transformOrigin={{
|
||||
vertical: 'top',
|
||||
horizontal: 'right',
|
||||
}}
|
||||
>
|
||||
<MenuItem onClick={exportToCSV} disabled={exporting}>
|
||||
<ListItemIcon>
|
||||
<TableChartIcon fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText>Export as CSV</ListItemText>
|
||||
</MenuItem>
|
||||
<MenuItem onClick={exportChartAsImage} disabled={exporting}>
|
||||
<ListItemIcon>
|
||||
<ImageIcon fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText>Export Chart as Image</ListItemText>
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Hook for keyword expansion with persona support
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { expandKeywords, expandKeywordsWithPersona } from '../../../../utils/keywordExpansion';
|
||||
|
||||
interface ResearchPersona {
|
||||
keyword_expansion_patterns?: Record<string, string[]>;
|
||||
suggested_keywords?: string[];
|
||||
}
|
||||
|
||||
interface KeywordExpansion {
|
||||
original: string[];
|
||||
expanded: string[];
|
||||
suggestions: string[];
|
||||
}
|
||||
|
||||
export const useKeywordExpansion = (
|
||||
keywords: string[],
|
||||
industry: string,
|
||||
researchPersona: ResearchPersona | null
|
||||
): KeywordExpansion | null => {
|
||||
const [keywordExpansion, setKeywordExpansion] = useState<KeywordExpansion | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (keywords.length > 0) {
|
||||
let expansion;
|
||||
|
||||
// If we have research persona with keyword expansion patterns, use them
|
||||
if (researchPersona?.keyword_expansion_patterns && Object.keys(researchPersona.keyword_expansion_patterns).length > 0) {
|
||||
expansion = expandKeywordsWithPersona(
|
||||
keywords,
|
||||
researchPersona.keyword_expansion_patterns,
|
||||
researchPersona.suggested_keywords
|
||||
);
|
||||
} else if (industry !== 'General') {
|
||||
// Fallback to industry-based expansion
|
||||
expansion = expandKeywords(keywords, industry);
|
||||
} else {
|
||||
expansion = { original: keywords, expanded: keywords, suggestions: [] };
|
||||
}
|
||||
|
||||
setKeywordExpansion(expansion);
|
||||
} else {
|
||||
setKeywordExpansion(null);
|
||||
}
|
||||
}, [keywords, industry, researchPersona]);
|
||||
|
||||
return keywordExpansion;
|
||||
};
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Hook for generating research angles with persona support
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { generateResearchAngles } from '../../../../utils/researchAngles';
|
||||
|
||||
interface ResearchPersona {
|
||||
research_angles?: string[];
|
||||
}
|
||||
|
||||
export const useResearchAngles = (
|
||||
keywords: string[],
|
||||
industry: string,
|
||||
researchPersona: ResearchPersona | null
|
||||
): string[] => {
|
||||
const [researchAngles, setResearchAngles] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (keywords.length > 0) {
|
||||
const query = keywords.join(' ');
|
||||
let angles: string[] = [];
|
||||
|
||||
// Priority 1: Use research persona angles if available and relevant
|
||||
if (researchPersona?.research_angles && researchPersona.research_angles.length > 0) {
|
||||
// Filter persona angles that are relevant to the current query
|
||||
const relevantPersonaAngles = researchPersona.research_angles
|
||||
.filter(angle => {
|
||||
const angleLower = angle.toLowerCase();
|
||||
const queryLower = query.toLowerCase();
|
||||
// Check if angle contains any keyword from query or vice versa
|
||||
return keywords.some(kw => angleLower.includes(kw.toLowerCase()) || queryLower.includes(kw.toLowerCase())) ||
|
||||
angleLower.includes(queryLower) || queryLower.includes(angleLower);
|
||||
})
|
||||
.slice(0, 3); // Use top 3 relevant persona angles
|
||||
|
||||
angles.push(...relevantPersonaAngles);
|
||||
}
|
||||
|
||||
// Priority 2: Generate additional angles using pattern matching
|
||||
const generatedAngles = generateResearchAngles(query, industry);
|
||||
|
||||
// Merge and deduplicate, prioritizing persona angles
|
||||
const allAngles = [...angles, ...generatedAngles];
|
||||
const uniqueAngles = Array.from(new Set(allAngles.map(a => a.toLowerCase())))
|
||||
.slice(0, 5) // Limit to 5 total
|
||||
.map(a => {
|
||||
// Find original casing from persona angles first, then generated
|
||||
const personaMatch = angles.find(pa => pa.toLowerCase() === a);
|
||||
if (personaMatch) return personaMatch;
|
||||
const generatedMatch = generatedAngles.find(ga => ga.toLowerCase() === a);
|
||||
return generatedMatch || a.charAt(0).toUpperCase() + a.slice(1);
|
||||
});
|
||||
|
||||
setResearchAngles(uniqueAngles);
|
||||
} else {
|
||||
setResearchAngles([]);
|
||||
}
|
||||
}, [keywords, industry, researchPersona]);
|
||||
|
||||
return researchAngles;
|
||||
};
|
||||
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* Hook for loading and managing research configuration and persona defaults
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { getResearchConfig, ProviderAvailability } from '../../../../api/researchConfig';
|
||||
import { WizardState } from '../../types/research.types';
|
||||
import { ResearchProvider } from '../../../../services/blogWriterApi';
|
||||
|
||||
interface ResearchPersona {
|
||||
research_angles?: string[];
|
||||
recommended_presets?: Array<{
|
||||
name: string;
|
||||
keywords: string | string[];
|
||||
description?: string;
|
||||
}>;
|
||||
suggested_keywords?: string[];
|
||||
keyword_expansion_patterns?: Record<string, string[]>;
|
||||
industry?: string;
|
||||
target_audience?: string;
|
||||
}
|
||||
|
||||
interface UseResearchConfigResult {
|
||||
providerAvailability: ProviderAvailability | null;
|
||||
researchPersona: ResearchPersona | null;
|
||||
loadingConfig: boolean;
|
||||
applyPersonaDefaults: (state: WizardState, onUpdate: (updates: Partial<WizardState>) => void) => void;
|
||||
}
|
||||
|
||||
export const useResearchConfig = (): UseResearchConfigResult => {
|
||||
const [providerAvailability, setProviderAvailability] = useState<ProviderAvailability | null>(null);
|
||||
const [researchPersona, setResearchPersona] = useState<ResearchPersona | null>(null);
|
||||
const [loadingConfig, setLoadingConfig] = useState(true);
|
||||
const [configData, setConfigData] = useState<any>(null);
|
||||
|
||||
// Load research configuration on mount
|
||||
useEffect(() => {
|
||||
const loadConfig = async () => {
|
||||
try {
|
||||
const config = await getResearchConfig();
|
||||
setConfigData(config);
|
||||
|
||||
// Set provider availability with fallback
|
||||
setProviderAvailability(config?.provider_availability || {
|
||||
google_available: true,
|
||||
exa_available: false,
|
||||
tavily_available: false,
|
||||
tavily_key_status: 'missing',
|
||||
gemini_key_status: 'missing',
|
||||
exa_key_status: 'missing'
|
||||
});
|
||||
|
||||
// Store research persona data if available
|
||||
if (config?.research_persona || config?.persona_defaults) {
|
||||
const defaults = config.persona_defaults || {};
|
||||
setResearchPersona({
|
||||
research_angles: config.research_persona?.research_angles || defaults.research_angles,
|
||||
recommended_presets: config.research_persona?.recommended_presets || [],
|
||||
suggested_keywords: config.research_persona?.suggested_keywords || defaults.suggested_keywords,
|
||||
keyword_expansion_patterns: config.research_persona?.keyword_expansion_patterns,
|
||||
industry: config.research_persona?.default_industry || defaults.industry,
|
||||
target_audience: config.research_persona?.default_target_audience || defaults.target_audience
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[useResearchConfig] Failed to load research config:', error);
|
||||
setProviderAvailability({
|
||||
google_available: true,
|
||||
exa_available: false,
|
||||
tavily_available: false,
|
||||
tavily_key_status: 'missing',
|
||||
gemini_key_status: 'missing',
|
||||
exa_key_status: 'missing'
|
||||
});
|
||||
} finally {
|
||||
setLoadingConfig(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadConfig();
|
||||
}, []);
|
||||
|
||||
const applyPersonaDefaults = (state: WizardState, onUpdate: (updates: Partial<WizardState>) => void) => {
|
||||
if (!configData?.persona_defaults) return;
|
||||
|
||||
const defaults = configData.persona_defaults;
|
||||
|
||||
// Apply industry if provided and user hasn't customized
|
||||
if (defaults.industry && (!state.industry || state.industry === 'General')) {
|
||||
onUpdate({ industry: defaults.industry });
|
||||
}
|
||||
|
||||
// Apply target audience if provided
|
||||
if (defaults.target_audience && (!state.targetAudience || state.targetAudience === 'General')) {
|
||||
onUpdate({ targetAudience: defaults.target_audience });
|
||||
}
|
||||
|
||||
// Apply suggested Exa domains if Exa is available and not already set
|
||||
if (configData.provider_availability?.exa_available && defaults.suggested_domains?.length > 0) {
|
||||
if (!state.config.exa_include_domains || state.config.exa_include_domains.length === 0) {
|
||||
onUpdate({
|
||||
config: {
|
||||
...state.config,
|
||||
exa_include_domains: defaults.suggested_domains
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Apply suggested Exa category if available
|
||||
if (defaults.suggested_exa_category && !state.config.exa_category) {
|
||||
onUpdate({
|
||||
config: {
|
||||
...state.config,
|
||||
exa_category: defaults.suggested_exa_category
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Apply enhanced Exa defaults from research persona
|
||||
if (defaults.suggested_exa_search_type && !state.config.exa_search_type) {
|
||||
onUpdate({
|
||||
config: {
|
||||
...state.config,
|
||||
exa_search_type: defaults.suggested_exa_search_type as 'auto' | 'keyword' | 'neural'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Apply Tavily defaults from research persona
|
||||
if (defaults.suggested_tavily_topic && !state.config.tavily_topic) {
|
||||
onUpdate({
|
||||
config: {
|
||||
...state.config,
|
||||
tavily_topic: defaults.suggested_tavily_topic as 'general' | 'news' | 'finance'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (defaults.suggested_tavily_search_depth && !state.config.tavily_search_depth) {
|
||||
onUpdate({
|
||||
config: {
|
||||
...state.config,
|
||||
tavily_search_depth: defaults.suggested_tavily_search_depth as 'basic' | 'advanced'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (defaults.suggested_tavily_include_answer && !state.config.tavily_include_answer) {
|
||||
const answerValue = defaults.suggested_tavily_include_answer === 'true' ? true :
|
||||
defaults.suggested_tavily_include_answer === 'false' ? false :
|
||||
defaults.suggested_tavily_include_answer as 'basic' | 'advanced';
|
||||
onUpdate({
|
||||
config: {
|
||||
...state.config,
|
||||
tavily_include_answer: answerValue
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (defaults.suggested_tavily_time_range && !state.config.tavily_time_range) {
|
||||
onUpdate({
|
||||
config: {
|
||||
...state.config,
|
||||
tavily_time_range: defaults.suggested_tavily_time_range as 'day' | 'week' | 'month' | 'year'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (defaults.suggested_tavily_raw_content_format && !state.config.tavily_include_raw_content) {
|
||||
const rawContentValue = defaults.suggested_tavily_raw_content_format === 'true' ? true :
|
||||
defaults.suggested_tavily_raw_content_format === 'false' ? false :
|
||||
defaults.suggested_tavily_raw_content_format as 'markdown' | 'text';
|
||||
onUpdate({
|
||||
config: {
|
||||
...state.config,
|
||||
tavily_include_raw_content: rawContentValue
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Apply additional hyper-personalization defaults from research persona
|
||||
if (defaults.has_research_persona) {
|
||||
// Apply default research mode if not already customized
|
||||
if (defaults.default_research_mode && state.researchMode === 'comprehensive') {
|
||||
const validModes = ['basic', 'comprehensive', 'targeted'] as const;
|
||||
if (validModes.includes(defaults.default_research_mode as typeof validModes[number])) {
|
||||
onUpdate({ researchMode: defaults.default_research_mode as typeof validModes[number] });
|
||||
}
|
||||
}
|
||||
|
||||
// Apply default provider (only if it's available)
|
||||
if (defaults.default_provider) {
|
||||
const validProviders = ['exa', 'tavily', 'google'] as const;
|
||||
type ValidProvider = typeof validProviders[number];
|
||||
|
||||
if (validProviders.includes(defaults.default_provider as ValidProvider)) {
|
||||
const providerAvailable =
|
||||
(defaults.default_provider === 'exa' && configData.provider_availability?.exa_available) ||
|
||||
(defaults.default_provider === 'tavily' && configData.provider_availability?.tavily_available) ||
|
||||
(defaults.default_provider === 'google' && configData.provider_availability?.google_available);
|
||||
|
||||
if (providerAvailable && !state.config.provider) {
|
||||
onUpdate({
|
||||
config: {
|
||||
...state.config,
|
||||
provider: defaults.default_provider as ValidProvider
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
providerAvailability,
|
||||
researchPersona,
|
||||
loadingConfig,
|
||||
applyPersonaDefaults,
|
||||
};
|
||||
};
|
||||
@@ -123,6 +123,49 @@ export interface TrendAnalysis {
|
||||
impact: string | null;
|
||||
timeline: string | null;
|
||||
sources: string[];
|
||||
// Google Trends specific (optional)
|
||||
google_trends_data?: GoogleTrendsData;
|
||||
interest_score?: number; // 0-100 from Google Trends
|
||||
regional_interest?: Record<string, number>;
|
||||
related_topics?: {
|
||||
top: string[];
|
||||
rising: string[];
|
||||
};
|
||||
related_queries?: {
|
||||
top: string[];
|
||||
rising: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface GoogleTrendsData {
|
||||
interest_over_time: Array<Record<string, any>>;
|
||||
interest_by_region: Array<Record<string, any>>;
|
||||
related_topics: {
|
||||
top: Array<Record<string, any>>;
|
||||
rising: Array<Record<string, any>>;
|
||||
};
|
||||
related_queries: {
|
||||
top: Array<Record<string, any>>;
|
||||
rising: Array<Record<string, any>>;
|
||||
};
|
||||
trending_searches?: string[];
|
||||
timeframe: string;
|
||||
geo: string;
|
||||
keywords: string[];
|
||||
timestamp: string;
|
||||
cached?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface TrendsConfig {
|
||||
enabled: boolean;
|
||||
keywords: string[];
|
||||
keywords_justification: string;
|
||||
timeframe: string;
|
||||
timeframe_justification: string;
|
||||
geo: string;
|
||||
geo_justification: string;
|
||||
expected_insights: string[];
|
||||
}
|
||||
|
||||
export interface ComparisonItem {
|
||||
@@ -172,6 +215,42 @@ export interface AnalyzeIntentRequest {
|
||||
use_competitor_data: boolean;
|
||||
}
|
||||
|
||||
// Optimized provider configuration with AI-driven justifications
|
||||
export interface OptimizedConfig {
|
||||
provider: 'exa' | 'tavily' | 'google';
|
||||
provider_justification?: string;
|
||||
|
||||
// Exa settings with justifications
|
||||
exa_type?: string;
|
||||
exa_type_justification?: string;
|
||||
exa_category?: string;
|
||||
exa_category_justification?: string;
|
||||
exa_include_domains?: string[];
|
||||
exa_include_domains_justification?: string;
|
||||
exa_num_results?: number;
|
||||
exa_num_results_justification?: string;
|
||||
exa_date_filter?: string;
|
||||
exa_date_justification?: string;
|
||||
exa_highlights?: boolean;
|
||||
exa_highlights_justification?: string;
|
||||
exa_context?: boolean;
|
||||
exa_context_justification?: string;
|
||||
|
||||
// Tavily settings with justifications
|
||||
tavily_topic?: string;
|
||||
tavily_topic_justification?: string;
|
||||
tavily_search_depth?: string;
|
||||
tavily_search_depth_justification?: string;
|
||||
tavily_include_answer?: boolean | string;
|
||||
tavily_include_answer_justification?: string;
|
||||
tavily_time_range?: string;
|
||||
tavily_time_range_justification?: string;
|
||||
tavily_max_results?: number;
|
||||
tavily_max_results_justification?: string;
|
||||
tavily_raw_content?: string;
|
||||
tavily_raw_content_justification?: string;
|
||||
}
|
||||
|
||||
export interface AnalyzeIntentResponse {
|
||||
success: boolean;
|
||||
intent: ResearchIntent;
|
||||
@@ -180,7 +259,16 @@ export interface AnalyzeIntentResponse {
|
||||
suggested_keywords: string[];
|
||||
suggested_angles: string[];
|
||||
quick_options: QuickOption[];
|
||||
confidence_reason?: string;
|
||||
great_example?: string;
|
||||
error_message: string | null;
|
||||
|
||||
// Unified: Optimized provider parameters based on intent
|
||||
optimized_config?: OptimizedConfig;
|
||||
recommended_provider?: 'exa' | 'tavily' | 'google';
|
||||
|
||||
// Google Trends configuration (if trends in deliverables)
|
||||
trends_config?: TrendsConfig;
|
||||
}
|
||||
|
||||
export interface QuickOption {
|
||||
@@ -200,6 +288,7 @@ export interface IntentDrivenResearchRequest {
|
||||
max_sources: number;
|
||||
include_domains: string[];
|
||||
exclude_domains: string[];
|
||||
trends_config?: TrendsConfig; // Google Trends configuration
|
||||
skip_inference: boolean;
|
||||
}
|
||||
|
||||
@@ -237,6 +326,9 @@ export interface IntentDrivenResearchResponse {
|
||||
// The intent used
|
||||
intent: ResearchIntent | null;
|
||||
|
||||
// Google Trends data (if trends were analyzed)
|
||||
google_trends_data?: GoogleTrendsData;
|
||||
|
||||
// Error
|
||||
error_message: string | null;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@ import { BlogResearchResponse, ResearchMode, ResearchConfig } from '../../../ser
|
||||
import {
|
||||
ResearchIntent,
|
||||
AnalyzeIntentResponse,
|
||||
IntentDrivenResearchResponse
|
||||
IntentDrivenResearchResponse,
|
||||
ResearchQuery,
|
||||
} from './intent.types';
|
||||
|
||||
export interface WizardState {
|
||||
@@ -35,7 +36,7 @@ export interface ResearchExecution {
|
||||
analyzeIntent: (state: WizardState) => Promise<AnalyzeIntentResponse | null>;
|
||||
confirmIntent: (intent: ResearchIntent) => void;
|
||||
updateIntentField: <K extends keyof ResearchIntent>(field: K, value: ResearchIntent[K]) => void;
|
||||
executeIntentResearch: (state: WizardState) => Promise<IntentDrivenResearchResponse | null>;
|
||||
executeIntentResearch: (state: WizardState, selectedQueries?: ResearchQuery[]) => Promise<IntentDrivenResearchResponse | null>;
|
||||
clearIntent: () => void;
|
||||
}
|
||||
|
||||
|
||||
@@ -16,11 +16,12 @@ export const VideoStudioDashboard: React.FC = () => {
|
||||
sx={{
|
||||
maxWidth: 1400,
|
||||
mx: 'auto',
|
||||
borderRadius: 4,
|
||||
border: '1px solid rgba(255,255,255,0.08)',
|
||||
background: 'rgba(15,23,42,0.78)',
|
||||
borderRadius: 5,
|
||||
border: '1px solid rgba(255,255,255,0.12)',
|
||||
background: 'rgba(15,23,42,0.85)',
|
||||
p: { xs: 3, md: 5 },
|
||||
backdropFilter: 'blur(25px)',
|
||||
backdropFilter: 'blur(30px)',
|
||||
boxShadow: '0 20px 60px rgba(15,23,42,0.6), inset 0 1px 0 rgba(255,255,255,0.05)',
|
||||
}}
|
||||
>
|
||||
|
||||
|
||||
@@ -39,11 +39,21 @@ export const VideoStudioLayout: React.FC<VideoStudioLayoutProps> = ({
|
||||
<Box
|
||||
sx={{
|
||||
minHeight: '100vh',
|
||||
background: 'linear-gradient(135deg, #0f172a 0%, #1e1b4b 40%, #312e81 100%)',
|
||||
background: 'linear-gradient(135deg, #0f172a 0%, #1e1b4b 35%, #312e81 70%, #1e1b4b 100%)',
|
||||
py: 4,
|
||||
px: 2,
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'radial-gradient(circle at 20% 50%, rgba(99,102,241,0.15) 0%, transparent 50%), radial-gradient(circle at 80% 80%, rgba(139,92,246,0.15) 0%, transparent 50%)',
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
|
||||
388
frontend/src/components/VideoStudio/dashboard/InfoModal.tsx
Normal file
388
frontend/src/components/VideoStudio/dashboard/InfoModal.tsx
Normal file
@@ -0,0 +1,388 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Typography,
|
||||
Box,
|
||||
Stack,
|
||||
Chip,
|
||||
Divider,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Paper,
|
||||
} from '@mui/material';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import { alpha } from '@mui/material/styles';
|
||||
|
||||
export interface AIModel {
|
||||
name: string;
|
||||
provider: string;
|
||||
capabilities: string[];
|
||||
pricing: {
|
||||
model: 'per_second' | 'per_video' | 'flat_rate' | 'free';
|
||||
rate?: number;
|
||||
min?: number;
|
||||
max?: number;
|
||||
unit?: string;
|
||||
description: string;
|
||||
};
|
||||
features: string[];
|
||||
}
|
||||
|
||||
export interface PerfectForUseCase {
|
||||
title: string;
|
||||
description: string;
|
||||
examples: string[];
|
||||
}
|
||||
|
||||
export interface CostDetail {
|
||||
factors: string[];
|
||||
typicalRange?: string;
|
||||
examples: Array<{
|
||||
scenario: string;
|
||||
cost: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface InfoModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
type: 'perfect-for' | 'cost' | 'ai-models';
|
||||
perfectFor?: PerfectForUseCase[];
|
||||
costDetails?: CostDetail;
|
||||
aiModels?: AIModel[];
|
||||
}
|
||||
|
||||
export const InfoModal: React.FC<InfoModalProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
title,
|
||||
type,
|
||||
perfectFor,
|
||||
costDetails,
|
||||
aiModels,
|
||||
}) => {
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
maxWidth="md"
|
||||
fullWidth
|
||||
PaperProps={{
|
||||
sx: {
|
||||
background: '#ffffff',
|
||||
border: '2px solid rgba(139,92,246,0.3)',
|
||||
borderRadius: 4,
|
||||
boxShadow: '0 24px 48px rgba(0,0,0,0.3), 0 0 0 1px rgba(0,0,0,0.05)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<DialogTitle
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
borderBottom: '2px solid rgba(139,92,246,0.2)',
|
||||
pb: 2.5,
|
||||
background: 'linear-gradient(135deg, rgba(139,92,246,0.1), rgba(99,102,241,0.08))',
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6" fontWeight={800} sx={{ color: '#1e293b', fontSize: '1.25rem' }}>
|
||||
{title}
|
||||
</Typography>
|
||||
<IconButton
|
||||
onClick={onClose}
|
||||
size="small"
|
||||
sx={{
|
||||
color: '#64748b',
|
||||
'&:hover': {
|
||||
background: 'rgba(239,68,68,0.1)',
|
||||
color: '#ef4444',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<CloseIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogContent sx={{ pt: 3, backgroundColor: '#ffffff' }}>
|
||||
{type === 'perfect-for' && perfectFor && (
|
||||
<Stack spacing={3}>
|
||||
{perfectFor.map((useCase, index) => (
|
||||
<Box key={index}>
|
||||
<Typography
|
||||
variant="subtitle1"
|
||||
fontWeight={800}
|
||||
sx={{ color: '#6366f1', mb: 1.5, fontSize: '1.1rem' }}
|
||||
>
|
||||
{useCase.title}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ color: '#334155', mb: 1.5, lineHeight: 1.8, fontSize: '0.95rem' }}
|
||||
>
|
||||
{useCase.description}
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
|
||||
{useCase.examples.map((example, idx) => (
|
||||
<Chip
|
||||
key={idx}
|
||||
label={example}
|
||||
size="small"
|
||||
sx={{
|
||||
background: 'rgba(139,92,246,0.1)',
|
||||
color: '#6366f1',
|
||||
border: '1px solid rgba(139,92,246,0.3)',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
{index < perfectFor.length - 1 && (
|
||||
<Divider sx={{ mt: 2, borderColor: 'rgba(255,255,255,0.1)' }} />
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{type === 'cost' && costDetails && (
|
||||
<Stack spacing={3}>
|
||||
<Box>
|
||||
<Typography
|
||||
variant="subtitle1"
|
||||
fontWeight={800}
|
||||
sx={{ color: '#0ea5e9', mb: 1.5, fontSize: '1.1rem' }}
|
||||
>
|
||||
Cost Factors
|
||||
</Typography>
|
||||
<Stack spacing={1.5}>
|
||||
{costDetails.factors.map((factor, idx) => (
|
||||
<Box
|
||||
key={idx}
|
||||
sx={{
|
||||
p: 2,
|
||||
borderRadius: 2,
|
||||
background: 'rgba(14,165,233,0.08)',
|
||||
border: '1px solid rgba(56,189,248,0.2)',
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ color: '#334155', fontWeight: 600, lineHeight: 1.7 }}
|
||||
>
|
||||
• {factor}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
{costDetails.typicalRange && (
|
||||
<Box>
|
||||
<Typography
|
||||
variant="subtitle1"
|
||||
fontWeight={700}
|
||||
sx={{ color: '#c7d2fe', mb: 1.5 }}
|
||||
>
|
||||
Typical Cost Range
|
||||
</Typography>
|
||||
<Chip
|
||||
label={costDetails.typicalRange}
|
||||
sx={{
|
||||
background: 'linear-gradient(120deg, rgba(16,185,129,0.15), rgba(34,197,94,0.1))',
|
||||
color: '#059669',
|
||||
border: '1px solid rgba(34,197,94,0.3)',
|
||||
fontWeight: 700,
|
||||
fontSize: '0.9rem',
|
||||
p: 1,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{costDetails.examples && costDetails.examples.length > 0 && (
|
||||
<Box>
|
||||
<Typography
|
||||
variant="subtitle1"
|
||||
fontWeight={700}
|
||||
sx={{ color: '#c7d2fe', mb: 1.5 }}
|
||||
>
|
||||
Cost Examples
|
||||
</Typography>
|
||||
<TableContainer
|
||||
component={Paper}
|
||||
sx={{
|
||||
background: '#f8fafc',
|
||||
border: '1px solid rgba(0,0,0,0.1)',
|
||||
}}
|
||||
>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow sx={{ background: 'rgba(139,92,246,0.05)' }}>
|
||||
<TableCell sx={{ color: '#1e293b', fontWeight: 700 }}>Scenario</TableCell>
|
||||
<TableCell align="right" sx={{ color: '#1e293b', fontWeight: 700 }}>
|
||||
Estimated Cost
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{costDetails.examples.map((example, idx) => (
|
||||
<TableRow key={idx}>
|
||||
<TableCell sx={{ color: '#334155' }}>
|
||||
{example.scenario}
|
||||
</TableCell>
|
||||
<TableCell align="right" sx={{ color: '#059669', fontWeight: 700 }}>
|
||||
{example.cost}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{type === 'ai-models' && aiModels && (
|
||||
<Stack spacing={3}>
|
||||
{aiModels.map((model, index) => (
|
||||
<Box
|
||||
key={index}
|
||||
sx={{
|
||||
p: 2.5,
|
||||
borderRadius: 2,
|
||||
background: 'rgba(99,102,241,0.05)',
|
||||
border: '1px solid rgba(139,92,246,0.2)',
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" spacing={1.5} alignItems="center" sx={{ mb: 2 }}>
|
||||
<Typography variant="h6" fontWeight={800} sx={{ color: '#6366f1' }}>
|
||||
{model.name}
|
||||
</Typography>
|
||||
<Chip
|
||||
label={model.provider}
|
||||
size="small"
|
||||
sx={{
|
||||
background: 'rgba(139,92,246,0.1)',
|
||||
color: '#6366f1',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
fontWeight={800}
|
||||
sx={{ color: '#6366f1', mb: 1.5, fontSize: '0.95rem' }}
|
||||
>
|
||||
Capabilities
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
|
||||
{model.capabilities.map((capability, idx) => (
|
||||
<Chip
|
||||
key={idx}
|
||||
label={capability}
|
||||
size="small"
|
||||
sx={{
|
||||
background: 'rgba(14,165,233,0.1)',
|
||||
color: '#0ea5e9',
|
||||
border: '1px solid rgba(56,189,248,0.2)',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
fontWeight={800}
|
||||
sx={{ color: '#99f6e4', mb: 1.5, fontSize: '0.95rem' }}
|
||||
>
|
||||
Pricing
|
||||
</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
borderRadius: 2,
|
||||
background: 'rgba(16,185,129,0.08)',
|
||||
border: '2px solid rgba(34,197,94,0.2)',
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{ color: '#059669', fontWeight: 700, mb: 1, fontSize: '1rem' }}
|
||||
>
|
||||
{model.pricing.description}
|
||||
</Typography>
|
||||
{model.pricing.rate && (
|
||||
<Typography variant="body2" sx={{ color: '#334155', fontWeight: 600 }}>
|
||||
Rate: <span style={{ color: '#059669' }}>${model.pricing.rate}</span>
|
||||
{model.pricing.unit || '/second'}
|
||||
{model.pricing.min && ` (min: $${model.pricing.min})`}
|
||||
{model.pricing.max && ` (max: $${model.pricing.max})`}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
fontWeight={800}
|
||||
sx={{ color: '#6366f1', mb: 1.5, fontSize: '0.95rem' }}
|
||||
>
|
||||
Key Features
|
||||
</Typography>
|
||||
<Stack spacing={1}>
|
||||
{model.features.map((feature, idx) => (
|
||||
<Typography
|
||||
key={idx}
|
||||
variant="body2"
|
||||
sx={{ color: '#334155', pl: 1.5, lineHeight: 1.7 }}
|
||||
>
|
||||
• {feature}
|
||||
</Typography>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
{index < aiModels.length - 1 && (
|
||||
<Divider sx={{ mt: 2, borderColor: 'rgba(255,255,255,0.1)' }} />
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions sx={{ p: 2, borderTop: '1px solid rgba(0,0,0,0.1)', backgroundColor: '#f8fafc' }}>
|
||||
<Box sx={{ flex: 1 }} />
|
||||
<IconButton
|
||||
onClick={onClose}
|
||||
sx={{
|
||||
background: 'rgba(139,92,246,0.1)',
|
||||
color: '#6366f1',
|
||||
'&:hover': {
|
||||
background: 'rgba(139,92,246,0.2)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Paper,
|
||||
@@ -14,10 +14,14 @@ import LockIcon from '@mui/icons-material/Lock';
|
||||
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
|
||||
import SavingsIcon from '@mui/icons-material/Savings';
|
||||
import HelpOutlineIcon from '@mui/icons-material/HelpOutline';
|
||||
import PeopleIcon from '@mui/icons-material/People';
|
||||
import AttachMoneyIcon from '@mui/icons-material/AttachMoney';
|
||||
import SmartToyIcon from '@mui/icons-material/SmartToy';
|
||||
import { alpha } from '@mui/material/styles';
|
||||
import type { ModuleConfig } from './types';
|
||||
import { statusStyles } from './modules';
|
||||
import { CreateVideoPreview, AvatarVideoPreview, EnhanceVideoPreview } from './previews';
|
||||
import { CreateVideoPreview, AvatarVideoPreview, EnhanceVideoPreview, VideoTranslatePreview, VideoBackgroundRemoverPreview } from './previews';
|
||||
import { InfoModal } from './InfoModal';
|
||||
|
||||
interface ModuleCardProps {
|
||||
module: ModuleConfig;
|
||||
@@ -36,25 +40,47 @@ export const ModuleCard: React.FC<ModuleCardProps> = ({
|
||||
}) => {
|
||||
const status = statusStyles[module.status];
|
||||
const disabled = module.status !== 'live';
|
||||
const [openModal, setOpenModal] = useState<'perfect-for' | 'cost' | 'ai-models' | null>(null);
|
||||
|
||||
return (
|
||||
<Paper
|
||||
sx={{
|
||||
height: '100%',
|
||||
borderRadius: 4,
|
||||
p: 3,
|
||||
border: '1px solid rgba(255,255,255,0.12)',
|
||||
background: 'linear-gradient(160deg, rgba(15,23,42,0.95), rgba(30,41,59,0.92))',
|
||||
p: 3.5,
|
||||
border: isHovered
|
||||
? '2px solid rgba(139,92,246,0.6)'
|
||||
: '1px solid rgba(255,255,255,0.18)',
|
||||
background: isHovered
|
||||
? 'linear-gradient(160deg, rgba(30,41,59,0.98), rgba(51,65,85,0.95))'
|
||||
: 'linear-gradient(160deg, rgba(15,23,42,0.98), rgba(30,41,59,0.95))',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 1.75,
|
||||
gap: 2,
|
||||
position: 'relative',
|
||||
transition: 'transform 0.28s ease, box-shadow 0.28s ease, border-color 0.28s ease',
|
||||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
boxShadow: isHovered
|
||||
? '0 24px 50px rgba(79,70,229,0.32)'
|
||||
: '0 12px 28px rgba(15,23,42,0.35)',
|
||||
transform: isHovered ? 'translateY(-4px)' : 'translateY(0)',
|
||||
? '0 32px 64px rgba(79,70,229,0.4), 0 0 0 1px rgba(139,92,246,0.2), inset 0 1px 0 rgba(255,255,255,0.1)'
|
||||
: '0 8px 32px rgba(15,23,42,0.5), 0 0 0 1px rgba(255,255,255,0.05), inset 0 1px 0 rgba(255,255,255,0.05)',
|
||||
transform: isHovered ? 'translateY(-6px) scale(1.01)' : 'translateY(0) scale(1)',
|
||||
overflow: 'hidden',
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: '3px',
|
||||
background: isHovered
|
||||
? 'linear-gradient(90deg, #8b5cf6, #6366f1, #3b82f6, #8b5cf6)'
|
||||
: 'linear-gradient(90deg, rgba(139,92,246,0.3), rgba(99,102,241,0.2))',
|
||||
backgroundSize: '200% 100%',
|
||||
animation: isHovered ? 'shimmer 2s linear infinite' : 'none',
|
||||
'@keyframes shimmer': {
|
||||
'0%': { backgroundPosition: '200% 0' },
|
||||
'100%': { backgroundPosition: '-200% 0' },
|
||||
},
|
||||
},
|
||||
}}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
@@ -63,24 +89,45 @@ export const ModuleCard: React.FC<ModuleCardProps> = ({
|
||||
<Stack direction="row" spacing={1.5} alignItems="center">
|
||||
<Box
|
||||
sx={{
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 2,
|
||||
width: 52,
|
||||
height: 52,
|
||||
borderRadius: 2.5,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: alpha('#6366f1', 0.2),
|
||||
background: isHovered
|
||||
? 'linear-gradient(135deg, rgba(139,92,246,0.35), rgba(99,102,241,0.3))'
|
||||
: 'linear-gradient(135deg, rgba(99,102,241,0.25), rgba(139,92,246,0.2))',
|
||||
color: '#c7d2fe',
|
||||
fontSize: 22,
|
||||
fontSize: 26,
|
||||
border: '1px solid rgba(139,92,246,0.3)',
|
||||
boxShadow: isHovered
|
||||
? '0 8px 16px rgba(139,92,246,0.3), inset 0 1px 0 rgba(255,255,255,0.1)'
|
||||
: '0 4px 12px rgba(99,102,241,0.2)',
|
||||
transition: 'all 0.3s ease',
|
||||
}}
|
||||
>
|
||||
{module.icon}
|
||||
</Box>
|
||||
<Stack spacing={0.25}>
|
||||
<Typography variant="h6" fontWeight={700}>
|
||||
<Typography
|
||||
variant="h6"
|
||||
fontWeight={800}
|
||||
sx={{
|
||||
color: '#f1f5f9',
|
||||
fontSize: '1.25rem',
|
||||
letterSpacing: '-0.02em',
|
||||
}}
|
||||
>
|
||||
{module.title}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
color: 'rgba(203,213,225,0.9)',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{module.subtitle}
|
||||
</Typography>
|
||||
</Stack>
|
||||
@@ -89,14 +136,25 @@ export const ModuleCard: React.FC<ModuleCardProps> = ({
|
||||
label={status.label}
|
||||
size="small"
|
||||
sx={{
|
||||
backgroundColor: alpha(status.color, 0.2),
|
||||
backgroundColor: alpha(status.color, 0.25),
|
||||
color: status.color,
|
||||
fontWeight: 700,
|
||||
border: `1px solid ${alpha(status.color, 0.4)}`,
|
||||
boxShadow: `0 2px 8px ${alpha(status.color, 0.2)}`,
|
||||
fontSize: '0.7rem',
|
||||
height: 26,
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Typography variant="body2" sx={{ color: 'rgba(241,245,249,0.95)' }}>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
color: 'rgba(241,245,249,0.92)',
|
||||
lineHeight: 1.7,
|
||||
fontSize: '0.95rem',
|
||||
}}
|
||||
>
|
||||
{module.description}
|
||||
</Typography>
|
||||
|
||||
@@ -107,96 +165,223 @@ export const ModuleCard: React.FC<ModuleCardProps> = ({
|
||||
size="small"
|
||||
label={item}
|
||||
sx={{
|
||||
background: 'linear-gradient(120deg, rgba(99,102,241,0.45), rgba(14,165,233,0.38))',
|
||||
background: isHovered
|
||||
? 'linear-gradient(120deg, rgba(139,92,246,0.5), rgba(99,102,241,0.45))'
|
||||
: 'linear-gradient(120deg, rgba(99,102,241,0.4), rgba(14,165,233,0.35))',
|
||||
color: '#f8fafc',
|
||||
border: '1px solid rgba(255,255,255,0.35)',
|
||||
border: '1px solid rgba(255,255,255,0.4)',
|
||||
fontWeight: 600,
|
||||
letterSpacing: 0.2,
|
||||
letterSpacing: 0.3,
|
||||
fontSize: '0.75rem',
|
||||
height: 28,
|
||||
boxShadow: '0 2px 8px rgba(99,102,241,0.2)',
|
||||
transition: 'all 0.3s ease',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<Tooltip title={module.help || 'Guidance and intended use cases'}>
|
||||
<HelpOutlineIcon sx={{ fontSize: 18, color: 'rgba(148,163,184,0.95)' }} />
|
||||
</Tooltip>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{module.help || 'Built for creators: pick a template and we guide duration/aspect and cost.'}
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
<Divider sx={{ borderColor: 'rgba(255,255,255,0.1)' }} />
|
||||
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<InfoOutlinedIcon sx={{ fontSize: 18, color: 'rgba(148,163,184,0.9)' }} />
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{module.pricingNote || 'Cost shown before run (duration, resolution, provider).'}
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
{module.costDrivers && (
|
||||
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
|
||||
{module.costDrivers.map(driver => (
|
||||
<Chip
|
||||
key={driver}
|
||||
size="small"
|
||||
icon={<SavingsIcon sx={{ fontSize: 16 }} />}
|
||||
label={driver}
|
||||
sx={{
|
||||
backgroundColor: 'rgba(15,118,110,0.25)',
|
||||
color: '#99f6e4',
|
||||
border: '1px solid rgba(34,197,94,0.35)',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
ETA: {module.eta || 'TBD'}
|
||||
</Typography>
|
||||
{/* Info Chips */}
|
||||
<Stack direction="row" spacing={1.5} flexWrap="wrap" useFlexGap>
|
||||
{module.perfectFor && (
|
||||
<Chip
|
||||
icon={<PeopleIcon sx={{ fontSize: 18 }} />}
|
||||
label="Perfect for"
|
||||
onClick={() => setOpenModal(openModal === 'perfect-for' ? null : 'perfect-for')}
|
||||
sx={{
|
||||
background: openModal === 'perfect-for'
|
||||
? 'linear-gradient(120deg, rgba(139,92,246,0.5), rgba(99,102,241,0.45))'
|
||||
: 'linear-gradient(120deg, rgba(139,92,246,0.3), rgba(99,102,241,0.25))',
|
||||
color: '#c7d2fe',
|
||||
border: '2px solid rgba(139,92,246,0.6)',
|
||||
fontWeight: 700,
|
||||
fontSize: '0.8rem',
|
||||
height: 36,
|
||||
cursor: 'pointer',
|
||||
px: 2,
|
||||
py: 1,
|
||||
boxShadow: openModal === 'perfect-for'
|
||||
? '0 4px 12px rgba(139,92,246,0.4)'
|
||||
: '0 2px 8px rgba(139,92,246,0.2)',
|
||||
'&:hover': {
|
||||
background: 'linear-gradient(120deg, rgba(139,92,246,0.4), rgba(99,102,241,0.35))',
|
||||
border: '2px solid rgba(139,92,246,0.8)',
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: '0 6px 16px rgba(139,92,246,0.4)',
|
||||
},
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{module.costDetails && (
|
||||
<Chip
|
||||
icon={<AttachMoneyIcon sx={{ fontSize: 18 }} />}
|
||||
label="Cost depends on"
|
||||
onClick={() => setOpenModal(openModal === 'cost' ? null : 'cost')}
|
||||
sx={{
|
||||
background: openModal === 'cost'
|
||||
? 'linear-gradient(120deg, rgba(14,165,233,0.5), rgba(56,189,248,0.45))'
|
||||
: 'linear-gradient(120deg, rgba(14,165,233,0.3), rgba(56,189,248,0.25))',
|
||||
color: '#7dd3fc',
|
||||
border: '2px solid rgba(56,189,248,0.6)',
|
||||
fontWeight: 700,
|
||||
fontSize: '0.8rem',
|
||||
height: 36,
|
||||
cursor: 'pointer',
|
||||
px: 2,
|
||||
py: 1,
|
||||
boxShadow: openModal === 'cost'
|
||||
? '0 4px 12px rgba(56,189,248,0.4)'
|
||||
: '0 2px 8px rgba(56,189,248,0.2)',
|
||||
'&:hover': {
|
||||
background: 'linear-gradient(120deg, rgba(14,165,233,0.4), rgba(56,189,248,0.35))',
|
||||
border: '2px solid rgba(56,189,248,0.8)',
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: '0 6px 16px rgba(56,189,248,0.4)',
|
||||
},
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{module.aiModels && (
|
||||
<Chip
|
||||
icon={<SmartToyIcon sx={{ fontSize: 18 }} />}
|
||||
label="AI Models"
|
||||
onClick={() => setOpenModal(openModal === 'ai-models' ? null : 'ai-models')}
|
||||
sx={{
|
||||
background: openModal === 'ai-models'
|
||||
? 'linear-gradient(120deg, rgba(16,185,129,0.5), rgba(34,197,94,0.45))'
|
||||
: 'linear-gradient(120deg, rgba(16,185,129,0.3), rgba(34,197,94,0.25))',
|
||||
color: '#99f6e4',
|
||||
border: '2px solid rgba(34,197,94,0.6)',
|
||||
fontWeight: 700,
|
||||
fontSize: '0.8rem',
|
||||
height: 36,
|
||||
cursor: 'pointer',
|
||||
px: 2,
|
||||
py: 1,
|
||||
boxShadow: openModal === 'ai-models'
|
||||
? '0 4px 12px rgba(34,197,94,0.4)'
|
||||
: '0 2px 8px rgba(34,197,94,0.2)',
|
||||
'&:hover': {
|
||||
background: 'linear-gradient(120deg, rgba(16,185,129,0.4), rgba(34,197,94,0.35))',
|
||||
border: '2px solid rgba(34,197,94,0.8)',
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: '0 6px 16px rgba(34,197,94,0.4)',
|
||||
},
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{/* Visual Preview Component */}
|
||||
{module.status === 'live' && (
|
||||
<Box sx={{ mt: 1 }}>
|
||||
{(module.status === 'live' || module.status === 'beta') && (
|
||||
<Box sx={{ mt: 1.5 }}>
|
||||
{module.key === 'create' && <CreateVideoPreview />}
|
||||
{module.key === 'avatar' && <AvatarVideoPreview />}
|
||||
{module.key === 'enhance' && <EnhanceVideoPreview />}
|
||||
{module.key === 'video-translate' && <VideoTranslatePreview />}
|
||||
{module.key === 'video-background-remover' && <VideoBackgroundRemoverPreview />}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Stack direction="row" spacing={1} alignItems="center" sx={{ mt: 'auto' }}>
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={1.5}
|
||||
alignItems="center"
|
||||
sx={{
|
||||
mt: 'auto',
|
||||
pt: 2,
|
||||
borderTop: '1px solid rgba(255,255,255,0.1)',
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
size="medium"
|
||||
startIcon={disabled ? <LockIcon /> : <LaunchIcon />}
|
||||
disabled={disabled}
|
||||
onClick={() => onNavigate(module.route)}
|
||||
sx={{
|
||||
textTransform: 'none',
|
||||
fontWeight: 700,
|
||||
boxShadow: 'none',
|
||||
background: disabled ? 'rgba(148,163,184,0.25)' : 'linear-gradient(120deg,#6366f1,#8b5cf6)',
|
||||
fontSize: '0.95rem',
|
||||
px: 3,
|
||||
py: 1.25,
|
||||
background: disabled
|
||||
? 'rgba(148,163,184,0.25)'
|
||||
: isHovered
|
||||
? 'linear-gradient(120deg, #8b5cf6, #6366f1)'
|
||||
: 'linear-gradient(120deg, #6366f1, #8b5cf6)',
|
||||
boxShadow: disabled
|
||||
? 'none'
|
||||
: isHovered
|
||||
? '0 8px 24px rgba(139,92,246,0.5), 0 0 0 1px rgba(255,255,255,0.1)'
|
||||
: '0 4px 16px rgba(99,102,241,0.4)',
|
||||
'&:hover': {
|
||||
background: 'linear-gradient(120deg, #8b5cf6, #6366f1)',
|
||||
boxShadow: '0 12px 32px rgba(139,92,246,0.6), 0 0 0 1px rgba(255,255,255,0.15)',
|
||||
transform: 'translateY(-2px)',
|
||||
},
|
||||
transition: 'all 0.3s ease',
|
||||
}}
|
||||
>
|
||||
{disabled ? 'Preview' : 'Open'}
|
||||
{disabled ? 'Preview' : 'Open Studio'}
|
||||
</Button>
|
||||
<Tooltip title="Feature details & roadmap">
|
||||
<Tooltip
|
||||
title="View detailed feature documentation and use cases"
|
||||
arrow
|
||||
placement="top"
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
size="medium"
|
||||
variant="text"
|
||||
color="inherit"
|
||||
onClick={() => onNavigate(module.route)}
|
||||
sx={{ textTransform: 'none', color: '#c7d2fe' }}
|
||||
sx={{
|
||||
textTransform: 'none',
|
||||
color: 'rgba(199,210,254,0.9)',
|
||||
fontWeight: 600,
|
||||
fontSize: '0.9rem',
|
||||
'&:hover': {
|
||||
color: '#c7d2fe',
|
||||
background: 'rgba(139,92,246,0.1)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
Learn more
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
|
||||
{/* Info Modals - Open on Click */}
|
||||
{module.perfectFor && (
|
||||
<InfoModal
|
||||
open={openModal === 'perfect-for'}
|
||||
onClose={() => setOpenModal(null)}
|
||||
title={`Perfect for - ${module.title}`}
|
||||
type="perfect-for"
|
||||
perfectFor={module.perfectFor}
|
||||
/>
|
||||
)}
|
||||
{module.costDetails && (
|
||||
<InfoModal
|
||||
open={openModal === 'cost'}
|
||||
onClose={() => setOpenModal(null)}
|
||||
title={`Cost Information - ${module.title}`}
|
||||
type="cost"
|
||||
costDetails={module.costDetails}
|
||||
/>
|
||||
)}
|
||||
{module.aiModels && (
|
||||
<InfoModal
|
||||
open={openModal === 'ai-models'}
|
||||
onClose={() => setOpenModal(null)}
|
||||
title={`AI Models & Capabilities - ${module.title}`}
|
||||
type="ai-models"
|
||||
aiModels={module.aiModels}
|
||||
/>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -12,6 +12,7 @@ import TranslateIcon from '@mui/icons-material/Translate';
|
||||
import WallpaperIcon from '@mui/icons-material/Wallpaper';
|
||||
import MusicNoteIcon from '@mui/icons-material/MusicNote';
|
||||
import type { ModuleConfig } from './types';
|
||||
import type { AIModel, PerfectForUseCase, CostDetail } from './InfoModal';
|
||||
|
||||
export const statusStyles = {
|
||||
live: { label: 'Live', color: '#10b981' },
|
||||
@@ -25,45 +26,212 @@ export const videoStudioModules: ModuleConfig[] = [
|
||||
title: 'Create Studio',
|
||||
subtitle: 'Turn your ideas into videos',
|
||||
description:
|
||||
'Describe your video idea and we create it for you. Perfect for Instagram Reels, TikTok, YouTube Shorts, LinkedIn posts, and more. We automatically choose the best settings for your platform.',
|
||||
highlights: ['Text to Video', 'Image to Video', 'Platform Ready'],
|
||||
'Transform text descriptions into engaging video content instantly. Perfect for content creators producing daily social media content, marketers creating campaign videos, and businesses generating product showcases. Supports text-to-video and image-to-video with automatic platform optimization for Instagram Reels, TikTok, YouTube Shorts, and LinkedIn.',
|
||||
highlights: ['Text to Video', 'Image to Video', 'Auto Platform Optimization'],
|
||||
status: 'live',
|
||||
route: '/video-studio/create',
|
||||
pricingNote: 'Cost depends on video length and quality. We show you the price before generating.',
|
||||
pricingNote: 'Cost depends on video length and quality. We show you the exact price before generating. Typical range: $0.50-$1.50 per video.',
|
||||
eta: 'Now',
|
||||
icon: <MovieCreationIcon />,
|
||||
help: 'Perfect for creating engaging social media content. Just describe what you want and we handle the rest. Add background music or voiceover later.',
|
||||
costDrivers: ['Video length (5–10 seconds)', 'Quality (480p/720p/1080p)', 'Platform format'],
|
||||
help: 'Perfect for content creators producing daily social media content, marketers creating campaign videos, and businesses generating product showcases. Just describe your video idea (e.g., "A modern coffee shop with baristas crafting latte art") and we create it with optimal settings for your chosen platform. Add background music or voiceover in post-production.',
|
||||
costDrivers: ['Video duration (5–15 seconds recommended)', 'Resolution (480p/720p/1080p)', 'Platform format (9:16, 16:9, 1:1)'],
|
||||
perfectFor: [
|
||||
{
|
||||
title: 'Content Creators',
|
||||
description: 'Produce daily social media content for Instagram Reels, TikTok, and YouTube Shorts. Create engaging videos from simple text descriptions without video editing skills.',
|
||||
examples: ['Instagram Reels', 'TikTok Videos', 'YouTube Shorts', 'Daily Content'],
|
||||
},
|
||||
{
|
||||
title: 'Digital Marketers',
|
||||
description: 'Create campaign videos, product showcases, and promotional content quickly. Generate multiple variations for A/B testing and multi-platform campaigns.',
|
||||
examples: ['Campaign Videos', 'Product Showcases', 'Social Media Ads', 'A/B Testing'],
|
||||
},
|
||||
{
|
||||
title: 'Businesses',
|
||||
description: 'Generate professional product videos, explainer content, and brand storytelling videos. Perfect for e-commerce, SaaS, and service businesses.',
|
||||
examples: ['Product Demos', 'Explainer Videos', 'Brand Content', 'E-commerce'],
|
||||
},
|
||||
],
|
||||
costDetails: {
|
||||
factors: [
|
||||
'Video duration: 5-15 seconds recommended for optimal cost',
|
||||
'Resolution: 480p ($0.50), 720p ($0.75), 1080p ($1.00+)',
|
||||
'Platform format: Auto-optimized for Instagram, TikTok, YouTube, LinkedIn',
|
||||
'Provider selection: Auto-selects best model based on requirements',
|
||||
],
|
||||
typicalRange: '$0.50 - $1.50 per video',
|
||||
examples: [
|
||||
{ scenario: '5-second Instagram Reel (720p)', cost: '$0.50' },
|
||||
{ scenario: '10-second TikTok video (1080p)', cost: '$1.00' },
|
||||
{ scenario: '15-second LinkedIn post (1080p)', cost: '$1.50' },
|
||||
],
|
||||
},
|
||||
aiModels: [
|
||||
{
|
||||
name: 'WAN 2.5',
|
||||
provider: 'WaveSpeed AI',
|
||||
capabilities: ['Text-to-Video', 'Image-to-Video', 'High Quality', 'Fast Generation'],
|
||||
pricing: {
|
||||
model: 'per_second',
|
||||
rate: 0.05,
|
||||
unit: '/second',
|
||||
description: '$0.05 per second (minimum 5 seconds, typical 5-15 seconds)',
|
||||
},
|
||||
features: [
|
||||
'Best for short-form social media content',
|
||||
'Automatic platform optimization',
|
||||
'Motion and style control',
|
||||
'High-quality output ready for social platforms',
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Seedance 1.5 Pro',
|
||||
provider: 'WaveSpeed AI',
|
||||
capabilities: ['Text-to-Video', 'Longer Duration', 'Professional Quality'],
|
||||
pricing: {
|
||||
model: 'per_second',
|
||||
rate: 0.08,
|
||||
unit: '/second',
|
||||
description: '$0.08 per second (best for 10-30 second videos)',
|
||||
},
|
||||
features: [
|
||||
'Ideal for longer-form content',
|
||||
'Professional-grade quality',
|
||||
'Better motion continuity',
|
||||
'Suitable for YouTube and LinkedIn',
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'avatar',
|
||||
title: 'Avatar Studio',
|
||||
subtitle: 'Create talking videos from photos',
|
||||
description:
|
||||
'Upload a photo and audio to create a talking avatar. Perfect for explainer videos, tutorials, personalized messages, and social media content. Your photo comes to life with perfect lip-sync.',
|
||||
highlights: ['Talking Avatars', 'Lip-sync', 'Translation'],
|
||||
'Transform static photos into dynamic talking videos with perfect lip-sync. Ideal for content creators building personal brands, marketers creating personalized campaigns, and businesses producing explainer videos. Upload a photo and audio to generate professional talking avatars that engage audiences across social platforms.',
|
||||
highlights: ['Talking Avatars', 'Perfect Lip-sync', 'Multi-language Support'],
|
||||
status: 'beta',
|
||||
route: '/video-studio/avatar',
|
||||
pricingNote: 'Cost depends on video length and quality',
|
||||
pricingNote: 'Cost depends on video length and quality. Perfect for short-form content (5-30 seconds)',
|
||||
eta: 'Beta',
|
||||
icon: <FaceRetouchingNaturalIcon />,
|
||||
help: 'Great for creating personalized video messages, explainer videos, and tutorials. Upload your photo and audio, and we create a talking video.',
|
||||
costDrivers: ['Video length', 'Quality'],
|
||||
help: 'Perfect for content creators building personal brands, marketers creating personalized video campaigns, and businesses producing explainer videos. Upload your photo and audio, and we create a talking video with perfect lip-sync. Great for Instagram Reels, LinkedIn videos, YouTube intros, and personalized customer messages.',
|
||||
costDrivers: ['Video duration (seconds)', 'Resolution (480p/720p/1080p)'],
|
||||
perfectFor: [
|
||||
{
|
||||
title: 'Personal Branding',
|
||||
description: 'Build your personal brand with talking avatar videos. Perfect for content creators, coaches, and thought leaders who want to create engaging video content without appearing on camera.',
|
||||
examples: ['YouTube Intros', 'Instagram Reels', 'LinkedIn Videos', 'Personal Branding'],
|
||||
},
|
||||
{
|
||||
title: 'Marketing Campaigns',
|
||||
description: 'Create personalized video messages for customers, product explainers, and campaign videos. Scale personalized video content without hiring actors or video production teams.',
|
||||
examples: ['Customer Messages', 'Product Explainer', 'Campaign Videos', 'Personalization'],
|
||||
},
|
||||
{
|
||||
title: 'Business Content',
|
||||
description: 'Produce professional explainer videos, training content, and corporate communications. Transform static presentations into dynamic talking videos.',
|
||||
examples: ['Explainer Videos', 'Training Content', 'Corporate Communications', 'E-learning'],
|
||||
},
|
||||
],
|
||||
costDetails: {
|
||||
factors: [
|
||||
'Video duration: 5-30 seconds recommended',
|
||||
'Resolution: 480p ($0.20), 720p ($0.40), 1080p ($0.60+)',
|
||||
'Audio length determines video duration',
|
||||
'Perfect lip-sync quality across all resolutions',
|
||||
],
|
||||
typicalRange: '$0.20 - $0.60 per video',
|
||||
examples: [
|
||||
{ scenario: '10-second talking avatar (720p)', cost: '$0.40' },
|
||||
{ scenario: '20-second explainer video (1080p)', cost: '$0.60' },
|
||||
{ scenario: '30-second personalized message (720p)', cost: '$0.60' },
|
||||
],
|
||||
},
|
||||
aiModels: [
|
||||
{
|
||||
name: 'Hunyuan Avatar',
|
||||
provider: 'Tencent',
|
||||
capabilities: ['Talking Avatars', 'Perfect Lip-sync', 'Natural Expressions'],
|
||||
pricing: {
|
||||
model: 'per_second',
|
||||
rate: 0.02,
|
||||
unit: '/second',
|
||||
description: '$0.02 per second (minimum 5 seconds)',
|
||||
},
|
||||
features: [
|
||||
'Industry-leading lip-sync accuracy',
|
||||
'Natural facial expressions and movements',
|
||||
'Supports multiple languages',
|
||||
'High-quality output for professional use',
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'enhance',
|
||||
title: 'Enhance Studio',
|
||||
subtitle: 'Upgrade your video quality',
|
||||
description:
|
||||
'Transform low-resolution videos into professional-quality content. Upscale from 480p to 1080p or 4K, boost frame rate, and improve clarity. Perfect for upgrading social media content or preparing videos for YouTube.',
|
||||
highlights: ['Upscale Quality', 'Smooth Motion', 'Frame Rate Boost'],
|
||||
'Transform low-resolution videos into professional-quality content. Upscale from 480p to 1080p or 4K, boost frame rate from 24fps to 60fps, and dramatically improve clarity. Essential for content creators upgrading phone footage, marketers repurposing old content, and businesses preparing videos for professional presentations.',
|
||||
highlights: ['AI Upscaling', 'Frame Rate Boost', 'Professional Quality'],
|
||||
status: 'live',
|
||||
route: '/video-studio/enhance',
|
||||
pricingNote: 'Cost depends on original quality and target quality',
|
||||
pricingNote: 'Cost depends on original quality and target quality. FlashVSR AI model provides best results',
|
||||
eta: 'Now',
|
||||
icon: <HighQualityIcon />,
|
||||
help: 'Perfect for improving videos shot on phones or upgrading old content. Make your videos look professional and ready for any platform.',
|
||||
costDrivers: ['Original quality', 'Target quality', 'Video length'],
|
||||
help: 'Perfect for content creators upgrading phone footage to professional quality, marketers repurposing old content for new campaigns, and businesses preparing videos for presentations. Transform 480p phone videos into 1080p professional content ready for YouTube, LinkedIn, and marketing materials. FlashVSR AI model ensures superior upscaling with motion preservation.',
|
||||
costDrivers: ['Original resolution', 'Target resolution (1080p/4K)', 'Video duration'],
|
||||
perfectFor: [
|
||||
{
|
||||
title: 'Content Upgrading',
|
||||
description: 'Upgrade phone footage to professional quality. Transform 480p videos shot on mobile devices into 1080p content ready for YouTube, LinkedIn, and marketing materials.',
|
||||
examples: ['Phone to Professional', 'YouTube Ready', 'LinkedIn Quality', 'Marketing Materials'],
|
||||
},
|
||||
{
|
||||
title: 'Content Repurposing',
|
||||
description: 'Repurpose old content for new campaigns. Enhance archived videos, upgrade legacy content, and breathe new life into existing video assets.',
|
||||
examples: ['Archive Enhancement', 'Legacy Content', 'Campaign Repurposing', 'Asset Upgrading'],
|
||||
},
|
||||
{
|
||||
title: 'Professional Presentations',
|
||||
description: 'Prepare videos for professional presentations, client deliverables, and corporate communications. Ensure consistent quality across all video assets.',
|
||||
examples: ['Client Deliverables', 'Corporate Videos', 'Presentations', 'Professional Quality'],
|
||||
},
|
||||
],
|
||||
costDetails: {
|
||||
factors: [
|
||||
'Original resolution: Lower resolution = higher upscaling cost',
|
||||
'Target resolution: 1080p ($0.10/s), 4K ($0.20/s)',
|
||||
'Video duration: Cost scales with video length',
|
||||
'Frame rate boost: Additional cost for 60fps conversion',
|
||||
],
|
||||
typicalRange: '$0.10 - $0.20 per second',
|
||||
examples: [
|
||||
{ scenario: '480p to 1080p (10 seconds)', cost: '$1.00' },
|
||||
{ scenario: '720p to 4K (15 seconds)', cost: '$3.00' },
|
||||
{ scenario: '1080p to 4K with 60fps (20 seconds)', cost: '$4.00' },
|
||||
],
|
||||
},
|
||||
aiModels: [
|
||||
{
|
||||
name: 'FlashVSR',
|
||||
provider: 'WaveSpeed AI',
|
||||
capabilities: ['Video Upscaling', 'Motion Preservation', 'Quality Enhancement'],
|
||||
pricing: {
|
||||
model: 'per_second',
|
||||
rate: 0.10,
|
||||
unit: '/second',
|
||||
description: '$0.10 per second for 1080p, $0.20 per second for 4K',
|
||||
},
|
||||
features: [
|
||||
'Superior upscaling with motion preservation',
|
||||
'Maintains video quality and details',
|
||||
'Supports up to 4K resolution',
|
||||
'Frame rate boost to 60fps available',
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'extend',
|
||||
@@ -83,17 +251,56 @@ export const videoStudioModules: ModuleConfig[] = [
|
||||
{
|
||||
key: 'edit',
|
||||
title: 'Edit Studio',
|
||||
subtitle: 'Trim, enhance, and customize',
|
||||
subtitle: 'Trim, speed control, and stabilization',
|
||||
description:
|
||||
'Trim and cut videos, adjust speed, stabilize shaky footage, replace backgrounds, swap faces, add captions and subtitles, and color grade. All the editing tools you need in one place.',
|
||||
highlights: ['Trim & Cut', 'Background Swap', 'Add Captions'],
|
||||
status: 'coming soon',
|
||||
'Free video editing using FFmpeg: trim/cut to time range, slow motion or fast forward (0.25x–4x), and camera stabilization with vidstab. Perfect for polishing social clips and fixing shaky footage.',
|
||||
highlights: ['Trim & Cut', 'Speed Control', 'Stabilization'],
|
||||
status: 'live',
|
||||
route: '/video-studio/edit',
|
||||
pricingNote: 'Cost depends on video length and number of edits',
|
||||
eta: 'Coming soon',
|
||||
pricingNote: 'Free (FFmpeg processing)',
|
||||
eta: 'Now',
|
||||
icon: <EditIcon />,
|
||||
help: 'Complete video editing suite for content creators. Make your videos perfect before sharing on social media.',
|
||||
costDrivers: ['Video length', 'Number of edits'],
|
||||
help: 'Free editing operations: Trim video to specific time range, adjust playback speed (slow motion or fast forward), and stabilize shaky footage. More features coming soon!',
|
||||
costDrivers: ['Free - no cost'],
|
||||
perfectFor: [
|
||||
{
|
||||
title: 'Content Polishing',
|
||||
description: 'Polish your social media content with professional editing tools. Trim unwanted sections, adjust speed for dramatic effect, and stabilize shaky footage.',
|
||||
examples: ['Social Media', 'Content Polishing', 'Quick Edits', 'Post-Production'],
|
||||
},
|
||||
{
|
||||
title: 'Video Optimization',
|
||||
description: 'Optimize videos for different platforms. Trim to platform-specific durations, adjust speed for engagement, and fix camera shake.',
|
||||
examples: ['Platform Optimization', 'Duration Control', 'Speed Adjustment', 'Quality Fix'],
|
||||
},
|
||||
],
|
||||
costDetails: {
|
||||
factors: ['All operations are free', 'No cost for trim, speed, or stabilization', 'FFmpeg-based processing', 'Unlimited usage'],
|
||||
typicalRange: 'Free',
|
||||
examples: [
|
||||
{ scenario: 'Trim 10-second clip', cost: 'Free' },
|
||||
{ scenario: 'Speed adjustment (2x)', cost: 'Free' },
|
||||
{ scenario: 'Stabilize shaky footage', cost: 'Free' },
|
||||
],
|
||||
},
|
||||
aiModels: [
|
||||
{
|
||||
name: 'FFmpeg',
|
||||
provider: 'Open Source',
|
||||
capabilities: ['Video Editing', 'Trim & Cut', 'Speed Control', 'Stabilization'],
|
||||
pricing: {
|
||||
model: 'free',
|
||||
description: 'Free - No cost for all operations',
|
||||
},
|
||||
features: [
|
||||
'Professional-grade video editing',
|
||||
'Trim and cut to precise time ranges',
|
||||
'Speed control from 0.25x to 4x',
|
||||
'Camera stabilization with vidstab',
|
||||
'Text overlay and audio controls',
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'transform',
|
||||
@@ -154,6 +361,57 @@ export const videoStudioModules: ModuleConfig[] = [
|
||||
icon: <TranslateIcon />,
|
||||
help: 'Perfect for global content creators, localization, and reaching international audiences. No voice actors or dubbing needed.',
|
||||
costDrivers: ['Video duration'],
|
||||
perfectFor: [
|
||||
{
|
||||
title: 'Global Content',
|
||||
description: 'Reach international audiences with translated video content. Perfect for content creators expanding to new markets and businesses going global.',
|
||||
examples: ['International Marketing', 'Global Expansion', 'Multi-market Content', 'Localization'],
|
||||
},
|
||||
{
|
||||
title: 'Localization',
|
||||
description: 'Localize video content for different regions and languages. Maintain brand voice and messaging across all markets without hiring voice actors.',
|
||||
examples: ['Content Localization', 'Regional Adaptation', 'Brand Consistency', 'Market Entry'],
|
||||
},
|
||||
{
|
||||
title: 'Accessibility',
|
||||
description: 'Make content accessible to diverse audiences. Translate educational content, tutorials, and informational videos to multiple languages.',
|
||||
examples: ['Educational Content', 'Tutorials', 'Accessibility', 'Inclusive Content'],
|
||||
},
|
||||
],
|
||||
costDetails: {
|
||||
factors: [
|
||||
'Video duration: Cost scales with video length',
|
||||
'Language selection: All 70+ languages same price',
|
||||
'Lip-sync preservation: Automatic and included',
|
||||
'Natural voice: AI-generated voices maintain quality',
|
||||
],
|
||||
typicalRange: '$0.19 - $0.75 per video',
|
||||
examples: [
|
||||
{ scenario: '5-second video translation', cost: '$0.19' },
|
||||
{ scenario: '10-second video translation', cost: '$0.38' },
|
||||
{ scenario: '20-second video translation', cost: '$0.75' },
|
||||
],
|
||||
},
|
||||
aiModels: [
|
||||
{
|
||||
name: 'HeyGen Video Translate',
|
||||
provider: 'HeyGen',
|
||||
capabilities: ['Video Translation', 'Lip-sync Preservation', '70+ Languages', 'Natural Voice'],
|
||||
pricing: {
|
||||
model: 'per_second',
|
||||
rate: 0.0375,
|
||||
unit: '/second',
|
||||
description: '$0.0375 per second (70+ languages, 175+ dialects)',
|
||||
},
|
||||
features: [
|
||||
'Translates to 70+ languages and 175+ dialects',
|
||||
'Preserves perfect lip-sync',
|
||||
'Natural-sounding AI voices',
|
||||
'No voice actors or dubbing needed',
|
||||
'Maintains original emotion and tone',
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'video-background-remover',
|
||||
@@ -169,6 +427,59 @@ export const videoStudioModules: ModuleConfig[] = [
|
||||
icon: <WallpaperIcon />,
|
||||
help: 'Perfect for product videos, presentations, and creative content. Remove backgrounds or replace them with custom images.',
|
||||
costDrivers: ['Video duration'],
|
||||
perfectFor: [
|
||||
{
|
||||
title: 'Product Videos',
|
||||
description: 'Create professional product videos with clean backgrounds. Remove distracting backgrounds or replace with branded environments for e-commerce and marketing.',
|
||||
examples: ['E-commerce', 'Product Showcase', 'Marketing Videos', 'Branded Content'],
|
||||
},
|
||||
{
|
||||
title: 'Presentations',
|
||||
description: 'Prepare videos for presentations and corporate communications. Remove backgrounds for clean, professional look or replace with branded backgrounds.',
|
||||
examples: ['Corporate Videos', 'Presentations', 'Professional Content', 'Business Communications'],
|
||||
},
|
||||
{
|
||||
title: 'Creative Content',
|
||||
description: 'Create creative video content with custom backgrounds. Perfect for social media, advertising, and artistic projects.',
|
||||
examples: ['Social Media', 'Advertising', 'Creative Projects', 'Visual Effects'],
|
||||
},
|
||||
],
|
||||
costDetails: {
|
||||
factors: [
|
||||
'Video duration: $0.01 per second',
|
||||
'Minimum charge: $0.05 for videos ≤5 seconds',
|
||||
'Maximum charge: $6.00 for videos up to 600 seconds',
|
||||
'Background replacement: Same price as removal',
|
||||
],
|
||||
typicalRange: '$0.05 - $1.00 per video',
|
||||
examples: [
|
||||
{ scenario: '5-second background removal', cost: '$0.05' },
|
||||
{ scenario: '10-second background replacement', cost: '$0.10' },
|
||||
{ scenario: '30-second product video', cost: '$0.30' },
|
||||
],
|
||||
},
|
||||
aiModels: [
|
||||
{
|
||||
name: 'Video Background Remover',
|
||||
provider: 'WaveSpeed AI',
|
||||
capabilities: ['Background Removal', 'Background Replacement', 'Clean Matting', 'Edge-Aware Blending'],
|
||||
pricing: {
|
||||
model: 'per_second',
|
||||
rate: 0.01,
|
||||
unit: '/second',
|
||||
min: 0.05,
|
||||
max: 6.00,
|
||||
description: '$0.01 per second (min $0.05, max $6.00)',
|
||||
},
|
||||
features: [
|
||||
'Automatic background detection and removal',
|
||||
'Clean matting with edge-aware blending',
|
||||
'Custom background replacement support',
|
||||
'Transparent background option',
|
||||
'Production-ready quality output',
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'add-audio-to-video',
|
||||
@@ -184,20 +495,81 @@ export const videoStudioModules: ModuleConfig[] = [
|
||||
icon: <MusicNoteIcon />,
|
||||
help: 'Perfect for post-production, social content, and prototyping. Use optional text prompts to guide specific sounds or let AI automatically generate appropriate audio based on visual cues.',
|
||||
costDrivers: ['Video duration'],
|
||||
perfectFor: [
|
||||
{
|
||||
title: 'Post-Production',
|
||||
description: 'Add professional Foley and ambient audio to videos. Perfect for film, animation, and video production workflows.',
|
||||
examples: ['Film Production', 'Animation', 'Video Production', 'Post-Production'],
|
||||
},
|
||||
{
|
||||
title: 'Social Content',
|
||||
description: 'Generate audio for social media content. Create engaging audio tracks for silent footage or enhance existing videos.',
|
||||
examples: ['Social Media', 'Content Creation', 'Silent Footage', 'Audio Enhancement'],
|
||||
},
|
||||
],
|
||||
costDetails: {
|
||||
factors: [
|
||||
'Model selection: Hunyuan (per-second) or Think Sound (flat rate)',
|
||||
'Video duration: Longer videos cost more with Hunyuan',
|
||||
'Audio quality: 48 kHz hi-fi output',
|
||||
'Optional text prompts for sound guidance',
|
||||
],
|
||||
typicalRange: '$0.05 - $0.20 per video',
|
||||
examples: [
|
||||
{ scenario: '5-second video (Hunyuan)', cost: '$0.10' },
|
||||
{ scenario: '10-second video (Think Sound)', cost: '$0.05' },
|
||||
{ scenario: '15-second video (Hunyuan)', cost: '$0.30' },
|
||||
],
|
||||
},
|
||||
aiModels: [
|
||||
{
|
||||
name: 'Hunyuan Video Foley',
|
||||
provider: 'Tencent',
|
||||
capabilities: ['Foley Generation', '48 kHz Hi-Fi', 'Multi-Scene Sync'],
|
||||
pricing: {
|
||||
model: 'per_second',
|
||||
rate: 0.02,
|
||||
unit: '/second',
|
||||
description: '$0.02 per second (48 kHz hi-fi output)',
|
||||
},
|
||||
features: [
|
||||
'48 kHz hi-fi audio quality',
|
||||
'Multi-scene synchronization',
|
||||
'Timing-accurate audio tracks',
|
||||
'Optional text prompt control',
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Think Sound',
|
||||
provider: 'WaveSpeed AI',
|
||||
capabilities: ['Context-Aware Audio', 'Prompt-Guided', 'Flat Rate'],
|
||||
pricing: {
|
||||
model: 'flat_rate',
|
||||
rate: 0.05,
|
||||
description: '$0.05 per video (flat rate, any duration)',
|
||||
},
|
||||
features: [
|
||||
'Context-aware sound generation',
|
||||
'Built-in prompt enhancer',
|
||||
'Flat rate pricing (any duration)',
|
||||
'Best for consistent pricing',
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'library',
|
||||
title: 'Asset Library',
|
||||
subtitle: 'Organize and manage your videos',
|
||||
description:
|
||||
'Keep all your videos organized with AI-powered tagging, version tracking, usage analytics, and secure sharing. Manage your video content library like a pro.',
|
||||
highlights: ['AI Tagging', 'Version Control', 'Usage Analytics'],
|
||||
status: 'beta',
|
||||
'Search, filter, and organize all your video assets. Create collections, mark favorites, track usage, and manage your video content library with powerful search and filtering tools.',
|
||||
highlights: ['Search & Filter', 'Collections', 'Favorites', 'Usage Tracking'],
|
||||
status: 'live',
|
||||
route: '/video-studio/library',
|
||||
pricingNote: 'Storage and download costs',
|
||||
eta: 'Beta',
|
||||
pricingNote: 'Free (storage included)',
|
||||
eta: 'Now',
|
||||
icon: <LibraryBooksIcon />,
|
||||
help: 'Perfect for content creators managing multiple videos. Keep everything organized, track usage, and share securely.',
|
||||
costDrivers: ['Storage space', 'Downloads'],
|
||||
help: 'Perfect for content creators managing multiple videos. Search by title, model, or prompt. Create collections to organize videos by project or campaign. Track downloads and usage.',
|
||||
costDrivers: ['Free - no cost'],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import React from 'react';
|
||||
import { Box, Stack, Typography, Chip } from '@mui/material';
|
||||
import { OptimizedVideo } from '../../../ImageStudio/dashboard/utils/OptimizedVideo';
|
||||
|
||||
export const VideoBackgroundRemoverPreview: React.FC = () => {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
mt: 2,
|
||||
borderRadius: 3,
|
||||
border: '3px solid',
|
||||
borderImage: 'linear-gradient(135deg, rgba(239,68,68,0.8), rgba(249,115,22,0.8), rgba(251,191,36,0.8)) 1',
|
||||
overflow: 'hidden',
|
||||
height: { xs: 260, md: 300 },
|
||||
display: 'flex',
|
||||
background: '#0f172a',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
flex: '0 0 auto',
|
||||
width: '50%',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<OptimizedVideo
|
||||
src="/videos/scene_1_user_33Gz1FPI86V_0a5d0d71.mp4"
|
||||
alt="Original video with background"
|
||||
controls
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
preload="metadata"
|
||||
sx={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
}}
|
||||
/>
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 16,
|
||||
left: 16,
|
||||
background: 'rgba(239,68,68,0.9)',
|
||||
color: '#fff',
|
||||
px: 2,
|
||||
py: 1,
|
||||
borderRadius: 2,
|
||||
fontWeight: 700,
|
||||
fontSize: '0.85rem',
|
||||
}}
|
||||
>
|
||||
Original
|
||||
</Box>
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
flex: '0 0 auto',
|
||||
width: '50%',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
background: 'linear-gradient(135deg, #1e293b, #334155)',
|
||||
}}
|
||||
>
|
||||
<OptimizedVideo
|
||||
src="/videos/text-video-voiceover.mp4"
|
||||
alt="Background removed video"
|
||||
controls
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
preload="metadata"
|
||||
sx={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
}}
|
||||
/>
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 16,
|
||||
right: 16,
|
||||
background: 'rgba(16,185,129,0.9)',
|
||||
color: '#fff',
|
||||
px: 2,
|
||||
py: 1,
|
||||
borderRadius: 2,
|
||||
fontWeight: 700,
|
||||
fontSize: '0.85rem',
|
||||
}}
|
||||
>
|
||||
Background Removed
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
bottom: 16,
|
||||
left: 16,
|
||||
right: 16,
|
||||
background: 'rgba(15,23,42,0.9)',
|
||||
color: '#fff',
|
||||
p: 1.5,
|
||||
borderRadius: 2,
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
|
||||
<Chip
|
||||
size="small"
|
||||
label="Clean Matting"
|
||||
sx={{ background: '#10b981', color: '#fff', fontWeight: 600, fontSize: '0.7rem' }}
|
||||
/>
|
||||
<Chip
|
||||
size="small"
|
||||
label="$0.01/s"
|
||||
sx={{ background: '#3b82f6', color: '#fff', fontWeight: 600, fontSize: '0.7rem' }}
|
||||
/>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,103 @@
|
||||
import React from 'react';
|
||||
import { Box, Stack, Typography, Chip } from '@mui/material';
|
||||
import { OptimizedVideo } from '../../../ImageStudio/dashboard/utils/OptimizedVideo';
|
||||
|
||||
export const VideoTranslatePreview: React.FC = () => {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
mt: 2,
|
||||
borderRadius: 3,
|
||||
border: '3px solid',
|
||||
borderImage: 'linear-gradient(135deg, rgba(14,165,233,0.8), rgba(99,102,241,0.8), rgba(139,92,246,0.8)) 1',
|
||||
overflow: 'hidden',
|
||||
height: { xs: 260, md: 300 },
|
||||
display: 'flex',
|
||||
background: '#0f172a',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
flex: '0 0 auto',
|
||||
width: '70%',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<OptimizedVideo
|
||||
src="/videos/text-video-voiceover.mp4"
|
||||
alt="Video translation example"
|
||||
controls
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
preload="metadata"
|
||||
sx={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
}}
|
||||
/>
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 16,
|
||||
left: 16,
|
||||
background: 'rgba(14,165,233,0.9)',
|
||||
color: '#fff',
|
||||
px: 2,
|
||||
py: 1,
|
||||
borderRadius: 2,
|
||||
fontWeight: 700,
|
||||
fontSize: '0.85rem',
|
||||
}}
|
||||
>
|
||||
Original: English
|
||||
</Box>
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
flex: '0 0 auto',
|
||||
width: '30%',
|
||||
background: 'rgba(248,250,252,0.95)',
|
||||
color: '#0f172a',
|
||||
p: 3,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 1.5,
|
||||
boxShadow: '-12px 0 24px rgba(15,23,42,0.25)',
|
||||
}}
|
||||
>
|
||||
<Typography variant="overline" sx={{ letterSpacing: 1.5, color: '#0ea5e9', fontWeight: 700 }}>
|
||||
Translate to 70+ Languages
|
||||
</Typography>
|
||||
<Typography variant="subtitle2" fontWeight={700}>
|
||||
AI Video Translation
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontSize: '0.85rem' }}>
|
||||
Preserves lip-sync and natural voice. Perfect for global content, localization, and reaching international audiences.
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={1} flexWrap="wrap">
|
||||
<Chip
|
||||
size="small"
|
||||
label="70+ Languages"
|
||||
sx={{ background: '#cffafe', color: '#0f766e', borderRadius: 999, fontWeight: 600 }}
|
||||
/>
|
||||
<Chip
|
||||
size="small"
|
||||
label="Lip-sync"
|
||||
sx={{ background: '#ede9fe', color: '#4c1d95', borderRadius: 999, fontWeight: 600 }}
|
||||
/>
|
||||
<Chip
|
||||
size="small"
|
||||
label="$0.0375/s"
|
||||
sx={{ background: '#dcfce7', color: '#166534', borderRadius: 999, fontWeight: 600 }}
|
||||
/>
|
||||
</Stack>
|
||||
<Typography variant="caption" sx={{ color: '#64748b', mt: 0.5 }}>
|
||||
Best for: Global content creators, localization, international marketing
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -1,3 +1,5 @@
|
||||
export { CreateVideoPreview } from './CreateVideoPreview';
|
||||
export { AvatarVideoPreview } from './AvatarVideoPreview';
|
||||
export { EnhanceVideoPreview } from './EnhanceVideoPreview';
|
||||
export { VideoTranslatePreview } from './VideoTranslatePreview';
|
||||
export { VideoBackgroundRemoverPreview } from './VideoBackgroundRemoverPreview';
|
||||
@@ -1,3 +1,7 @@
|
||||
import type { AIModel, PerfectForUseCase, CostDetail } from './InfoModal';
|
||||
|
||||
export type { AIModel, PerfectForUseCase, CostDetail };
|
||||
|
||||
export type ModuleStatus = 'live' | 'beta' | 'coming soon';
|
||||
|
||||
export interface ModuleConfig {
|
||||
@@ -13,4 +17,7 @@ export interface ModuleConfig {
|
||||
icon?: React.ReactNode;
|
||||
help?: string;
|
||||
costDrivers?: string[];
|
||||
perfectFor?: PerfectForUseCase[];
|
||||
costDetails?: CostDetail;
|
||||
aiModels?: AIModel[];
|
||||
}
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import React from 'react';
|
||||
import ModulePlaceholder from '../ModulePlaceholder';
|
||||
|
||||
export const EditVideo: React.FC = () => {
|
||||
return (
|
||||
<ModulePlaceholder
|
||||
title="Edit Studio"
|
||||
subtitle="Trim, replace, captions"
|
||||
status="coming soon"
|
||||
description="Non-destructive trims, speed changes, stabilization, background replace, object/face swap, captions/subtitles."
|
||||
bullets={[
|
||||
'Use cases: polish social clips, remove sections, localize with captions',
|
||||
'Planned: timeline editor, region/face selection, auto-captions',
|
||||
'Guardrails: duration caps per tier, cost shown before edits run',
|
||||
]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditVideo;
|
||||
@@ -0,0 +1,448 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Grid,
|
||||
Box,
|
||||
Button,
|
||||
Typography,
|
||||
Stack,
|
||||
CircularProgress,
|
||||
LinearProgress,
|
||||
Alert,
|
||||
Paper,
|
||||
} from '@mui/material';
|
||||
import { VideoStudioLayout } from '../../VideoStudioLayout';
|
||||
import { useEditVideo } from './hooks/useEditVideo';
|
||||
import {
|
||||
VideoUpload,
|
||||
OperationSelector,
|
||||
TrimSettings,
|
||||
SpeedSettings,
|
||||
StabilizeSettings,
|
||||
TextOverlaySettings,
|
||||
VolumeSettings,
|
||||
NormalizeSettings,
|
||||
DenoiseSettings,
|
||||
} from './components';
|
||||
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
||||
import ErrorIcon from '@mui/icons-material/Error';
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
|
||||
export const EditVideo: React.FC = () => {
|
||||
const {
|
||||
videoFile,
|
||||
videoPreview,
|
||||
videoDuration,
|
||||
setVideoFile,
|
||||
operation,
|
||||
setOperation,
|
||||
startTime,
|
||||
endTime,
|
||||
maxDuration,
|
||||
trimMode,
|
||||
setStartTime,
|
||||
setEndTime,
|
||||
setMaxDuration,
|
||||
setTrimMode,
|
||||
speedFactor,
|
||||
setSpeedFactor,
|
||||
smoothing,
|
||||
setSmoothing,
|
||||
overlayText,
|
||||
textPosition,
|
||||
fontSize,
|
||||
fontColor,
|
||||
backgroundColor,
|
||||
textStartTime,
|
||||
textEndTime,
|
||||
setOverlayText,
|
||||
setTextPosition,
|
||||
setFontSize,
|
||||
setFontColor,
|
||||
setBackgroundColor,
|
||||
setTextStartTime,
|
||||
setTextEndTime,
|
||||
volumeFactor,
|
||||
setVolumeFactor,
|
||||
targetLevel,
|
||||
setTargetLevel,
|
||||
denoiseStrength,
|
||||
setDenoiseStrength,
|
||||
editing,
|
||||
progress,
|
||||
error,
|
||||
result,
|
||||
canEdit,
|
||||
costHint,
|
||||
operationDescription,
|
||||
processVideo,
|
||||
reset,
|
||||
} = useEditVideo();
|
||||
|
||||
return (
|
||||
<VideoStudioLayout
|
||||
headerProps={{
|
||||
title: 'Edit Studio',
|
||||
subtitle:
|
||||
'Trim, adjust speed, stabilize, add text, and enhance audio. All operations are free using FFmpeg.',
|
||||
}}
|
||||
>
|
||||
<Grid container spacing={3}>
|
||||
{/* Left Panel - Upload & Settings */}
|
||||
<Grid item xs={12} lg={5}>
|
||||
<Stack spacing={2}>
|
||||
<VideoUpload
|
||||
videoPreview={videoPreview}
|
||||
videoDuration={videoDuration}
|
||||
onVideoSelect={setVideoFile}
|
||||
/>
|
||||
|
||||
{videoFile && (
|
||||
<>
|
||||
<OperationSelector
|
||||
selectedOperation={operation}
|
||||
onOperationChange={setOperation}
|
||||
/>
|
||||
|
||||
{operation === 'trim' && (
|
||||
<TrimSettings
|
||||
videoDuration={videoDuration}
|
||||
startTime={startTime}
|
||||
endTime={endTime}
|
||||
maxDuration={maxDuration}
|
||||
trimMode={trimMode}
|
||||
onStartTimeChange={setStartTime}
|
||||
onEndTimeChange={setEndTime}
|
||||
onMaxDurationChange={setMaxDuration}
|
||||
onTrimModeChange={setTrimMode}
|
||||
/>
|
||||
)}
|
||||
|
||||
{operation === 'speed' && (
|
||||
<SpeedSettings
|
||||
videoDuration={videoDuration}
|
||||
speedFactor={speedFactor}
|
||||
onSpeedFactorChange={setSpeedFactor}
|
||||
/>
|
||||
)}
|
||||
|
||||
{operation === 'stabilize' && (
|
||||
<StabilizeSettings
|
||||
smoothing={smoothing}
|
||||
onSmoothingChange={setSmoothing}
|
||||
/>
|
||||
)}
|
||||
|
||||
{operation === 'text' && (
|
||||
<TextOverlaySettings
|
||||
text={overlayText}
|
||||
position={textPosition}
|
||||
fontSize={fontSize}
|
||||
fontColor={fontColor}
|
||||
backgroundColor={backgroundColor}
|
||||
startTime={textStartTime}
|
||||
endTime={textEndTime}
|
||||
videoDuration={videoDuration}
|
||||
onTextChange={setOverlayText}
|
||||
onPositionChange={setTextPosition}
|
||||
onFontSizeChange={setFontSize}
|
||||
onFontColorChange={setFontColor}
|
||||
onBackgroundColorChange={setBackgroundColor}
|
||||
onStartTimeChange={setTextStartTime}
|
||||
onEndTimeChange={setTextEndTime}
|
||||
/>
|
||||
)}
|
||||
|
||||
{operation === 'volume' && (
|
||||
<VolumeSettings
|
||||
volumeFactor={volumeFactor}
|
||||
onVolumeFactorChange={setVolumeFactor}
|
||||
/>
|
||||
)}
|
||||
|
||||
{operation === 'normalize' && (
|
||||
<NormalizeSettings
|
||||
targetLevel={targetLevel}
|
||||
onTargetLevelChange={setTargetLevel}
|
||||
/>
|
||||
)}
|
||||
|
||||
{operation === 'denoise' && (
|
||||
<DenoiseSettings
|
||||
strength={denoiseStrength}
|
||||
onStrengthChange={setDenoiseStrength}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<Box>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="contained"
|
||||
size="large"
|
||||
startIcon={
|
||||
editing ? (
|
||||
<CircularProgress size={20} color="inherit" />
|
||||
) : (
|
||||
<EditIcon />
|
||||
)
|
||||
}
|
||||
onClick={processVideo}
|
||||
disabled={!canEdit || editing}
|
||||
sx={{
|
||||
py: 1.5,
|
||||
backgroundColor: '#3b82f6',
|
||||
'&:hover': {
|
||||
backgroundColor: '#2563eb',
|
||||
},
|
||||
'&:disabled': {
|
||||
backgroundColor: '#cbd5e1',
|
||||
color: '#94a3b8',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{editing ? 'Processing...' : 'Process Video'}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{videoFile && (
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
borderRadius: 1,
|
||||
backgroundColor: '#f8fafc',
|
||||
border: '1px solid #e2e8f0',
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center">
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Cost:
|
||||
</Typography>
|
||||
<Typography variant="body2" fontWeight={600} color="#10b981">
|
||||
{costHint}
|
||||
</Typography>
|
||||
</Stack>
|
||||
{operationDescription && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{ mt: 1, display: 'block' }}
|
||||
>
|
||||
{operationDescription}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
</Grid>
|
||||
|
||||
{/* Right Panel - Preview & Result */}
|
||||
<Grid item xs={12} lg={7}>
|
||||
<Stack spacing={3}>
|
||||
{/* Progress */}
|
||||
{editing && (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 3,
|
||||
borderRadius: 2,
|
||||
border: '1px solid #e2e8f0',
|
||||
backgroundColor: '#f8fafc',
|
||||
}}
|
||||
>
|
||||
<Stack spacing={2}>
|
||||
<Stack direction="row" alignItems="center" spacing={1}>
|
||||
<CircularProgress size={20} />
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Processing video...
|
||||
</Typography>
|
||||
</Stack>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={progress}
|
||||
sx={{
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
backgroundColor: '#e2e8f0',
|
||||
'& .MuiLinearProgress-bar': {
|
||||
backgroundColor: '#3b82f6',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Typography variant="caption" color="text.secondary" textAlign="center">
|
||||
{progress.toFixed(0)}% complete
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<Alert
|
||||
severity="error"
|
||||
icon={<ErrorIcon />}
|
||||
onClose={() => reset()}
|
||||
sx={{ borderRadius: 2 }}
|
||||
>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Result */}
|
||||
{result && (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 3,
|
||||
borderRadius: 2,
|
||||
border: '2px solid #10b981',
|
||||
backgroundColor: '#f0fdf4',
|
||||
}}
|
||||
>
|
||||
<Stack spacing={2}>
|
||||
<Stack direction="row" alignItems="center" spacing={1}>
|
||||
<CheckCircleIcon sx={{ color: '#10b981' }} />
|
||||
<Typography variant="h6" color="#0f172a" fontWeight={600}>
|
||||
Video Processed Successfully!
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
<video
|
||||
src={result.video_url}
|
||||
controls
|
||||
style={{
|
||||
width: '100%',
|
||||
maxHeight: '400px',
|
||||
borderRadius: '8px',
|
||||
objectFit: 'contain',
|
||||
backgroundColor: '#000',
|
||||
}}
|
||||
/>
|
||||
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center">
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Operation: <strong>{result.edit_type}</strong>
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Cost: <strong>${result.cost.toFixed(4)}</strong>
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
<Stack direction="row" spacing={2}>
|
||||
<Button
|
||||
variant="contained"
|
||||
href={result.video_url}
|
||||
download
|
||||
sx={{
|
||||
backgroundColor: '#10b981',
|
||||
'&:hover': { backgroundColor: '#059669' },
|
||||
}}
|
||||
>
|
||||
Download Video
|
||||
</Button>
|
||||
<Button variant="outlined" onClick={reset}>
|
||||
Edit Another Video
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* Info Box */}
|
||||
{!editing && !result && (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 3,
|
||||
borderRadius: 2,
|
||||
border: '1px solid #e2e8f0',
|
||||
backgroundColor: '#f8fafc',
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
sx={{ mb: 2, fontWeight: 600, color: '#0f172a' }}
|
||||
>
|
||||
About Edit Studio
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
Edit Studio provides free video editing operations using FFmpeg:
|
||||
</Typography>
|
||||
|
||||
<Typography variant="caption" color="text.secondary" fontWeight={600} sx={{ mb: 1, display: 'block' }}>
|
||||
Video Processing
|
||||
</Typography>
|
||||
<Stack spacing={1} sx={{ mb: 2 }}>
|
||||
<Box>
|
||||
<Typography variant="body2" fontWeight={600} color="#0f172a">
|
||||
Trim & Cut
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Cut video to specific time range or limit to a maximum duration.
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="body2" fontWeight={600} color="#0f172a">
|
||||
Speed Control
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Slow motion (0.25x-0.5x) or fast forward (1.5x-4x).
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="body2" fontWeight={600} color="#0f172a">
|
||||
Stabilization
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Reduce camera shake using FFmpeg's vidstab filter.
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
<Typography variant="caption" color="text.secondary" fontWeight={600} sx={{ mb: 1, display: 'block' }}>
|
||||
Text & Audio
|
||||
</Typography>
|
||||
<Stack spacing={1}>
|
||||
<Box>
|
||||
<Typography variant="body2" fontWeight={600} color="#0f172a">
|
||||
Text Overlay
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Add captions, titles, or watermarks with customizable style.
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="body2" fontWeight={600} color="#0f172a">
|
||||
Volume Control
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Mute, reduce, or boost audio volume.
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="body2" fontWeight={600} color="#0f172a">
|
||||
Audio Normalization
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
EBU R128 loudness normalization for streaming platforms.
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="body2" fontWeight={600} color="#0f172a">
|
||||
Noise Reduction
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Remove background noise like AC, hum, or hiss.
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Paper>
|
||||
)}
|
||||
</Stack>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</VideoStudioLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditVideo;
|
||||
@@ -0,0 +1,137 @@
|
||||
import React from 'react';
|
||||
import { Box, Typography, Paper, Slider, Stack, Chip } from '@mui/material';
|
||||
import NoiseAwareIcon from '@mui/icons-material/NoiseAware';
|
||||
|
||||
interface DenoiseSettingsProps {
|
||||
strength: number;
|
||||
onStrengthChange: (value: number) => void;
|
||||
}
|
||||
|
||||
export const DenoiseSettings: React.FC<DenoiseSettingsProps> = ({
|
||||
strength,
|
||||
onStrengthChange,
|
||||
}) => {
|
||||
const getStrengthLevel = (value: number) => {
|
||||
if (value <= 0.3) return { label: 'Light', color: '#10b981', description: 'Subtle cleanup, preserves original audio quality' };
|
||||
if (value <= 0.6) return { label: 'Moderate', color: '#3b82f6', description: 'Good for background noise like AC, fans' };
|
||||
return { label: 'Strong', color: '#f59e0b', description: 'Heavy noise, may affect voice clarity' };
|
||||
};
|
||||
|
||||
const level = getStrengthLevel(strength);
|
||||
|
||||
return (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 3,
|
||||
borderRadius: 2,
|
||||
border: '1px solid #e2e8f0',
|
||||
backgroundColor: '#ffffff',
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 2 }}>
|
||||
<NoiseAwareIcon sx={{ color: '#3b82f6' }} />
|
||||
<Typography variant="subtitle2" sx={{ color: '#0f172a', fontWeight: 700 }}>
|
||||
Noise Reduction Settings
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
<Stack spacing={3}>
|
||||
{/* Strength Display */}
|
||||
<Box>
|
||||
<Stack direction="row" spacing={2} alignItems="center" sx={{ mb: 2 }}>
|
||||
<Typography variant="h4" fontWeight={700} color="#0f172a">
|
||||
{Math.round(strength * 100)}%
|
||||
</Typography>
|
||||
<Chip
|
||||
label={level.label}
|
||||
size="small"
|
||||
sx={{
|
||||
backgroundColor: level.color,
|
||||
color: '#fff',
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
<Slider
|
||||
value={strength}
|
||||
onChange={(_, value) => onStrengthChange(value as number)}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.1}
|
||||
marks={[
|
||||
{ value: 0, label: '0%' },
|
||||
{ value: 0.3, label: '30%' },
|
||||
{ value: 0.5, label: '50%' },
|
||||
{ value: 0.7, label: '70%' },
|
||||
{ value: 1, label: '100%' },
|
||||
]}
|
||||
sx={{
|
||||
color: level.color,
|
||||
'& .MuiSlider-markLabel': {
|
||||
fontSize: '0.75rem',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Current Level Description */}
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
borderRadius: 2,
|
||||
backgroundColor: '#f1f5f9',
|
||||
border: '1px solid #e2e8f0',
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
<strong>{level.label} Reduction:</strong> {level.description}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Tips */}
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
borderRadius: 2,
|
||||
backgroundColor: '#dbeafe',
|
||||
border: '1px solid #93c5fd',
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" color="#1e40af" sx={{ mb: 1 }}>
|
||||
<strong>💡 Tips for Best Results</strong>
|
||||
</Typography>
|
||||
<Stack component="ul" spacing={0.5} sx={{ pl: 2, m: 0 }}>
|
||||
<Typography component="li" variant="caption" color="#1e40af">
|
||||
Start with light reduction and increase if needed
|
||||
</Typography>
|
||||
<Typography component="li" variant="caption" color="#1e40af">
|
||||
High values may make voices sound muffled
|
||||
</Typography>
|
||||
<Typography component="li" variant="caption" color="#1e40af">
|
||||
Works best on constant background noise (AC, hum)
|
||||
</Typography>
|
||||
<Typography component="li" variant="caption" color="#1e40af">
|
||||
May not remove intermittent noises (clicks, pops)
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
{strength > 0.7 && (
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
borderRadius: 2,
|
||||
backgroundColor: '#fef3c7',
|
||||
border: '1px solid #fbbf24',
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" color="#92400e">
|
||||
⚠️ High strength may affect audio quality. Consider using a lower setting and applying
|
||||
normalization after.
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,119 @@
|
||||
import React from 'react';
|
||||
import { Box, Typography, Paper, Slider, Stack, Chip, Select, MenuItem, FormControl, InputLabel } from '@mui/material';
|
||||
import TuneIcon from '@mui/icons-material/Tune';
|
||||
|
||||
interface NormalizeSettingsProps {
|
||||
targetLevel: number;
|
||||
onTargetLevelChange: (value: number) => void;
|
||||
}
|
||||
|
||||
const presets = [
|
||||
{ value: -14, label: 'Streaming (YouTube, Spotify)', description: '-14 LUFS' },
|
||||
{ value: -16, label: 'Podcasts', description: '-16 LUFS' },
|
||||
{ value: -23, label: 'TV Broadcast (EBU R128)', description: '-23 LUFS' },
|
||||
{ value: -24, label: 'US Broadcast (ATSC A/85)', description: '-24 LUFS' },
|
||||
];
|
||||
|
||||
export const NormalizeSettings: React.FC<NormalizeSettingsProps> = ({
|
||||
targetLevel,
|
||||
onTargetLevelChange,
|
||||
}) => {
|
||||
const currentPreset = presets.find((p) => p.value === targetLevel);
|
||||
|
||||
return (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 3,
|
||||
borderRadius: 2,
|
||||
border: '1px solid #e2e8f0',
|
||||
backgroundColor: '#ffffff',
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 2 }}>
|
||||
<TuneIcon sx={{ color: '#3b82f6' }} />
|
||||
<Typography variant="subtitle2" sx={{ color: '#0f172a', fontWeight: 700 }}>
|
||||
Audio Normalization Settings
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
<Stack spacing={3}>
|
||||
{/* Preset Selection */}
|
||||
<FormControl fullWidth size="small">
|
||||
<InputLabel>Preset</InputLabel>
|
||||
<Select
|
||||
value={targetLevel}
|
||||
label="Preset"
|
||||
onChange={(e) => onTargetLevelChange(e.target.value as number)}
|
||||
>
|
||||
{presets.map((preset) => (
|
||||
<MenuItem key={preset.value} value={preset.value}>
|
||||
<Stack direction="row" spacing={1} alignItems="center" justifyContent="space-between" width="100%">
|
||||
<span>{preset.label}</span>
|
||||
<Chip label={preset.description} size="small" variant="outlined" />
|
||||
</Stack>
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
{/* Manual Slider */}
|
||||
<Box>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
|
||||
Target Level: {targetLevel} LUFS
|
||||
</Typography>
|
||||
<Slider
|
||||
value={targetLevel}
|
||||
onChange={(_, value) => onTargetLevelChange(value as number)}
|
||||
min={-30}
|
||||
max={-10}
|
||||
step={1}
|
||||
marks={[
|
||||
{ value: -30, label: '-30' },
|
||||
{ value: -23, label: '-23' },
|
||||
{ value: -16, label: '-16' },
|
||||
{ value: -14, label: '-14' },
|
||||
{ value: -10, label: '-10' },
|
||||
]}
|
||||
sx={{ color: '#3b82f6' }}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Info Box */}
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
borderRadius: 2,
|
||||
backgroundColor: '#dbeafe',
|
||||
border: '1px solid #93c5fd',
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" color="#1e40af" sx={{ mb: 1 }}>
|
||||
<strong>EBU R128 Normalization</strong>
|
||||
</Typography>
|
||||
<Typography variant="caption" color="#1e40af">
|
||||
{currentPreset
|
||||
? `Using ${currentPreset.label} preset (${currentPreset.description}). This ensures consistent audio levels across your content.`
|
||||
: `Custom level: ${targetLevel} LUFS. Lower values = quieter, higher values = louder.`}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* LUFS Explanation */}
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
borderRadius: 2,
|
||||
backgroundColor: '#f1f5f9',
|
||||
border: '1px solid #e2e8f0',
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
<strong>What is LUFS?</strong> Loudness Units relative to Full Scale (LUFS) is the
|
||||
industry standard for measuring audio loudness. It accounts for human perception,
|
||||
making volume levels consistent across different content.
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,182 @@
|
||||
import React from 'react';
|
||||
import { Box, Typography, Paper, Stack, Chip, Divider } from '@mui/material';
|
||||
import ContentCutIcon from '@mui/icons-material/ContentCut';
|
||||
import SpeedIcon from '@mui/icons-material/Speed';
|
||||
import CameraAltIcon from '@mui/icons-material/CameraAlt';
|
||||
import TextFieldsIcon from '@mui/icons-material/TextFields';
|
||||
import VolumeUpIcon from '@mui/icons-material/VolumeUp';
|
||||
import TuneIcon from '@mui/icons-material/Tune';
|
||||
import NoiseAwareIcon from '@mui/icons-material/NoiseAware';
|
||||
import type { EditOperation } from '../hooks/useEditVideo';
|
||||
|
||||
interface OperationSelectorProps {
|
||||
selectedOperation: EditOperation;
|
||||
onOperationChange: (operation: EditOperation) => void;
|
||||
}
|
||||
|
||||
interface OperationInfo {
|
||||
key: EditOperation;
|
||||
label: string;
|
||||
description: string;
|
||||
icon: React.ReactNode;
|
||||
phase: 1 | 2;
|
||||
}
|
||||
|
||||
const operations: OperationInfo[] = [
|
||||
// Phase 1 - Video Operations
|
||||
{
|
||||
key: 'trim',
|
||||
label: 'Trim & Cut',
|
||||
description: 'Cut video to specific time range or max duration',
|
||||
icon: <ContentCutIcon />,
|
||||
phase: 1,
|
||||
},
|
||||
{
|
||||
key: 'speed',
|
||||
label: 'Speed Control',
|
||||
description: 'Slow motion (0.25x-0.5x) or fast forward (1.5x-4x)',
|
||||
icon: <SpeedIcon />,
|
||||
phase: 1,
|
||||
},
|
||||
{
|
||||
key: 'stabilize',
|
||||
label: 'Stabilize',
|
||||
description: 'Reduce camera shake with FFmpeg vidstab',
|
||||
icon: <CameraAltIcon />,
|
||||
phase: 1,
|
||||
},
|
||||
// Phase 2 - Text & Audio Operations
|
||||
{
|
||||
key: 'text',
|
||||
label: 'Text Overlay',
|
||||
description: 'Add text captions, titles, or watermarks',
|
||||
icon: <TextFieldsIcon />,
|
||||
phase: 2,
|
||||
},
|
||||
{
|
||||
key: 'volume',
|
||||
label: 'Volume Control',
|
||||
description: 'Adjust audio volume (mute, reduce, boost)',
|
||||
icon: <VolumeUpIcon />,
|
||||
phase: 2,
|
||||
},
|
||||
{
|
||||
key: 'normalize',
|
||||
label: 'Normalize Audio',
|
||||
description: 'EBU R128 loudness normalization for streaming',
|
||||
icon: <TuneIcon />,
|
||||
phase: 2,
|
||||
},
|
||||
{
|
||||
key: 'denoise',
|
||||
label: 'Noise Reduction',
|
||||
description: 'Remove background noise (AC, hum, hiss)',
|
||||
icon: <NoiseAwareIcon />,
|
||||
phase: 2,
|
||||
},
|
||||
];
|
||||
|
||||
export const OperationSelector: React.FC<OperationSelectorProps> = ({
|
||||
selectedOperation,
|
||||
onOperationChange,
|
||||
}) => {
|
||||
const phase1Ops = operations.filter((op) => op.phase === 1);
|
||||
const phase2Ops = operations.filter((op) => op.phase === 2);
|
||||
|
||||
const renderOperation = (op: OperationInfo) => (
|
||||
<Box
|
||||
key={op.key}
|
||||
onClick={() => onOperationChange(op.key)}
|
||||
sx={{
|
||||
p: 1.5,
|
||||
borderRadius: 2,
|
||||
border: '2px solid',
|
||||
borderColor: selectedOperation === op.key ? '#3b82f6' : '#e2e8f0',
|
||||
backgroundColor: selectedOperation === op.key ? '#eff6ff' : '#ffffff',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
'&:hover': {
|
||||
borderColor: '#3b82f6',
|
||||
backgroundColor: selectedOperation === op.key ? '#eff6ff' : '#f8fafc',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" spacing={1.5} alignItems="center">
|
||||
<Box
|
||||
sx={{
|
||||
p: 0.75,
|
||||
borderRadius: 1,
|
||||
backgroundColor: selectedOperation === op.key ? '#3b82f6' : '#f1f5f9',
|
||||
color: selectedOperation === op.key ? '#fff' : '#64748b',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
{op.icon}
|
||||
</Box>
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<Typography variant="body2" fontWeight={600} color="#0f172a" noWrap>
|
||||
{op.label}
|
||||
</Typography>
|
||||
<Chip
|
||||
label="Free"
|
||||
size="small"
|
||||
sx={{
|
||||
height: 16,
|
||||
fontSize: '0.6rem',
|
||||
backgroundColor: '#10b981',
|
||||
color: '#fff',
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
<Typography variant="caption" color="text.secondary" noWrap>
|
||||
{op.description}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
|
||||
return (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 2,
|
||||
borderRadius: 2,
|
||||
border: '1px solid #e2e8f0',
|
||||
backgroundColor: '#ffffff',
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
sx={{
|
||||
mb: 1.5,
|
||||
color: '#0f172a',
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
Edit Operation
|
||||
</Typography>
|
||||
|
||||
{/* Phase 1: Video */}
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mb: 0.5, display: 'block' }}>
|
||||
Video Processing
|
||||
</Typography>
|
||||
<Stack spacing={0.75} sx={{ mb: 2 }}>
|
||||
{phase1Ops.map(renderOperation)}
|
||||
</Stack>
|
||||
|
||||
<Divider sx={{ my: 1.5 }} />
|
||||
|
||||
{/* Phase 2: Audio */}
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mb: 0.5, display: 'block' }}>
|
||||
Text & Audio
|
||||
</Typography>
|
||||
<Stack spacing={0.75}>
|
||||
{phase2Ops.map(renderOperation)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,133 @@
|
||||
import React from 'react';
|
||||
import { Box, Typography, Paper, Slider, Stack, Chip } from '@mui/material';
|
||||
import SpeedIcon from '@mui/icons-material/Speed';
|
||||
|
||||
interface SpeedSettingsProps {
|
||||
videoDuration: number;
|
||||
speedFactor: number;
|
||||
onSpeedFactorChange: (value: number) => void;
|
||||
}
|
||||
|
||||
const speedMarks = [
|
||||
{ value: 0.25, label: '0.25x' },
|
||||
{ value: 0.5, label: '0.5x' },
|
||||
{ value: 1.0, label: '1x' },
|
||||
{ value: 1.5, label: '1.5x' },
|
||||
{ value: 2.0, label: '2x' },
|
||||
{ value: 4.0, label: '4x' },
|
||||
];
|
||||
|
||||
export const SpeedSettings: React.FC<SpeedSettingsProps> = ({
|
||||
videoDuration,
|
||||
speedFactor,
|
||||
onSpeedFactorChange,
|
||||
}) => {
|
||||
const resultDuration = videoDuration / speedFactor;
|
||||
|
||||
const getSpeedLabel = (factor: number) => {
|
||||
if (factor < 1) return 'Slow Motion';
|
||||
if (factor === 1) return 'Normal';
|
||||
return 'Fast Forward';
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 3,
|
||||
borderRadius: 2,
|
||||
border: '1px solid #e2e8f0',
|
||||
backgroundColor: '#ffffff',
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 2 }}>
|
||||
<SpeedIcon sx={{ color: '#3b82f6' }} />
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
sx={{
|
||||
color: '#0f172a',
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
Speed Settings
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
<Stack spacing={3}>
|
||||
{/* Speed Slider */}
|
||||
<Box>
|
||||
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 2 }}>
|
||||
<Typography variant="h4" fontWeight={700} color="#0f172a">
|
||||
{speedFactor}x
|
||||
</Typography>
|
||||
<Chip
|
||||
label={getSpeedLabel(speedFactor)}
|
||||
size="small"
|
||||
sx={{
|
||||
backgroundColor: speedFactor < 1 ? '#6366f1' : speedFactor > 1 ? '#f59e0b' : '#10b981',
|
||||
color: '#fff',
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
<Slider
|
||||
value={speedFactor}
|
||||
onChange={(_, value) => onSpeedFactorChange(value as number)}
|
||||
min={0.25}
|
||||
max={4.0}
|
||||
step={0.25}
|
||||
marks={speedMarks}
|
||||
valueLabelDisplay="auto"
|
||||
valueLabelFormat={(value) => `${value}x`}
|
||||
sx={{
|
||||
color: '#3b82f6',
|
||||
'& .MuiSlider-markLabel': {
|
||||
fontSize: '0.75rem',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Duration Preview */}
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
borderRadius: 2,
|
||||
backgroundColor: '#f1f5f9',
|
||||
border: '1px solid #e2e8f0',
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center">
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Original Duration
|
||||
</Typography>
|
||||
<Typography variant="body1" fontWeight={600} color="#0f172a">
|
||||
{videoDuration.toFixed(1)}s
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography variant="h5" color="#64748b">
|
||||
→
|
||||
</Typography>
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Result Duration
|
||||
</Typography>
|
||||
<Typography variant="body1" fontWeight={600} color="#3b82f6">
|
||||
{resultDuration.toFixed(1)}s
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
{/* Tips */}
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{speedFactor < 1
|
||||
? '💡 Slow motion is great for dramatic effect or analyzing motion'
|
||||
: speedFactor > 1
|
||||
? '💡 Fast forward can help condense long clips or create time-lapse effect'
|
||||
: '💡 Normal speed keeps the video unchanged'}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,127 @@
|
||||
import React from 'react';
|
||||
import { Box, Typography, Paper, Slider, Stack, Chip } from '@mui/material';
|
||||
import CameraAltIcon from '@mui/icons-material/CameraAlt';
|
||||
|
||||
interface StabilizeSettingsProps {
|
||||
smoothing: number;
|
||||
onSmoothingChange: (value: number) => void;
|
||||
}
|
||||
|
||||
export const StabilizeSettings: React.FC<StabilizeSettingsProps> = ({
|
||||
smoothing,
|
||||
onSmoothingChange,
|
||||
}) => {
|
||||
const getStabilizationLevel = (value: number) => {
|
||||
if (value <= 5) return { label: 'Light', color: '#10b981' };
|
||||
if (value <= 15) return { label: 'Moderate', color: '#3b82f6' };
|
||||
if (value <= 30) return { label: 'Strong', color: '#f59e0b' };
|
||||
return { label: 'Maximum', color: '#ef4444' };
|
||||
};
|
||||
|
||||
const level = getStabilizationLevel(smoothing);
|
||||
|
||||
return (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 3,
|
||||
borderRadius: 2,
|
||||
border: '1px solid #e2e8f0',
|
||||
backgroundColor: '#ffffff',
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 2 }}>
|
||||
<CameraAltIcon sx={{ color: '#3b82f6' }} />
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
sx={{
|
||||
color: '#0f172a',
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
Stabilization Settings
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
<Stack spacing={3}>
|
||||
{/* Smoothing Slider */}
|
||||
<Box>
|
||||
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 2 }}>
|
||||
<Typography variant="h4" fontWeight={700} color="#0f172a">
|
||||
{smoothing}
|
||||
</Typography>
|
||||
<Chip
|
||||
label={level.label}
|
||||
size="small"
|
||||
sx={{
|
||||
backgroundColor: level.color,
|
||||
color: '#fff',
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
<Slider
|
||||
value={smoothing}
|
||||
onChange={(_, value) => onSmoothingChange(value as number)}
|
||||
min={1}
|
||||
max={100}
|
||||
step={1}
|
||||
marks={[
|
||||
{ value: 1, label: 'Min' },
|
||||
{ value: 10, label: '10' },
|
||||
{ value: 30, label: '30' },
|
||||
{ value: 50, label: '50' },
|
||||
{ value: 100, label: 'Max' },
|
||||
]}
|
||||
valueLabelDisplay="auto"
|
||||
sx={{
|
||||
color: '#3b82f6',
|
||||
'& .MuiSlider-markLabel': {
|
||||
fontSize: '0.75rem',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Info Box */}
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
borderRadius: 2,
|
||||
backgroundColor: '#f1f5f9',
|
||||
border: '1px solid #e2e8f0',
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
|
||||
<strong>Smoothing</strong> controls how aggressively camera shake is corrected.
|
||||
</Typography>
|
||||
<Stack component="ul" spacing={0.5} sx={{ pl: 2, m: 0 }}>
|
||||
<Typography component="li" variant="caption" color="text.secondary">
|
||||
<strong>1-5:</strong> Light stabilization, preserves natural motion
|
||||
</Typography>
|
||||
<Typography component="li" variant="caption" color="text.secondary">
|
||||
<strong>10-15:</strong> Recommended for handheld footage
|
||||
</Typography>
|
||||
<Typography component="li" variant="caption" color="text.secondary">
|
||||
<strong>30+:</strong> Strong stabilization, may add slight zoom
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
{/* Warning */}
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
borderRadius: 2,
|
||||
backgroundColor: '#fef3c7',
|
||||
border: '1px solid #fbbf24',
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" color="#92400e">
|
||||
⚠️ Stabilization uses FFmpeg's vidstab filter and requires FFmpeg with vidstab support.
|
||||
Processing may take longer for high-resolution or long videos.
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,205 @@
|
||||
import React from 'react';
|
||||
import { Box, Typography, Paper, TextField, Slider, Stack, Select, MenuItem, FormControl, InputLabel } from '@mui/material';
|
||||
import TextFieldsIcon from '@mui/icons-material/TextFields';
|
||||
|
||||
interface TextOverlaySettingsProps {
|
||||
text: string;
|
||||
position: string;
|
||||
fontSize: number;
|
||||
fontColor: string;
|
||||
backgroundColor: string;
|
||||
startTime: number;
|
||||
endTime: number | null;
|
||||
videoDuration: number;
|
||||
onTextChange: (value: string) => void;
|
||||
onPositionChange: (value: string) => void;
|
||||
onFontSizeChange: (value: number) => void;
|
||||
onFontColorChange: (value: string) => void;
|
||||
onBackgroundColorChange: (value: string) => void;
|
||||
onStartTimeChange: (value: number) => void;
|
||||
onEndTimeChange: (value: number | null) => void;
|
||||
}
|
||||
|
||||
const positions = [
|
||||
{ value: 'top', label: 'Top Center' },
|
||||
{ value: 'center', label: 'Center' },
|
||||
{ value: 'bottom', label: 'Bottom Center' },
|
||||
{ value: 'top-left', label: 'Top Left' },
|
||||
{ value: 'top-right', label: 'Top Right' },
|
||||
{ value: 'bottom-left', label: 'Bottom Left' },
|
||||
{ value: 'bottom-right', label: 'Bottom Right' },
|
||||
];
|
||||
|
||||
const colors = [
|
||||
{ value: 'white', label: 'White' },
|
||||
{ value: 'black', label: 'Black' },
|
||||
{ value: 'yellow', label: 'Yellow' },
|
||||
{ value: 'red', label: 'Red' },
|
||||
{ value: 'blue', label: 'Blue' },
|
||||
{ value: 'green', label: 'Green' },
|
||||
];
|
||||
|
||||
export const TextOverlaySettings: React.FC<TextOverlaySettingsProps> = ({
|
||||
text,
|
||||
position,
|
||||
fontSize,
|
||||
fontColor,
|
||||
backgroundColor,
|
||||
startTime,
|
||||
endTime,
|
||||
videoDuration,
|
||||
onTextChange,
|
||||
onPositionChange,
|
||||
onFontSizeChange,
|
||||
onFontColorChange,
|
||||
onBackgroundColorChange,
|
||||
onStartTimeChange,
|
||||
onEndTimeChange,
|
||||
}) => {
|
||||
return (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 3,
|
||||
borderRadius: 2,
|
||||
border: '1px solid #e2e8f0',
|
||||
backgroundColor: '#ffffff',
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 2 }}>
|
||||
<TextFieldsIcon sx={{ color: '#3b82f6' }} />
|
||||
<Typography variant="subtitle2" sx={{ color: '#0f172a', fontWeight: 700 }}>
|
||||
Text Overlay Settings
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
<Stack spacing={3}>
|
||||
{/* Text Input */}
|
||||
<TextField
|
||||
label="Text to Display"
|
||||
value={text}
|
||||
onChange={(e) => onTextChange(e.target.value)}
|
||||
multiline
|
||||
rows={2}
|
||||
fullWidth
|
||||
placeholder="Enter your text here..."
|
||||
/>
|
||||
|
||||
{/* Position */}
|
||||
<FormControl fullWidth size="small">
|
||||
<InputLabel>Position</InputLabel>
|
||||
<Select
|
||||
value={position}
|
||||
label="Position"
|
||||
onChange={(e) => onPositionChange(e.target.value)}
|
||||
>
|
||||
{positions.map((pos) => (
|
||||
<MenuItem key={pos.value} value={pos.value}>
|
||||
{pos.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
{/* Font Size */}
|
||||
<Box>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
|
||||
Font Size: {fontSize}px
|
||||
</Typography>
|
||||
<Slider
|
||||
value={fontSize}
|
||||
onChange={(_, value) => onFontSizeChange(value as number)}
|
||||
min={12}
|
||||
max={120}
|
||||
step={4}
|
||||
marks={[
|
||||
{ value: 24, label: '24' },
|
||||
{ value: 48, label: '48' },
|
||||
{ value: 72, label: '72' },
|
||||
{ value: 96, label: '96' },
|
||||
]}
|
||||
sx={{ color: '#3b82f6' }}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Colors */}
|
||||
<Stack direction="row" spacing={2}>
|
||||
<FormControl fullWidth size="small">
|
||||
<InputLabel>Font Color</InputLabel>
|
||||
<Select
|
||||
value={fontColor}
|
||||
label="Font Color"
|
||||
onChange={(e) => onFontColorChange(e.target.value)}
|
||||
>
|
||||
{colors.map((color) => (
|
||||
<MenuItem key={color.value} value={color.value}>
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<Box
|
||||
sx={{
|
||||
width: 16,
|
||||
height: 16,
|
||||
borderRadius: 1,
|
||||
backgroundColor: color.value,
|
||||
border: '1px solid #e2e8f0',
|
||||
}}
|
||||
/>
|
||||
<span>{color.label}</span>
|
||||
</Stack>
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControl fullWidth size="small">
|
||||
<InputLabel>Background</InputLabel>
|
||||
<Select
|
||||
value={backgroundColor.split('@')[0]}
|
||||
label="Background"
|
||||
onChange={(e) => onBackgroundColorChange(`${e.target.value}@0.5`)}
|
||||
>
|
||||
{colors.map((color) => (
|
||||
<MenuItem key={color.value} value={color.value}>
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<Box
|
||||
sx={{
|
||||
width: 16,
|
||||
height: 16,
|
||||
borderRadius: 1,
|
||||
backgroundColor: color.value,
|
||||
border: '1px solid #e2e8f0',
|
||||
opacity: 0.5,
|
||||
}}
|
||||
/>
|
||||
<span>{color.label}</span>
|
||||
</Stack>
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Stack>
|
||||
|
||||
{/* Time Range */}
|
||||
<Box>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
|
||||
Display Time: {startTime.toFixed(1)}s - {endTime !== null ? `${endTime.toFixed(1)}s` : 'end'}
|
||||
</Typography>
|
||||
<Slider
|
||||
value={[startTime, endTime ?? videoDuration]}
|
||||
onChange={(_, value) => {
|
||||
if (Array.isArray(value)) {
|
||||
onStartTimeChange(value[0]);
|
||||
onEndTimeChange(value[1] >= videoDuration ? null : value[1]);
|
||||
}
|
||||
}}
|
||||
min={0}
|
||||
max={videoDuration}
|
||||
step={0.1}
|
||||
valueLabelDisplay="auto"
|
||||
valueLabelFormat={(value) => `${value.toFixed(1)}s`}
|
||||
sx={{ color: '#3b82f6' }}
|
||||
/>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,163 @@
|
||||
import React from 'react';
|
||||
import { Box, Typography, Paper, Slider, TextField, Stack, ToggleButton, ToggleButtonGroup } from '@mui/material';
|
||||
import ContentCutIcon from '@mui/icons-material/ContentCut';
|
||||
import type { TrimMode } from '../hooks/useEditVideo';
|
||||
|
||||
interface TrimSettingsProps {
|
||||
videoDuration: number;
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
maxDuration: number | null;
|
||||
trimMode: TrimMode;
|
||||
onStartTimeChange: (value: number) => void;
|
||||
onEndTimeChange: (value: number) => void;
|
||||
onMaxDurationChange: (value: number | null) => void;
|
||||
onTrimModeChange: (value: TrimMode) => void;
|
||||
}
|
||||
|
||||
export const TrimSettings: React.FC<TrimSettingsProps> = ({
|
||||
videoDuration,
|
||||
startTime,
|
||||
endTime,
|
||||
maxDuration,
|
||||
trimMode,
|
||||
onStartTimeChange,
|
||||
onEndTimeChange,
|
||||
onMaxDurationChange,
|
||||
onTrimModeChange,
|
||||
}) => {
|
||||
const handleRangeChange = (_event: Event, value: number | number[]) => {
|
||||
if (Array.isArray(value)) {
|
||||
onStartTimeChange(value[0]);
|
||||
onEndTimeChange(value[1]);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 3,
|
||||
borderRadius: 2,
|
||||
border: '1px solid #e2e8f0',
|
||||
backgroundColor: '#ffffff',
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 2 }}>
|
||||
<ContentCutIcon sx={{ color: '#3b82f6' }} />
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
sx={{
|
||||
color: '#0f172a',
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
Trim Settings
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
<Stack spacing={3}>
|
||||
{/* Time Range Slider */}
|
||||
<Box>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
Select time range: {startTime.toFixed(1)}s - {endTime.toFixed(1)}s ({(endTime - startTime).toFixed(1)}s)
|
||||
</Typography>
|
||||
<Slider
|
||||
value={[startTime, endTime]}
|
||||
onChange={handleRangeChange}
|
||||
min={0}
|
||||
max={videoDuration}
|
||||
step={0.1}
|
||||
valueLabelDisplay="auto"
|
||||
valueLabelFormat={(value) => `${value.toFixed(1)}s`}
|
||||
sx={{
|
||||
color: '#3b82f6',
|
||||
'& .MuiSlider-thumb': {
|
||||
width: 16,
|
||||
height: 16,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Manual Time Input */}
|
||||
<Stack direction="row" spacing={2}>
|
||||
<TextField
|
||||
label="Start (s)"
|
||||
type="number"
|
||||
size="small"
|
||||
value={startTime}
|
||||
onChange={(e) => onStartTimeChange(parseFloat(e.target.value) || 0)}
|
||||
inputProps={{
|
||||
min: 0,
|
||||
max: endTime - 0.1,
|
||||
step: 0.1,
|
||||
}}
|
||||
sx={{ flex: 1 }}
|
||||
/>
|
||||
<TextField
|
||||
label="End (s)"
|
||||
type="number"
|
||||
size="small"
|
||||
value={endTime}
|
||||
onChange={(e) => onEndTimeChange(parseFloat(e.target.value) || videoDuration)}
|
||||
inputProps={{
|
||||
min: startTime + 0.1,
|
||||
max: videoDuration,
|
||||
step: 0.1,
|
||||
}}
|
||||
sx={{ flex: 1 }}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
{/* Max Duration Mode */}
|
||||
<Box>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
|
||||
Or set max duration (optional):
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={2} alignItems="center">
|
||||
<TextField
|
||||
label="Max Duration (s)"
|
||||
type="number"
|
||||
size="small"
|
||||
value={maxDuration ?? ''}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
onMaxDurationChange(val ? parseFloat(val) : null);
|
||||
}}
|
||||
inputProps={{
|
||||
min: 1,
|
||||
max: videoDuration,
|
||||
step: 0.1,
|
||||
}}
|
||||
sx={{ width: 150 }}
|
||||
/>
|
||||
{maxDuration !== null && (
|
||||
<ToggleButtonGroup
|
||||
value={trimMode}
|
||||
exclusive
|
||||
onChange={(_, value) => value && onTrimModeChange(value)}
|
||||
size="small"
|
||||
>
|
||||
<ToggleButton value="beginning" sx={{ px: 2 }}>
|
||||
Beginning
|
||||
</ToggleButton>
|
||||
<ToggleButton value="middle" sx={{ px: 2 }}>
|
||||
Middle
|
||||
</ToggleButton>
|
||||
<ToggleButton value="end" sx={{ px: 2 }}>
|
||||
End
|
||||
</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
)}
|
||||
</Stack>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mt: 1, display: 'block' }}>
|
||||
{maxDuration !== null
|
||||
? `Will keep the ${trimMode} ${maxDuration}s of the video`
|
||||
: 'Leave empty to use start/end times'}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,188 @@
|
||||
import React, { useRef, useState, useCallback } from 'react';
|
||||
import { Box, Typography, Paper, Stack, IconButton } from '@mui/material';
|
||||
import CloudUploadIcon from '@mui/icons-material/CloudUpload';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
|
||||
interface VideoUploadProps {
|
||||
videoPreview: string | null;
|
||||
videoDuration: number;
|
||||
onVideoSelect: (file: File | null) => void;
|
||||
}
|
||||
|
||||
export const VideoUpload: React.FC<VideoUploadProps> = ({
|
||||
videoPreview,
|
||||
videoDuration,
|
||||
onVideoSelect,
|
||||
}) => {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [isDragActive, setIsDragActive] = useState(false);
|
||||
|
||||
const handleFileSelect = (file: File) => {
|
||||
if (!file.type.startsWith('video/')) {
|
||||
alert('Please select a video file');
|
||||
return;
|
||||
}
|
||||
if (file.size > 500 * 1024 * 1024) {
|
||||
alert('Video file must be less than 500MB');
|
||||
return;
|
||||
}
|
||||
onVideoSelect(file);
|
||||
};
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
handleFileSelect(file);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClick = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleClear = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onVideoSelect(null);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragEnter = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragActive(true);
|
||||
}, []);
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragActive(false);
|
||||
}, []);
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragActive(false);
|
||||
|
||||
const file = e.dataTransfer.files?.[0];
|
||||
if (file) {
|
||||
handleFileSelect(file);
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 3,
|
||||
borderRadius: 2,
|
||||
border: '1px solid #e2e8f0',
|
||||
backgroundColor: '#ffffff',
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
sx={{
|
||||
mb: 2,
|
||||
color: '#0f172a',
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
Source Video
|
||||
</Typography>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="video/*"
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
|
||||
{videoPreview ? (
|
||||
<Box sx={{ position: 'relative' }}>
|
||||
<video
|
||||
src={videoPreview}
|
||||
controls
|
||||
style={{
|
||||
width: '100%',
|
||||
maxHeight: '300px',
|
||||
borderRadius: '8px',
|
||||
objectFit: 'contain',
|
||||
backgroundColor: '#000',
|
||||
}}
|
||||
/>
|
||||
<IconButton
|
||||
onClick={handleClear}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
right: 8,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.6)',
|
||||
color: '#fff',
|
||||
'&:hover': {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
||||
},
|
||||
}}
|
||||
size="small"
|
||||
>
|
||||
<CloseIcon fontSize="small" />
|
||||
</IconButton>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
display: 'block',
|
||||
mt: 1,
|
||||
color: '#64748b',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
Duration: {videoDuration.toFixed(1)}s
|
||||
</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<Box
|
||||
onClick={handleClick}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
sx={{
|
||||
p: 4,
|
||||
borderRadius: 2,
|
||||
border: '2px dashed',
|
||||
borderColor: isDragActive ? '#3b82f6' : '#e2e8f0',
|
||||
backgroundColor: isDragActive ? '#eff6ff' : '#f8fafc',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
'&:hover': {
|
||||
borderColor: '#3b82f6',
|
||||
backgroundColor: '#eff6ff',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Stack spacing={2} alignItems="center">
|
||||
<CloudUploadIcon sx={{ fontSize: 48, color: '#94a3b8' }} />
|
||||
<Typography variant="body2" color="text.secondary" textAlign="center">
|
||||
{isDragActive
|
||||
? 'Drop the video here...'
|
||||
: 'Drag and drop a video file, or click to select'}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Supported: MP4, MOV, AVI, WebM, MKV
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,132 @@
|
||||
import React from 'react';
|
||||
import { Box, Typography, Paper, Slider, Stack, Chip } from '@mui/material';
|
||||
import VolumeUpIcon from '@mui/icons-material/VolumeUp';
|
||||
import VolumeOffIcon from '@mui/icons-material/VolumeOff';
|
||||
import VolumeMuteIcon from '@mui/icons-material/VolumeMute';
|
||||
|
||||
interface VolumeSettingsProps {
|
||||
volumeFactor: number;
|
||||
onVolumeFactorChange: (value: number) => void;
|
||||
}
|
||||
|
||||
const volumeMarks = [
|
||||
{ value: 0, label: 'Mute' },
|
||||
{ value: 0.5, label: '50%' },
|
||||
{ value: 1.0, label: '100%' },
|
||||
{ value: 2.0, label: '200%' },
|
||||
{ value: 3.0, label: '300%' },
|
||||
];
|
||||
|
||||
export const VolumeSettings: React.FC<VolumeSettingsProps> = ({
|
||||
volumeFactor,
|
||||
onVolumeFactorChange,
|
||||
}) => {
|
||||
const getVolumeLabel = (factor: number) => {
|
||||
if (factor === 0) return { label: 'Muted', color: '#64748b', icon: <VolumeOffIcon /> };
|
||||
if (factor < 1) return { label: 'Reduced', color: '#f59e0b', icon: <VolumeMuteIcon /> };
|
||||
if (factor === 1) return { label: 'Original', color: '#10b981', icon: <VolumeUpIcon /> };
|
||||
if (factor <= 2) return { label: 'Boosted', color: '#3b82f6', icon: <VolumeUpIcon /> };
|
||||
return { label: 'Loud', color: '#ef4444', icon: <VolumeUpIcon /> };
|
||||
};
|
||||
|
||||
const info = getVolumeLabel(volumeFactor);
|
||||
|
||||
return (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 3,
|
||||
borderRadius: 2,
|
||||
border: '1px solid #e2e8f0',
|
||||
backgroundColor: '#ffffff',
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 2 }}>
|
||||
<VolumeUpIcon sx={{ color: '#3b82f6' }} />
|
||||
<Typography variant="subtitle2" sx={{ color: '#0f172a', fontWeight: 700 }}>
|
||||
Volume Settings
|
||||
</Typography>
|
||||
</Stack>
|
||||
|
||||
<Stack spacing={3}>
|
||||
{/* Volume Display */}
|
||||
<Box>
|
||||
<Stack direction="row" spacing={2} alignItems="center" sx={{ mb: 2 }}>
|
||||
<Typography variant="h3" fontWeight={700} color="#0f172a">
|
||||
{Math.round(volumeFactor * 100)}%
|
||||
</Typography>
|
||||
<Chip
|
||||
icon={info.icon}
|
||||
label={info.label}
|
||||
size="small"
|
||||
sx={{
|
||||
backgroundColor: info.color,
|
||||
color: '#fff',
|
||||
'& .MuiChip-icon': { color: '#fff' },
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
<Slider
|
||||
value={volumeFactor}
|
||||
onChange={(_, value) => onVolumeFactorChange(value as number)}
|
||||
min={0}
|
||||
max={3}
|
||||
step={0.1}
|
||||
marks={volumeMarks}
|
||||
valueLabelDisplay="auto"
|
||||
valueLabelFormat={(value) => `${Math.round(value * 100)}%`}
|
||||
sx={{
|
||||
color: info.color,
|
||||
'& .MuiSlider-markLabel': {
|
||||
fontSize: '0.75rem',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Info Box */}
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
borderRadius: 2,
|
||||
backgroundColor: '#f1f5f9',
|
||||
border: '1px solid #e2e8f0',
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
<strong>Volume Factor:</strong>
|
||||
</Typography>
|
||||
<Stack component="ul" spacing={0.5} sx={{ pl: 2, m: 0, mt: 1 }}>
|
||||
<Typography component="li" variant="caption" color="text.secondary">
|
||||
<strong>0%:</strong> Completely muted (silent video)
|
||||
</Typography>
|
||||
<Typography component="li" variant="caption" color="text.secondary">
|
||||
<strong>50%:</strong> Half volume (quieter)
|
||||
</Typography>
|
||||
<Typography component="li" variant="caption" color="text.secondary">
|
||||
<strong>100%:</strong> Original volume (no change)
|
||||
</Typography>
|
||||
<Typography component="li" variant="caption" color="text.secondary">
|
||||
<strong>200%+:</strong> Boosted (may cause distortion)
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
{volumeFactor > 2 && (
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
borderRadius: 2,
|
||||
backgroundColor: '#fef3c7',
|
||||
border: '1px solid #fbbf24',
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" color="#92400e">
|
||||
⚠️ High volume levels may cause audio distortion. Consider normalizing instead.
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
export { VideoUpload } from './VideoUpload';
|
||||
export { OperationSelector } from './OperationSelector';
|
||||
export { TrimSettings } from './TrimSettings';
|
||||
export { SpeedSettings } from './SpeedSettings';
|
||||
export { StabilizeSettings } from './StabilizeSettings';
|
||||
export { TextOverlaySettings } from './TextOverlaySettings';
|
||||
export { VolumeSettings } from './VolumeSettings';
|
||||
export { NormalizeSettings } from './NormalizeSettings';
|
||||
export { DenoiseSettings } from './DenoiseSettings';
|
||||
@@ -0,0 +1,309 @@
|
||||
import { useState, useMemo, useEffect, useCallback } from 'react';
|
||||
import { aiApiClient } from '../../../../../api/client';
|
||||
|
||||
export type EditOperation = 'trim' | 'speed' | 'stabilize' | 'text' | 'volume' | 'normalize' | 'denoise';
|
||||
export type TrimMode = 'beginning' | 'middle' | 'end';
|
||||
|
||||
export const useEditVideo = () => {
|
||||
const [videoFile, setVideoFile] = useState<File | null>(null);
|
||||
const [videoPreview, setVideoPreview] = useState<string | null>(null);
|
||||
const [videoDuration, setVideoDuration] = useState<number>(10);
|
||||
|
||||
// Edit operation
|
||||
const [operation, setOperation] = useState<EditOperation>('trim');
|
||||
|
||||
// Trim settings
|
||||
const [startTime, setStartTime] = useState<number>(0);
|
||||
const [endTime, setEndTime] = useState<number>(10);
|
||||
const [maxDuration, setMaxDuration] = useState<number | null>(null);
|
||||
const [trimMode, setTrimMode] = useState<TrimMode>('beginning');
|
||||
|
||||
// Speed settings
|
||||
const [speedFactor, setSpeedFactor] = useState<number>(1.0);
|
||||
|
||||
// Stabilization settings
|
||||
const [smoothing, setSmoothing] = useState<number>(10);
|
||||
|
||||
// Text overlay settings
|
||||
const [overlayText, setOverlayText] = useState<string>('');
|
||||
const [textPosition, setTextPosition] = useState<string>('center');
|
||||
const [fontSize, setFontSize] = useState<number>(48);
|
||||
const [fontColor, setFontColor] = useState<string>('white');
|
||||
const [backgroundColor, setBackgroundColor] = useState<string>('black@0.5');
|
||||
const [textStartTime, setTextStartTime] = useState<number>(0);
|
||||
const [textEndTime, setTextEndTime] = useState<number | null>(null);
|
||||
|
||||
// Volume settings
|
||||
const [volumeFactor, setVolumeFactor] = useState<number>(1.0);
|
||||
|
||||
// Normalize settings
|
||||
const [targetLevel, setTargetLevel] = useState<number>(-14);
|
||||
|
||||
// Denoise settings
|
||||
const [denoiseStrength, setDenoiseStrength] = useState<number>(0.5);
|
||||
|
||||
// State
|
||||
const [editing, setEditing] = useState<boolean>(false);
|
||||
const [progress, setProgress] = useState<number>(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [result, setResult] = useState<{ video_url: string; cost: number; edit_type: string } | null>(null);
|
||||
|
||||
// Update preview when file changes
|
||||
useEffect(() => {
|
||||
if (videoFile) {
|
||||
const url = URL.createObjectURL(videoFile);
|
||||
setVideoPreview(url);
|
||||
|
||||
const video = document.createElement('video');
|
||||
video.preload = 'metadata';
|
||||
video.onloadedmetadata = () => {
|
||||
setVideoDuration(video.duration);
|
||||
setEndTime(video.duration);
|
||||
URL.revokeObjectURL(video.src);
|
||||
};
|
||||
video.src = url;
|
||||
|
||||
return () => {
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
} else {
|
||||
setVideoPreview(null);
|
||||
setVideoDuration(10);
|
||||
setEndTime(10);
|
||||
}
|
||||
}, [videoFile]);
|
||||
|
||||
const canEdit = useMemo(() => {
|
||||
if (!videoFile) return false;
|
||||
|
||||
switch (operation) {
|
||||
case 'trim':
|
||||
return startTime < endTime && endTime <= videoDuration;
|
||||
case 'speed':
|
||||
return speedFactor > 0 && speedFactor <= 4.0;
|
||||
case 'stabilize':
|
||||
return smoothing >= 1 && smoothing <= 100;
|
||||
case 'text':
|
||||
return overlayText.trim().length > 0;
|
||||
case 'volume':
|
||||
return volumeFactor >= 0 && volumeFactor <= 5.0;
|
||||
case 'normalize':
|
||||
return targetLevel >= -50 && targetLevel <= 0;
|
||||
case 'denoise':
|
||||
return denoiseStrength >= 0 && denoiseStrength <= 1;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}, [videoFile, operation, startTime, endTime, videoDuration, speedFactor, smoothing, overlayText, volumeFactor, targetLevel, denoiseStrength]);
|
||||
|
||||
const costHint = useMemo(() => {
|
||||
if (!videoFile) return 'Upload a video to see cost';
|
||||
return 'Free (FFmpeg processing)';
|
||||
}, [videoFile]);
|
||||
|
||||
const operationDescription = useMemo(() => {
|
||||
switch (operation) {
|
||||
case 'trim':
|
||||
return `Trim video from ${startTime.toFixed(1)}s to ${endTime.toFixed(1)}s (${(endTime - startTime).toFixed(1)}s output)`;
|
||||
case 'speed':
|
||||
const resultDuration = videoDuration / speedFactor;
|
||||
return `${speedFactor}x speed -> ${resultDuration.toFixed(1)}s output`;
|
||||
case 'stabilize':
|
||||
return `Stabilize with smoothing: ${smoothing} (higher = smoother)`;
|
||||
case 'text':
|
||||
return `Add "${overlayText.substring(0, 20)}${overlayText.length > 20 ? '...' : ''}" at ${textPosition}`;
|
||||
case 'volume':
|
||||
return `${volumeFactor === 0 ? 'Mute' : volumeFactor < 1 ? 'Reduce' : volumeFactor === 1 ? 'Keep' : 'Boost'} volume to ${Math.round(volumeFactor * 100)}%`;
|
||||
case 'normalize':
|
||||
return `Normalize audio to ${targetLevel} LUFS`;
|
||||
case 'denoise':
|
||||
return `Reduce noise at ${Math.round(denoiseStrength * 100)}% strength`;
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}, [operation, startTime, endTime, speedFactor, videoDuration, smoothing, overlayText, textPosition, volumeFactor, targetLevel, denoiseStrength]);
|
||||
|
||||
const processVideo = useCallback(async (): Promise<void> => {
|
||||
if (!videoFile || !canEdit) return;
|
||||
|
||||
setEditing(true);
|
||||
setProgress(0);
|
||||
setError(null);
|
||||
setResult(null);
|
||||
|
||||
const progressInterval = setInterval(() => {
|
||||
setProgress((prev) => {
|
||||
if (prev >= 90) return prev;
|
||||
return prev + Math.random() * 10;
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', videoFile);
|
||||
|
||||
let endpoint = '/api/video-studio/edit/';
|
||||
|
||||
switch (operation) {
|
||||
case 'trim':
|
||||
endpoint += 'trim';
|
||||
formData.append('start_time', startTime.toString());
|
||||
formData.append('end_time', endTime.toString());
|
||||
if (maxDuration !== null) {
|
||||
formData.append('max_duration', maxDuration.toString());
|
||||
}
|
||||
formData.append('trim_mode', trimMode);
|
||||
break;
|
||||
case 'speed':
|
||||
endpoint += 'speed';
|
||||
formData.append('speed_factor', speedFactor.toString());
|
||||
break;
|
||||
case 'stabilize':
|
||||
endpoint += 'stabilize';
|
||||
formData.append('smoothing', smoothing.toString());
|
||||
break;
|
||||
case 'text':
|
||||
endpoint += 'text';
|
||||
formData.append('text', overlayText);
|
||||
formData.append('position', textPosition);
|
||||
formData.append('font_size', fontSize.toString());
|
||||
formData.append('font_color', fontColor);
|
||||
formData.append('background_color', backgroundColor);
|
||||
formData.append('start_time', textStartTime.toString());
|
||||
if (textEndTime !== null) {
|
||||
formData.append('end_time', textEndTime.toString());
|
||||
}
|
||||
break;
|
||||
case 'volume':
|
||||
endpoint += 'volume';
|
||||
formData.append('volume_factor', volumeFactor.toString());
|
||||
break;
|
||||
case 'normalize':
|
||||
endpoint += 'normalize';
|
||||
formData.append('target_level', targetLevel.toString());
|
||||
break;
|
||||
case 'denoise':
|
||||
endpoint += 'denoise';
|
||||
formData.append('strength', denoiseStrength.toString());
|
||||
break;
|
||||
}
|
||||
|
||||
const response = await aiApiClient.post(endpoint, formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
|
||||
clearInterval(progressInterval);
|
||||
setProgress(100);
|
||||
setResult({
|
||||
video_url: response.data.video_url,
|
||||
cost: response.data.cost || 0,
|
||||
edit_type: response.data.edit_type,
|
||||
});
|
||||
} catch (err: any) {
|
||||
clearInterval(progressInterval);
|
||||
setError(err.response?.data?.detail || err.message || 'Video editing failed');
|
||||
} finally {
|
||||
setEditing(false);
|
||||
}
|
||||
}, [videoFile, canEdit, operation, startTime, endTime, maxDuration, trimMode, speedFactor, smoothing, overlayText, textPosition, fontSize, fontColor, backgroundColor, textStartTime, textEndTime, volumeFactor, targetLevel, denoiseStrength]);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setVideoFile(null);
|
||||
setVideoPreview(null);
|
||||
setVideoDuration(10);
|
||||
setOperation('trim');
|
||||
setStartTime(0);
|
||||
setEndTime(10);
|
||||
setMaxDuration(null);
|
||||
setTrimMode('beginning');
|
||||
setSpeedFactor(1.0);
|
||||
setSmoothing(10);
|
||||
setOverlayText('');
|
||||
setTextPosition('center');
|
||||
setFontSize(48);
|
||||
setFontColor('white');
|
||||
setBackgroundColor('black@0.5');
|
||||
setTextStartTime(0);
|
||||
setTextEndTime(null);
|
||||
setVolumeFactor(1.0);
|
||||
setTargetLevel(-14);
|
||||
setDenoiseStrength(0.5);
|
||||
setEditing(false);
|
||||
setProgress(0);
|
||||
setError(null);
|
||||
setResult(null);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
// Video state
|
||||
videoFile,
|
||||
videoPreview,
|
||||
videoDuration,
|
||||
setVideoFile,
|
||||
|
||||
// Operation
|
||||
operation,
|
||||
setOperation,
|
||||
|
||||
// Trim settings
|
||||
startTime,
|
||||
endTime,
|
||||
maxDuration,
|
||||
trimMode,
|
||||
setStartTime,
|
||||
setEndTime,
|
||||
setMaxDuration,
|
||||
setTrimMode,
|
||||
|
||||
// Speed settings
|
||||
speedFactor,
|
||||
setSpeedFactor,
|
||||
|
||||
// Stabilization settings
|
||||
smoothing,
|
||||
setSmoothing,
|
||||
|
||||
// Text overlay settings
|
||||
overlayText,
|
||||
textPosition,
|
||||
fontSize,
|
||||
fontColor,
|
||||
backgroundColor,
|
||||
textStartTime,
|
||||
textEndTime,
|
||||
setOverlayText,
|
||||
setTextPosition,
|
||||
setFontSize,
|
||||
setFontColor,
|
||||
setBackgroundColor,
|
||||
setTextStartTime,
|
||||
setTextEndTime,
|
||||
|
||||
// Volume settings
|
||||
volumeFactor,
|
||||
setVolumeFactor,
|
||||
|
||||
// Normalize settings
|
||||
targetLevel,
|
||||
setTargetLevel,
|
||||
|
||||
// Denoise settings
|
||||
denoiseStrength,
|
||||
setDenoiseStrength,
|
||||
|
||||
// State
|
||||
editing,
|
||||
progress,
|
||||
error,
|
||||
result,
|
||||
canEdit,
|
||||
costHint,
|
||||
operationDescription,
|
||||
|
||||
// Actions
|
||||
processVideo,
|
||||
reset,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { EditVideo, default } from './EditVideo';
|
||||
@@ -0,0 +1,799 @@
|
||||
import React, { useState, useMemo, useEffect } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
Box,
|
||||
Paper,
|
||||
Typography,
|
||||
TextField,
|
||||
InputAdornment,
|
||||
Grid,
|
||||
Card,
|
||||
CardContent,
|
||||
CardMedia,
|
||||
Chip,
|
||||
IconButton,
|
||||
Stack,
|
||||
Button,
|
||||
ButtonGroup,
|
||||
Tabs,
|
||||
Tab,
|
||||
FormControl,
|
||||
Select,
|
||||
MenuItem,
|
||||
InputLabel,
|
||||
Divider,
|
||||
CircularProgress,
|
||||
Alert,
|
||||
Snackbar,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Checkbox,
|
||||
Tooltip,
|
||||
Menu,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Autocomplete,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Search,
|
||||
GridView,
|
||||
ViewList,
|
||||
Favorite,
|
||||
FavoriteBorder,
|
||||
Download,
|
||||
Share,
|
||||
Delete,
|
||||
VideoLibrary,
|
||||
Collections,
|
||||
Add,
|
||||
Edit,
|
||||
MoreVert,
|
||||
CalendarToday,
|
||||
CheckCircle,
|
||||
HourglassEmpty,
|
||||
Error as ErrorIcon,
|
||||
Refresh,
|
||||
Sort,
|
||||
FilterList,
|
||||
Folder,
|
||||
FolderOpen,
|
||||
} from '@mui/icons-material';
|
||||
import { VideoStudioLayout } from '../../VideoStudioLayout';
|
||||
import { useContentAssets, AssetFilters, ContentAsset } from '../../../../hooks/useContentAssets';
|
||||
import { useCollections, Collection } from '../../../../hooks/useCollections';
|
||||
|
||||
interface TabPanelProps {
|
||||
children?: React.ReactNode;
|
||||
index: number;
|
||||
value: number;
|
||||
}
|
||||
|
||||
function TabPanel(props: TabPanelProps) {
|
||||
const { children, value, index, ...other } = props;
|
||||
return (
|
||||
<div role="tabpanel" hidden={value !== index} {...other}>
|
||||
{value === index && <Box sx={{ pt: 3 }}>{children}</Box>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status?.toLowerCase()) {
|
||||
case 'completed':
|
||||
return <CheckCircle sx={{ color: '#10b981', fontSize: 18 }} />;
|
||||
case 'processing':
|
||||
return <HourglassEmpty sx={{ color: '#f59e0b', fontSize: 18 }} />;
|
||||
case 'failed':
|
||||
return <ErrorIcon sx={{ color: '#ef4444', fontSize: 18 }} />;
|
||||
default:
|
||||
return <HourglassEmpty sx={{ color: '#6b7280', fontSize: 18 }} />;
|
||||
}
|
||||
};
|
||||
|
||||
export const LibraryVideo: React.FC = () => {
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
const urlSourceModule = searchParams.get('source_module');
|
||||
const urlAssetType = searchParams.get('asset_type');
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||||
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
|
||||
const [tabValue, setTabValue] = useState(0);
|
||||
const [filterType, setFilterType] = useState(() => {
|
||||
if (urlAssetType) {
|
||||
return urlAssetType === 'video' ? 'videos' : 'all';
|
||||
}
|
||||
return 'videos'; // Default to videos for Video Studio
|
||||
});
|
||||
const [statusFilter, setStatusFilter] = useState('all');
|
||||
const [sortBy, setSortBy] = useState('created_at');
|
||||
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
|
||||
const [selectedAssets, setSelectedAssets] = useState<Set<number>>(new Set());
|
||||
const [page, setPage] = useState(0);
|
||||
const [pageSize] = useState(50);
|
||||
const [anchorEl, setAnchorEl] = useState<{ [key: number]: HTMLElement | null }>({});
|
||||
const [snackbar, setSnackbar] = useState<{ open: boolean; message: string; severity: 'success' | 'error' }>({
|
||||
open: false,
|
||||
message: '',
|
||||
severity: 'success',
|
||||
});
|
||||
|
||||
// Collections state
|
||||
const [selectedCollection, setSelectedCollection] = useState<number | null>(null);
|
||||
const [collectionDialogOpen, setCollectionDialogOpen] = useState(false);
|
||||
const [newCollectionName, setNewCollectionName] = useState('');
|
||||
const [newCollectionDescription, setNewCollectionDescription] = useState('');
|
||||
|
||||
const {
|
||||
collections,
|
||||
loading: collectionsLoading,
|
||||
createCollection,
|
||||
deleteCollection,
|
||||
addAssetsToCollection,
|
||||
removeAssetsFromCollection,
|
||||
refetch: refetchCollections,
|
||||
} = useCollections();
|
||||
|
||||
// Debounce search query
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setDebouncedSearch(searchQuery);
|
||||
setPage(0);
|
||||
}, 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchQuery]);
|
||||
|
||||
// Build filters
|
||||
const filters: AssetFilters = useMemo(() => {
|
||||
const baseFilters: AssetFilters = {
|
||||
limit: pageSize,
|
||||
offset: page * pageSize,
|
||||
};
|
||||
|
||||
if (urlSourceModule) {
|
||||
baseFilters.source_module = urlSourceModule as any;
|
||||
} else {
|
||||
// Default to video_studio sources for Video Studio
|
||||
baseFilters.source_module = 'main_video_generation';
|
||||
}
|
||||
|
||||
if (debouncedSearch) {
|
||||
baseFilters.search = debouncedSearch;
|
||||
}
|
||||
|
||||
if (filterType === 'videos') {
|
||||
baseFilters.asset_type = 'video';
|
||||
} else if (filterType === 'favorites') {
|
||||
baseFilters.favorites_only = true;
|
||||
}
|
||||
|
||||
if (tabValue === 1) {
|
||||
baseFilters.favorites_only = true;
|
||||
}
|
||||
|
||||
return baseFilters;
|
||||
}, [debouncedSearch, filterType, tabValue, page, pageSize, urlSourceModule]);
|
||||
|
||||
// Update filters when collection is selected
|
||||
const collectionFilters: AssetFilters = useMemo(() => {
|
||||
const baseFilters = { ...filters };
|
||||
if (selectedCollection !== null) {
|
||||
baseFilters.collection_id = selectedCollection;
|
||||
}
|
||||
if (sortBy) {
|
||||
baseFilters.sort_by = sortBy;
|
||||
}
|
||||
if (sortOrder) {
|
||||
baseFilters.sort_order = sortOrder;
|
||||
}
|
||||
return baseFilters;
|
||||
}, [filters, selectedCollection, sortBy, sortOrder]);
|
||||
|
||||
const { assets, loading, error, total, toggleFavorite, deleteAsset, trackUsage, refetch } = useContentAssets(collectionFilters);
|
||||
|
||||
// Use assets directly since backend now filters by collection_id
|
||||
const filteredAssets = assets;
|
||||
|
||||
const handleTabChange = (_event: React.SyntheticEvent, newValue: number) => {
|
||||
setTabValue(newValue);
|
||||
setPage(0);
|
||||
};
|
||||
|
||||
const handleSelectAll = (checked: boolean) => {
|
||||
if (checked) {
|
||||
setSelectedAssets(new Set(assets.map(a => a.id)));
|
||||
} else {
|
||||
setSelectedAssets(new Set());
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectAsset = (assetId: number, checked: boolean) => {
|
||||
const newSelected = new Set(selectedAssets);
|
||||
if (checked) {
|
||||
newSelected.add(assetId);
|
||||
} else {
|
||||
newSelected.delete(assetId);
|
||||
}
|
||||
setSelectedAssets(newSelected);
|
||||
};
|
||||
|
||||
const handleBulkDelete = async () => {
|
||||
if (selectedAssets.size === 0) return;
|
||||
if (!window.confirm(`Delete ${selectedAssets.size} selected asset(s)?`)) return;
|
||||
|
||||
try {
|
||||
await Promise.all(Array.from(selectedAssets).map(id => deleteAsset(id)));
|
||||
setSelectedAssets(new Set());
|
||||
setSnackbar({ open: true, message: `${selectedAssets.size} asset(s) deleted`, severity: 'success' });
|
||||
refetch();
|
||||
} catch (err) {
|
||||
setSnackbar({ open: true, message: 'Failed to delete assets', severity: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleBulkDownload = async () => {
|
||||
if (selectedAssets.size === 0) return;
|
||||
|
||||
try {
|
||||
const selectedAssetsData = assets.filter(a => selectedAssets.has(a.id));
|
||||
await Promise.all(selectedAssetsData.map(asset => trackUsage(asset.id, 'download')));
|
||||
|
||||
selectedAssetsData.forEach(asset => {
|
||||
window.open(asset.file_url, '_blank');
|
||||
});
|
||||
|
||||
setSnackbar({ open: true, message: `Downloading ${selectedAssets.size} asset(s)`, severity: 'success' });
|
||||
} catch (err) {
|
||||
setSnackbar({ open: true, message: 'Failed to download assets', severity: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleFavorite = async (assetId: number) => {
|
||||
try {
|
||||
await toggleFavorite(assetId);
|
||||
const asset = assets.find(a => a.id === assetId);
|
||||
setSnackbar({
|
||||
open: true,
|
||||
message: asset?.is_favorite ? 'Removed from favorites' : 'Added to favorites',
|
||||
severity: 'success',
|
||||
});
|
||||
} catch (err) {
|
||||
setSnackbar({ open: true, message: 'Failed to update favorite', severity: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (assetId: number) => {
|
||||
if (!window.confirm('Are you sure you want to delete this asset?')) return;
|
||||
try {
|
||||
await deleteAsset(assetId);
|
||||
setSnackbar({ open: true, message: 'Asset deleted', severity: 'success' });
|
||||
refetch();
|
||||
} catch (err) {
|
||||
setSnackbar({ open: true, message: 'Failed to delete asset', severity: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = async (asset: ContentAsset) => {
|
||||
try {
|
||||
await trackUsage(asset.id, 'download');
|
||||
window.open(asset.file_url, '_blank');
|
||||
} catch (err) {
|
||||
console.error('Error downloading:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateCollection = async () => {
|
||||
if (!newCollectionName.trim()) return;
|
||||
|
||||
try {
|
||||
await createCollection({
|
||||
name: newCollectionName,
|
||||
description: newCollectionDescription,
|
||||
is_public: false,
|
||||
});
|
||||
|
||||
setCollectionDialogOpen(false);
|
||||
setNewCollectionName('');
|
||||
setNewCollectionDescription('');
|
||||
setSnackbar({ open: true, message: 'Collection created', severity: 'success' });
|
||||
} catch (err) {
|
||||
setSnackbar({ open: true, message: 'Failed to create collection', severity: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddToCollection = async (collectionId: number) => {
|
||||
if (selectedAssets.size === 0) return;
|
||||
|
||||
try {
|
||||
await addAssetsToCollection(collectionId, Array.from(selectedAssets));
|
||||
setSelectedAssets(new Set());
|
||||
setSnackbar({ open: true, message: 'Assets added to collection', severity: 'success' });
|
||||
refetch();
|
||||
} catch (err) {
|
||||
setSnackbar({ open: true, message: 'Failed to add assets to collection', severity: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveFromCollection = async (assetId: number) => {
|
||||
if (!selectedCollection) return;
|
||||
|
||||
try {
|
||||
await removeAssetsFromCollection(selectedCollection, [assetId]);
|
||||
setSnackbar({ open: true, message: 'Asset removed from collection', severity: 'success' });
|
||||
refetch();
|
||||
} catch (err) {
|
||||
setSnackbar({ open: true, message: 'Failed to remove asset from collection', severity: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteCollection = async (collectionId: number) => {
|
||||
if (!window.confirm('Are you sure you want to delete this collection? Assets will not be deleted.')) return;
|
||||
|
||||
try {
|
||||
await deleteCollection(collectionId);
|
||||
if (selectedCollection === collectionId) {
|
||||
setSelectedCollection(null);
|
||||
}
|
||||
setSnackbar({ open: true, message: 'Collection deleted', severity: 'success' });
|
||||
refetch();
|
||||
} catch (err) {
|
||||
setSnackbar({ open: true, message: 'Failed to delete collection', severity: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<VideoStudioLayout
|
||||
headerProps={{
|
||||
title: 'Asset Library',
|
||||
subtitle: 'Manage and organize all your video assets. Search, filter, create collections, and track usage.',
|
||||
}}
|
||||
>
|
||||
<Box sx={{ width: '100%' }}>
|
||||
{/* Search and Filter Bar */}
|
||||
<Paper elevation={0} sx={{ p: 2, mb: 3, borderRadius: 2, border: '1px solid #e2e8f0' }}>
|
||||
<Stack spacing={2}>
|
||||
<Stack direction="row" spacing={2} alignItems="center">
|
||||
<TextField
|
||||
fullWidth
|
||||
placeholder="Search videos by title, description, prompt, or filename..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
InputProps={{
|
||||
startAdornment: (
|
||||
<InputAdornment position="start">
|
||||
<Search />
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
size="small"
|
||||
/>
|
||||
<FormControl size="small" sx={{ minWidth: 150 }}>
|
||||
<InputLabel>Sort By</InputLabel>
|
||||
<Select
|
||||
value={sortBy}
|
||||
label="Sort By"
|
||||
onChange={(e) => setSortBy(e.target.value)}
|
||||
>
|
||||
<MenuItem value="created_at">Date Created</MenuItem>
|
||||
<MenuItem value="updated_at">Last Updated</MenuItem>
|
||||
<MenuItem value="cost">Cost</MenuItem>
|
||||
<MenuItem value="file_size">File Size</MenuItem>
|
||||
<MenuItem value="title">Title</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<ButtonGroup size="small">
|
||||
<Button
|
||||
variant={sortOrder === 'desc' ? 'contained' : 'outlined'}
|
||||
onClick={() => setSortOrder('desc')}
|
||||
>
|
||||
Newest
|
||||
</Button>
|
||||
<Button
|
||||
variant={sortOrder === 'asc' ? 'contained' : 'outlined'}
|
||||
onClick={() => setSortOrder('asc')}
|
||||
>
|
||||
Oldest
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
<ButtonGroup size="small">
|
||||
<Button
|
||||
variant={viewMode === 'grid' ? 'contained' : 'outlined'}
|
||||
onClick={() => setViewMode('grid')}
|
||||
startIcon={<GridView />}
|
||||
>
|
||||
Grid
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewMode === 'list' ? 'contained' : 'outlined'}
|
||||
onClick={() => setViewMode('list')}
|
||||
startIcon={<ViewList />}
|
||||
>
|
||||
List
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</Stack>
|
||||
|
||||
{/* Collections Sidebar */}
|
||||
<Stack direction="row" spacing={2}>
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 2,
|
||||
minWidth: 250,
|
||||
borderRadius: 2,
|
||||
border: '1px solid #e2e8f0',
|
||||
backgroundColor: '#f8fafc',
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 2 }}>
|
||||
<Typography variant="subtitle2" fontWeight={700}>
|
||||
Collections
|
||||
</Typography>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => setCollectionDialogOpen(true)}
|
||||
sx={{ color: '#3b82f6' }}
|
||||
>
|
||||
<Add />
|
||||
</IconButton>
|
||||
</Stack>
|
||||
<Stack spacing={1}>
|
||||
<Button
|
||||
fullWidth
|
||||
variant={selectedCollection === null ? 'contained' : 'outlined'}
|
||||
startIcon={<VideoLibrary />}
|
||||
onClick={() => setSelectedCollection(null)}
|
||||
sx={{ justifyContent: 'flex-start' }}
|
||||
>
|
||||
All Videos
|
||||
</Button>
|
||||
{collections.map((collection) => (
|
||||
<Button
|
||||
key={collection.id}
|
||||
fullWidth
|
||||
variant={selectedCollection === collection.id ? 'contained' : 'outlined'}
|
||||
startIcon={<Folder />}
|
||||
onClick={() => setSelectedCollection(collection.id)}
|
||||
sx={{ justifyContent: 'flex-start' }}
|
||||
>
|
||||
<Box sx={{ flex: 1, textAlign: 'left' }}>{collection.name}</Box>
|
||||
<Chip label={collection.asset_count} size="small" sx={{ ml: 1 }} />
|
||||
</Button>
|
||||
))}
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{/* Main Content Area */}
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Tabs value={tabValue} onChange={handleTabChange} sx={{ mb: 2 }}>
|
||||
<Tab label="All Videos" />
|
||||
<Tab label="Favorites" />
|
||||
</Tabs>
|
||||
|
||||
<TabPanel value={tabValue} index={0}>
|
||||
{loading ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
) : error ? (
|
||||
<Alert severity="error" action={<Button onClick={refetch}>Retry</Button>}>
|
||||
{error}
|
||||
</Alert>
|
||||
) : filteredAssets.length === 0 ? (
|
||||
<Paper sx={{ p: 4, textAlign: 'center' }}>
|
||||
<VideoLibrary sx={{ fontSize: 64, color: '#94a3b8', mb: 2 }} />
|
||||
<Typography variant="h6" color="text.secondary" gutterBottom>
|
||||
No videos found
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{searchQuery ? 'Try adjusting your search query' : 'Your video assets will appear here'}
|
||||
</Typography>
|
||||
</Paper>
|
||||
) : (
|
||||
<>
|
||||
{/* Bulk Actions */}
|
||||
{selectedAssets.size > 0 && (
|
||||
<Paper sx={{ p: 2, mb: 2, backgroundColor: '#eff6ff', border: '1px solid #93c5fd' }}>
|
||||
<Stack direction="row" spacing={2} alignItems="center">
|
||||
<Typography variant="body2" fontWeight={600}>
|
||||
{selectedAssets.size} selected
|
||||
</Typography>
|
||||
<Button size="small" onClick={handleBulkDownload} startIcon={<Download />}>
|
||||
Download
|
||||
</Button>
|
||||
<Button size="small" color="error" onClick={handleBulkDelete} startIcon={<Delete />}>
|
||||
Delete
|
||||
</Button>
|
||||
<Divider orientation="vertical" flexItem />
|
||||
<FormControl size="small" sx={{ minWidth: 200 }}>
|
||||
<InputLabel>Add to Collection</InputLabel>
|
||||
<Select
|
||||
value=""
|
||||
label="Add to Collection"
|
||||
onChange={(e) => handleAddToCollection(Number(e.target.value))}
|
||||
>
|
||||
{collections.map((collection) => (
|
||||
<MenuItem key={collection.id} value={collection.id}>
|
||||
{collection.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Stack>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* Grid View */}
|
||||
{viewMode === 'grid' ? (
|
||||
<Grid container spacing={2}>
|
||||
{filteredAssets.map((asset) => (
|
||||
<Grid item xs={12} sm={6} md={4} lg={3} key={asset.id}>
|
||||
<Card
|
||||
sx={{
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
border: selectedAssets.has(asset.id) ? '2px solid #3b82f6' : '1px solid #e2e8f0',
|
||||
}}
|
||||
>
|
||||
<Box sx={{ position: 'relative' }}>
|
||||
<Checkbox
|
||||
checked={selectedAssets.has(asset.id)}
|
||||
onChange={(e) => handleSelectAsset(asset.id, e.target.checked)}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
left: 8,
|
||||
zIndex: 1,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.9)',
|
||||
}}
|
||||
/>
|
||||
<CardMedia
|
||||
component="video"
|
||||
src={asset.file_url}
|
||||
sx={{
|
||||
height: 200,
|
||||
backgroundColor: '#000',
|
||||
objectFit: 'contain',
|
||||
}}
|
||||
controls
|
||||
/>
|
||||
<IconButton
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
right: 8,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.6)',
|
||||
color: '#fff',
|
||||
'&:hover': { backgroundColor: 'rgba(0, 0, 0, 0.8)' },
|
||||
}}
|
||||
size="small"
|
||||
onClick={(e) => setAnchorEl({ ...anchorEl, [asset.id]: e.currentTarget })}
|
||||
>
|
||||
<MoreVert />
|
||||
</IconButton>
|
||||
</Box>
|
||||
<CardContent sx={{ flexGrow: 1, p: 1.5 }}>
|
||||
<Typography variant="body2" fontWeight={600} noWrap>
|
||||
{asset.title || asset.filename}
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={1} sx={{ mt: 1, flexWrap: 'wrap', gap: 0.5 }}>
|
||||
{asset.model && (
|
||||
<Chip label={asset.model} size="small" variant="outlined" />
|
||||
)}
|
||||
{asset.cost > 0 && (
|
||||
<Chip label={`$${asset.cost.toFixed(4)}`} size="small" variant="outlined" />
|
||||
)}
|
||||
</Stack>
|
||||
</CardContent>
|
||||
<Box sx={{ p: 1, borderTop: '1px solid #e2e8f0' }}>
|
||||
<Stack direction="row" spacing={1} justifyContent="space-between">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => handleFavorite(asset.id)}
|
||||
color={asset.is_favorite ? 'error' : 'default'}
|
||||
>
|
||||
{asset.is_favorite ? <Favorite /> : <FavoriteBorder />}
|
||||
</IconButton>
|
||||
<IconButton size="small" onClick={() => handleDownload(asset)}>
|
||||
<Download />
|
||||
</IconButton>
|
||||
<IconButton size="small" color="error" onClick={() => handleDelete(asset.id)}>
|
||||
<Delete />
|
||||
</IconButton>
|
||||
</Stack>
|
||||
</Box>
|
||||
<Menu
|
||||
anchorEl={anchorEl[asset.id]}
|
||||
open={Boolean(anchorEl[asset.id])}
|
||||
onClose={() => setAnchorEl({ ...anchorEl, [asset.id]: null })}
|
||||
>
|
||||
<MenuItem onClick={() => handleDownload(asset)}>
|
||||
<ListItemIcon><Download fontSize="small" /></ListItemIcon>
|
||||
<ListItemText>Download</ListItemText>
|
||||
</MenuItem>
|
||||
<MenuItem onClick={() => handleFavorite(asset.id)}>
|
||||
<ListItemIcon>
|
||||
{asset.is_favorite ? <Favorite fontSize="small" /> : <FavoriteBorder fontSize="small" />}
|
||||
</ListItemIcon>
|
||||
<ListItemText>{asset.is_favorite ? 'Remove from Favorites' : 'Add to Favorites'}</ListItemText>
|
||||
</MenuItem>
|
||||
{selectedCollection && (
|
||||
<MenuItem onClick={() => handleRemoveFromCollection(asset.id)}>
|
||||
<ListItemIcon><Delete fontSize="small" /></ListItemIcon>
|
||||
<ListItemText>Remove from Collection</ListItemText>
|
||||
</MenuItem>
|
||||
)}
|
||||
<Divider />
|
||||
<MenuItem onClick={() => handleDelete(asset.id)}>
|
||||
<ListItemIcon><Delete fontSize="small" color="error" /></ListItemIcon>
|
||||
<ListItemText>Delete</ListItemText>
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</Card>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
) : (
|
||||
/* List View */
|
||||
<TableContainer component={Paper} elevation={0} sx={{ border: '1px solid #e2e8f0' }}>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell padding="checkbox">
|
||||
<Checkbox
|
||||
checked={selectedAssets.size === assets.length && assets.length > 0}
|
||||
indeterminate={selectedAssets.size > 0 && selectedAssets.size < assets.length}
|
||||
onChange={(e) => handleSelectAll(e.target.checked)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>Video</TableCell>
|
||||
<TableCell>Title</TableCell>
|
||||
<TableCell>Model</TableCell>
|
||||
<TableCell>Cost</TableCell>
|
||||
<TableCell>Created</TableCell>
|
||||
<TableCell align="right">Actions</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{filteredAssets.map((asset) => (
|
||||
<TableRow key={asset.id} hover>
|
||||
<TableCell padding="checkbox">
|
||||
<Checkbox
|
||||
checked={selectedAssets.has(asset.id)}
|
||||
onChange={(e) => handleSelectAsset(asset.id, e.target.checked)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<video
|
||||
src={asset.file_url}
|
||||
style={{ width: 120, height: 68, objectFit: 'cover', borderRadius: 4 }}
|
||||
controls
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="body2" fontWeight={600}>
|
||||
{asset.title || asset.filename}
|
||||
</Typography>
|
||||
{asset.description && (
|
||||
<Typography variant="caption" color="text.secondary" noWrap>
|
||||
{asset.description}
|
||||
</Typography>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{asset.model && <Chip label={asset.model} size="small" />}
|
||||
</TableCell>
|
||||
<TableCell>${asset.cost.toFixed(4)}</TableCell>
|
||||
<TableCell>
|
||||
{new Date(asset.created_at).toLocaleDateString()}
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<Stack direction="row" spacing={0.5} justifyContent="flex-end">
|
||||
<IconButton size="small" onClick={() => handleFavorite(asset.id)}>
|
||||
{asset.is_favorite ? <Favorite color="error" /> : <FavoriteBorder />}
|
||||
</IconButton>
|
||||
<IconButton size="small" onClick={() => handleDownload(asset)}>
|
||||
<Download />
|
||||
</IconButton>
|
||||
<IconButton size="small" color="error" onClick={() => handleDelete(asset.id)}>
|
||||
<Delete />
|
||||
</IconButton>
|
||||
</Stack>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mt: 3 }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Showing {page * pageSize + 1}-{Math.min((page + 1) * pageSize, total)} of {total} videos
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={1}>
|
||||
<Button
|
||||
disabled={page === 0}
|
||||
onClick={() => setPage(p => p - 1)}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
disabled={(page + 1) * pageSize >= total}
|
||||
onClick={() => setPage(p => p + 1)}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel value={tabValue} index={1}>
|
||||
{/* Favorites tab - same content as All Videos but filtered */}
|
||||
<Typography>Favorites view (same as above with favorites_only filter)</Typography>
|
||||
</TabPanel>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{/* Create Collection Dialog */}
|
||||
<Dialog open={collectionDialogOpen} onClose={() => setCollectionDialogOpen(false)} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>Create New Collection</DialogTitle>
|
||||
<DialogContent>
|
||||
<Stack spacing={2} sx={{ mt: 1 }}>
|
||||
<TextField
|
||||
label="Collection Name"
|
||||
value={newCollectionName}
|
||||
onChange={(e) => setNewCollectionName(e.target.value)}
|
||||
fullWidth
|
||||
required
|
||||
/>
|
||||
<TextField
|
||||
label="Description (optional)"
|
||||
value={newCollectionDescription}
|
||||
onChange={(e) => setNewCollectionDescription(e.target.value)}
|
||||
fullWidth
|
||||
multiline
|
||||
rows={3}
|
||||
/>
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setCollectionDialogOpen(false)}>Cancel</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleCreateCollection}
|
||||
disabled={!newCollectionName.trim()}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
<Snackbar
|
||||
open={snackbar.open}
|
||||
autoHideDuration={3000}
|
||||
onClose={() => setSnackbar({ ...snackbar, open: false })}
|
||||
message={snackbar.message}
|
||||
/>
|
||||
</Box>
|
||||
</VideoStudioLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default LibraryVideo;
|
||||
@@ -0,0 +1 @@
|
||||
export { LibraryVideo, default } from './LibraryVideo';
|
||||
240
frontend/src/hooks/useCollections.ts
Normal file
240
frontend/src/hooks/useCollections.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useAuth } from '@clerk/clerk-react';
|
||||
|
||||
export interface Collection {
|
||||
id: number;
|
||||
user_id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
is_public: boolean;
|
||||
cover_asset_id?: number;
|
||||
asset_count: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface CollectionCreateRequest {
|
||||
name: string;
|
||||
description?: string;
|
||||
is_public?: boolean;
|
||||
}
|
||||
|
||||
export interface CollectionUpdateRequest {
|
||||
name?: string;
|
||||
description?: string;
|
||||
is_public?: boolean;
|
||||
cover_asset_id?: number;
|
||||
}
|
||||
|
||||
const API_BASE_URL = process.env.REACT_APP_API_BASE_URL || 'http://localhost:8000';
|
||||
|
||||
export const useCollections = () => {
|
||||
const { getToken } = useAuth();
|
||||
const [collections, setCollections] = useState<Collection[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchCollections = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const token = await getToken();
|
||||
if (!token) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/api/content-assets/collections`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch collections: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setCollections(data.collections || []);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch collections');
|
||||
setCollections([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [getToken]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchCollections();
|
||||
}, [fetchCollections]);
|
||||
|
||||
const createCollection = useCallback(async (data: CollectionCreateRequest): Promise<Collection> => {
|
||||
try {
|
||||
const token = await getToken();
|
||||
if (!token) {
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/api/content-assets/collections`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to create collection');
|
||||
}
|
||||
|
||||
const collection = await response.json();
|
||||
setCollections(prev => [...prev, collection]);
|
||||
return collection;
|
||||
} catch (err) {
|
||||
console.error('Error creating collection:', err);
|
||||
throw err;
|
||||
}
|
||||
}, [getToken]);
|
||||
|
||||
const updateCollection = useCallback(async (
|
||||
collectionId: number,
|
||||
data: CollectionUpdateRequest
|
||||
): Promise<Collection> => {
|
||||
try {
|
||||
const token = await getToken();
|
||||
if (!token) {
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/api/content-assets/collections/${collectionId}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to update collection');
|
||||
}
|
||||
|
||||
const collection = await response.json();
|
||||
setCollections(prev =>
|
||||
prev.map(c => c.id === collectionId ? collection : c)
|
||||
);
|
||||
return collection;
|
||||
} catch (err) {
|
||||
console.error('Error updating collection:', err);
|
||||
throw err;
|
||||
}
|
||||
}, [getToken]);
|
||||
|
||||
const deleteCollection = useCallback(async (collectionId: number): Promise<boolean> => {
|
||||
try {
|
||||
const token = await getToken();
|
||||
if (!token) {
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/api/content-assets/collections/${collectionId}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to delete collection');
|
||||
}
|
||||
|
||||
setCollections(prev => prev.filter(c => c.id !== collectionId));
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('Error deleting collection:', err);
|
||||
throw err;
|
||||
}
|
||||
}, [getToken]);
|
||||
|
||||
const addAssetsToCollection = useCallback(async (
|
||||
collectionId: number,
|
||||
assetIds: number[]
|
||||
): Promise<number> => {
|
||||
try {
|
||||
const token = await getToken();
|
||||
if (!token) {
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/api/content-assets/collections/${collectionId}/assets`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ asset_ids: assetIds }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to add assets to collection');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
// Refresh collections to update asset counts
|
||||
await fetchCollections();
|
||||
return result.assets_added || 0;
|
||||
} catch (err) {
|
||||
console.error('Error adding assets to collection:', err);
|
||||
throw err;
|
||||
}
|
||||
}, [getToken, fetchCollections]);
|
||||
|
||||
const removeAssetsFromCollection = useCallback(async (
|
||||
collectionId: number,
|
||||
assetIds: number[]
|
||||
): Promise<number> => {
|
||||
try {
|
||||
const token = await getToken();
|
||||
if (!token) {
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/api/content-assets/collections/${collectionId}/assets`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ asset_ids: assetIds }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to remove assets from collection');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
// Refresh collections to update asset counts
|
||||
await fetchCollections();
|
||||
return result.assets_removed || 0;
|
||||
} catch (err) {
|
||||
console.error('Error removing assets from collection:', err);
|
||||
throw err;
|
||||
}
|
||||
}, [getToken, fetchCollections]);
|
||||
|
||||
return {
|
||||
collections,
|
||||
loading,
|
||||
error,
|
||||
refetch: fetchCollections,
|
||||
createCollection,
|
||||
updateCollection,
|
||||
deleteCollection,
|
||||
addAssetsToCollection,
|
||||
removeAssetsFromCollection,
|
||||
};
|
||||
};
|
||||
@@ -33,6 +33,11 @@ export interface AssetFilters {
|
||||
search?: string;
|
||||
tags?: string[];
|
||||
favorites_only?: boolean;
|
||||
collection_id?: number;
|
||||
date_from?: string;
|
||||
date_to?: string;
|
||||
sort_by?: string;
|
||||
sort_order?: 'asc' | 'desc';
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
@@ -63,6 +68,11 @@ export const useContentAssets = (filters: AssetFilters = {}) => {
|
||||
search: filters.search,
|
||||
tags: filters.tags,
|
||||
favorites_only: filters.favorites_only,
|
||||
collection_id: filters.collection_id,
|
||||
date_from: filters.date_from,
|
||||
date_to: filters.date_to,
|
||||
sort_by: filters.sort_by,
|
||||
sort_order: filters.sort_order,
|
||||
limit: filters.limit,
|
||||
offset: filters.offset,
|
||||
};
|
||||
@@ -72,6 +82,11 @@ export const useContentAssets = (filters: AssetFilters = {}) => {
|
||||
filters.search,
|
||||
filters.tags?.join(','),
|
||||
filters.favorites_only,
|
||||
filters.collection_id,
|
||||
filters.date_from,
|
||||
filters.date_to,
|
||||
filters.sort_by,
|
||||
filters.sort_order,
|
||||
filters.limit,
|
||||
filters.offset,
|
||||
]);
|
||||
@@ -131,6 +146,11 @@ export const useContentAssets = (filters: AssetFilters = {}) => {
|
||||
if (currentFilters.search) params.append('search', currentFilters.search);
|
||||
if (currentFilters.tags && currentFilters.tags.length > 0) params.append('tags', currentFilters.tags.join(','));
|
||||
if (currentFilters.favorites_only) params.append('favorites_only', 'true');
|
||||
if (currentFilters.collection_id) params.append('collection_id', String(currentFilters.collection_id));
|
||||
if (currentFilters.date_from) params.append('date_from', currentFilters.date_from);
|
||||
if (currentFilters.date_to) params.append('date_to', currentFilters.date_to);
|
||||
if (currentFilters.sort_by) params.append('sort_by', currentFilters.sort_by);
|
||||
if (currentFilters.sort_order) params.append('sort_order', currentFilters.sort_order);
|
||||
params.append('limit', String(currentFilters.limit || 100));
|
||||
params.append('offset', String(currentFilters.offset || 0));
|
||||
|
||||
|
||||
@@ -104,6 +104,176 @@ export interface EditOperationMeta {
|
||||
};
|
||||
}
|
||||
|
||||
export interface EditingModel {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
cost: number;
|
||||
cost_8k?: number;
|
||||
tier: 'budget' | 'mid' | 'premium';
|
||||
max_resolution: [number, number];
|
||||
capabilities: string[];
|
||||
use_cases: string[];
|
||||
features: string[];
|
||||
supports_multi_image: boolean;
|
||||
supports_controlnet: boolean;
|
||||
languages: string[];
|
||||
api_params?: {
|
||||
uses_size?: boolean;
|
||||
uses_aspect_ratio?: boolean;
|
||||
uses_resolution?: boolean;
|
||||
supports_guidance_scale?: boolean;
|
||||
supports_seed?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ModelRecommendation {
|
||||
recommended_model: string;
|
||||
reason: string;
|
||||
alternatives: Array<{
|
||||
model_id: string;
|
||||
name: string;
|
||||
cost: number;
|
||||
reason: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface FaceSwapModel {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
cost: number;
|
||||
tier: 'budget' | 'mid' | 'premium';
|
||||
capabilities: string[];
|
||||
use_cases: string[];
|
||||
features: string[];
|
||||
max_faces: number;
|
||||
}
|
||||
|
||||
export interface FaceSwapModelRecommendation {
|
||||
recommended_model: string;
|
||||
reason: string;
|
||||
alternatives: Array<{
|
||||
model_id: string;
|
||||
name: string;
|
||||
cost: number;
|
||||
reason: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface FaceSwapRequestPayload {
|
||||
base_image_base64: string;
|
||||
face_image_base64: string;
|
||||
model?: string;
|
||||
target_face_index?: number;
|
||||
target_gender?: string;
|
||||
options?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface FaceSwapResult {
|
||||
success: boolean;
|
||||
image_base64: string;
|
||||
width: number;
|
||||
height: number;
|
||||
provider: string;
|
||||
model: string;
|
||||
metadata: Record<string, any>;
|
||||
}
|
||||
|
||||
// Compression types
|
||||
export interface CompressionRequest {
|
||||
image_base64: string;
|
||||
quality: number;
|
||||
format: string;
|
||||
target_size_kb?: number;
|
||||
strip_metadata: boolean;
|
||||
progressive: boolean;
|
||||
optimize: boolean;
|
||||
}
|
||||
|
||||
export interface CompressionResult {
|
||||
success: boolean;
|
||||
image_base64: string;
|
||||
original_size_kb: number;
|
||||
compressed_size_kb: number;
|
||||
compression_ratio: number;
|
||||
format: string;
|
||||
width: number;
|
||||
height: number;
|
||||
quality_used: number;
|
||||
metadata_stripped: boolean;
|
||||
}
|
||||
|
||||
export interface CompressionFormat {
|
||||
id: string;
|
||||
name: string;
|
||||
extension: string;
|
||||
description: string;
|
||||
supports_transparency: boolean;
|
||||
quality_range: [number, number];
|
||||
recommended_quality: number;
|
||||
use_cases: string[];
|
||||
}
|
||||
|
||||
export interface CompressionPreset {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
format: string;
|
||||
quality: number;
|
||||
target_size_kb?: number;
|
||||
strip_metadata: boolean;
|
||||
}
|
||||
|
||||
export interface CompressionEstimate {
|
||||
original_size_kb: number;
|
||||
estimated_size_kb: number;
|
||||
estimated_reduction_percent: number;
|
||||
width: number;
|
||||
height: number;
|
||||
format: string;
|
||||
}
|
||||
|
||||
// Format Converter types
|
||||
export interface FormatConversionRequest {
|
||||
image_base64: string;
|
||||
target_format: string;
|
||||
preserve_transparency: boolean;
|
||||
quality?: number;
|
||||
color_space?: string;
|
||||
strip_metadata: boolean;
|
||||
optimize: boolean;
|
||||
progressive: boolean;
|
||||
}
|
||||
|
||||
export interface FormatConversionResult {
|
||||
success: boolean;
|
||||
image_base64: string;
|
||||
original_format: string;
|
||||
target_format: string;
|
||||
original_size_kb: number;
|
||||
converted_size_kb: number;
|
||||
width: number;
|
||||
height: number;
|
||||
transparency_preserved: boolean;
|
||||
metadata_preserved: boolean;
|
||||
color_space?: string;
|
||||
}
|
||||
|
||||
export interface SupportedFormat {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
supports_transparency: boolean;
|
||||
supports_lossy: boolean;
|
||||
mime_type: string;
|
||||
}
|
||||
|
||||
export interface FormatRecommendation {
|
||||
format: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface EditImageRequestPayload {
|
||||
image_base64: string;
|
||||
operation: string;
|
||||
@@ -411,6 +581,146 @@ export const useImageStudio = () => {
|
||||
setEditError(null);
|
||||
}, []);
|
||||
|
||||
// Edit model selection state
|
||||
const [editModels, setEditModels] = useState<EditingModel[]>([]);
|
||||
const [isLoadingEditModels, setIsLoadingEditModels] = useState(false);
|
||||
const [modelRecommendation, setModelRecommendation] = useState<ModelRecommendation | null>(null);
|
||||
const [isLoadingRecommendation, setIsLoadingRecommendation] = useState(false);
|
||||
|
||||
// Face swap state
|
||||
const [faceSwapModels, setFaceSwapModels] = useState<FaceSwapModel[]>([]);
|
||||
const [isLoadingFaceSwapModels, setIsLoadingFaceSwapModels] = useState(false);
|
||||
const [faceSwapModelRecommendation, setFaceSwapModelRecommendation] = useState<FaceSwapModelRecommendation | null>(null);
|
||||
const [isLoadingFaceSwapRecommendation, setIsLoadingFaceSwapRecommendation] = useState(false);
|
||||
const [isProcessingFaceSwap, setIsProcessingFaceSwap] = useState(false);
|
||||
const [faceSwapResult, setFaceSwapResult] = useState<FaceSwapResult | null>(null);
|
||||
const [faceSwapError, setFaceSwapError] = useState<string | null>(null);
|
||||
|
||||
// Compression state
|
||||
const [compressionFormats, setCompressionFormats] = useState<CompressionFormat[]>([]);
|
||||
const [compressionPresets, setCompressionPresets] = useState<CompressionPreset[]>([]);
|
||||
const [isCompressing, setIsCompressing] = useState(false);
|
||||
const [compressionResult, setCompressionResult] = useState<CompressionResult | null>(null);
|
||||
const [compressionError, setCompressionError] = useState<string | null>(null);
|
||||
const [compressionEstimate, setCompressionEstimate] = useState<CompressionEstimate | null>(null);
|
||||
|
||||
// Format Converter state
|
||||
const [supportedFormats, setSupportedFormats] = useState<SupportedFormat[]>([]);
|
||||
const [isConvertingFormat, setIsConvertingFormat] = useState(false);
|
||||
const [formatConversionResult, setFormatConversionResult] = useState<FormatConversionResult | null>(null);
|
||||
const [formatConversionError, setFormatConversionError] = useState<string | null>(null);
|
||||
const [formatRecommendations, setFormatRecommendations] = useState<FormatRecommendation[]>([]);
|
||||
|
||||
// Load available editing models
|
||||
const loadEditModels = useCallback(async (operation?: string, tier?: string) => {
|
||||
setIsLoadingEditModels(true);
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (operation) params.append('operation', operation);
|
||||
if (tier) params.append('tier', tier);
|
||||
|
||||
const response = await aiApiClient.get(`/api/image-studio/edit/models?${params.toString()}`);
|
||||
setEditModels(response.data.models || []);
|
||||
return response.data.models || [];
|
||||
} catch (err: any) {
|
||||
console.error('Failed to load edit models:', err);
|
||||
return [];
|
||||
} finally {
|
||||
setIsLoadingEditModels(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Get model recommendation
|
||||
const getModelRecommendation = useCallback(async (
|
||||
operation: string,
|
||||
imageResolution?: { width: number; height: number },
|
||||
userTier?: string,
|
||||
preferences?: { prioritize_cost?: boolean; prioritize_quality?: boolean }
|
||||
) => {
|
||||
setIsLoadingRecommendation(true);
|
||||
try {
|
||||
const response = await aiApiClient.post('/api/image-studio/edit/recommend', {
|
||||
operation,
|
||||
image_resolution: imageResolution,
|
||||
user_tier: userTier,
|
||||
preferences,
|
||||
});
|
||||
setModelRecommendation(response.data);
|
||||
return response.data;
|
||||
} catch (err: any) {
|
||||
console.error('Failed to get model recommendation:', err);
|
||||
return null;
|
||||
} finally {
|
||||
setIsLoadingRecommendation(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Load face swap models
|
||||
const loadFaceSwapModels = useCallback(async (tier?: string) => {
|
||||
setIsLoadingFaceSwapModels(true);
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (tier) params.append('tier', tier);
|
||||
|
||||
const response = await aiApiClient.get(`/api/image-studio/face-swap/models?${params.toString()}`);
|
||||
setFaceSwapModels(response.data.models || []);
|
||||
return response.data.models || [];
|
||||
} catch (err: any) {
|
||||
console.error('Failed to load face swap models:', err);
|
||||
return [];
|
||||
} finally {
|
||||
setIsLoadingFaceSwapModels(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Get face swap model recommendation
|
||||
const getFaceSwapModelRecommendation = useCallback(async (
|
||||
baseImageResolution?: { width: number; height: number },
|
||||
faceImageResolution?: { width: number; height: number },
|
||||
userTier?: string,
|
||||
preferences?: { prioritize_cost?: boolean; prioritize_quality?: boolean }
|
||||
) => {
|
||||
setIsLoadingFaceSwapRecommendation(true);
|
||||
try {
|
||||
const response = await aiApiClient.post('/api/image-studio/face-swap/recommend', {
|
||||
base_image_resolution: baseImageResolution,
|
||||
face_image_resolution: faceImageResolution,
|
||||
user_tier: userTier,
|
||||
preferences,
|
||||
});
|
||||
setFaceSwapModelRecommendation(response.data);
|
||||
return response.data;
|
||||
} catch (err: any) {
|
||||
console.error('Failed to get face swap model recommendation:', err);
|
||||
return null;
|
||||
} finally {
|
||||
setIsLoadingFaceSwapRecommendation(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Process face swap
|
||||
const processFaceSwap = useCallback(async (payload: FaceSwapRequestPayload) => {
|
||||
setIsProcessingFaceSwap(true);
|
||||
setFaceSwapError(null);
|
||||
try {
|
||||
const response = await aiApiClient.post('/api/image-studio/face-swap/process', payload);
|
||||
setFaceSwapResult(response.data);
|
||||
return response.data as FaceSwapResult;
|
||||
} catch (err: any) {
|
||||
console.error('Failed to process face swap:', err);
|
||||
const message = err.response?.data?.detail || 'Failed to process face swap';
|
||||
setFaceSwapError(message);
|
||||
throw new Error(message);
|
||||
} finally {
|
||||
setIsProcessingFaceSwap(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const clearFaceSwapResult = useCallback(() => {
|
||||
setFaceSwapResult(null);
|
||||
setFaceSwapError(null);
|
||||
}, []);
|
||||
|
||||
// Process upscale
|
||||
const processUpscale = useCallback(async (payload: UpscaleRequestPayload) => {
|
||||
setIsUpscaling(true);
|
||||
@@ -511,6 +821,122 @@ export const useImageStudio = () => {
|
||||
setOptimizeError(null);
|
||||
}, []);
|
||||
|
||||
// Load compression formats
|
||||
const loadCompressionFormats = useCallback(async () => {
|
||||
try {
|
||||
const response = await aiApiClient.get('/api/image-studio/compress/formats');
|
||||
setCompressionFormats(response.data.formats || []);
|
||||
return response.data.formats || [];
|
||||
} catch (err: any) {
|
||||
console.error('Failed to load compression formats:', err);
|
||||
return [];
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Load compression presets
|
||||
const loadCompressionPresets = useCallback(async () => {
|
||||
try {
|
||||
const response = await aiApiClient.get('/api/image-studio/compress/presets');
|
||||
setCompressionPresets(response.data.presets || []);
|
||||
return response.data.presets || [];
|
||||
} catch (err: any) {
|
||||
console.error('Failed to load compression presets:', err);
|
||||
return [];
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Estimate compression
|
||||
const estimateCompression = useCallback(async (
|
||||
image_base64: string,
|
||||
format: string = 'jpeg',
|
||||
quality: number = 85,
|
||||
): Promise<CompressionEstimate | null> => {
|
||||
try {
|
||||
const response = await aiApiClient.post('/api/image-studio/compress/estimate', {
|
||||
image_base64,
|
||||
format,
|
||||
quality,
|
||||
});
|
||||
setCompressionEstimate(response.data);
|
||||
return response.data;
|
||||
} catch (err: any) {
|
||||
console.error('Failed to estimate compression:', err);
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Process compression
|
||||
const processCompression = useCallback(async (request: CompressionRequest): Promise<CompressionResult> => {
|
||||
setIsCompressing(true);
|
||||
setCompressionError(null);
|
||||
try {
|
||||
const response = await aiApiClient.post('/api/image-studio/compress', request);
|
||||
setCompressionResult(response.data);
|
||||
return response.data;
|
||||
} catch (err: any) {
|
||||
console.error('Failed to compress image:', err);
|
||||
const message = err.response?.data?.detail || 'Failed to compress image';
|
||||
setCompressionError(message);
|
||||
throw new Error(message);
|
||||
} finally {
|
||||
setIsCompressing(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const clearCompressionResult = useCallback(() => {
|
||||
setCompressionResult(null);
|
||||
setCompressionError(null);
|
||||
setCompressionEstimate(null);
|
||||
}, []);
|
||||
|
||||
// Load supported formats
|
||||
const loadSupportedFormats = useCallback(async () => {
|
||||
try {
|
||||
const response = await aiApiClient.get('/api/image-studio/convert-format/supported');
|
||||
setSupportedFormats(response.data.formats || []);
|
||||
return response.data.formats || [];
|
||||
} catch (err: any) {
|
||||
console.error('Failed to load supported formats:', err);
|
||||
return [];
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Get format recommendations
|
||||
const getFormatRecommendations = useCallback(async (sourceFormat: string): Promise<FormatRecommendation[]> => {
|
||||
try {
|
||||
const response = await aiApiClient.get(`/api/image-studio/convert-format/recommendations?source_format=${sourceFormat}`);
|
||||
setFormatRecommendations(response.data.recommendations || []);
|
||||
return response.data.recommendations || [];
|
||||
} catch (err: any) {
|
||||
console.error('Failed to get format recommendations:', err);
|
||||
return [];
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Process format conversion
|
||||
const processFormatConversion = useCallback(async (request: FormatConversionRequest): Promise<FormatConversionResult> => {
|
||||
setIsConvertingFormat(true);
|
||||
setFormatConversionError(null);
|
||||
try {
|
||||
const response = await aiApiClient.post('/api/image-studio/convert-format', request);
|
||||
setFormatConversionResult(response.data);
|
||||
return response.data;
|
||||
} catch (err: any) {
|
||||
console.error('Failed to convert format:', err);
|
||||
const message = err.response?.data?.detail || 'Failed to convert image format';
|
||||
setFormatConversionError(message);
|
||||
throw new Error(message);
|
||||
} finally {
|
||||
setIsConvertingFormat(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const clearFormatConversionResult = useCallback(() => {
|
||||
setFormatConversionResult(null);
|
||||
setFormatConversionError(null);
|
||||
setFormatRecommendations([]);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
// State
|
||||
templates,
|
||||
@@ -557,6 +983,47 @@ export const useImageStudio = () => {
|
||||
optimizeResult,
|
||||
optimizeError,
|
||||
clearOptimizeResult,
|
||||
// Edit model selection
|
||||
editModels,
|
||||
isLoadingEditModels,
|
||||
loadEditModels,
|
||||
modelRecommendation,
|
||||
isLoadingRecommendation,
|
||||
getModelRecommendation,
|
||||
// Face swap
|
||||
faceSwapModels,
|
||||
isLoadingFaceSwapModels,
|
||||
loadFaceSwapModels,
|
||||
faceSwapModelRecommendation,
|
||||
isLoadingFaceSwapRecommendation,
|
||||
getFaceSwapModelRecommendation,
|
||||
processFaceSwap,
|
||||
isProcessingFaceSwap,
|
||||
faceSwapResult,
|
||||
faceSwapError,
|
||||
clearFaceSwapResult,
|
||||
// Compression
|
||||
compressionFormats,
|
||||
compressionPresets,
|
||||
loadCompressionFormats,
|
||||
loadCompressionPresets,
|
||||
estimateCompression,
|
||||
processCompression,
|
||||
isCompressing,
|
||||
compressionResult,
|
||||
compressionError,
|
||||
compressionEstimate,
|
||||
clearCompressionResult,
|
||||
// Format Converter
|
||||
supportedFormats,
|
||||
loadSupportedFormats,
|
||||
getFormatRecommendations,
|
||||
processFormatConversion,
|
||||
isConvertingFormat,
|
||||
formatConversionResult,
|
||||
formatConversionError,
|
||||
formatRecommendations,
|
||||
clearFormatConversionResult,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -226,8 +226,11 @@ export const ResearchTest: React.FC = () => {
|
||||
console.log('[ResearchTest] Starting persona check...');
|
||||
try {
|
||||
const config = await getResearchConfig();
|
||||
console.log('[ResearchTest] Config received:', {
|
||||
console.log('[ResearchTest] 📥 Config received:', {
|
||||
hasResearchPersona: !!config.research_persona,
|
||||
researchPersonaType: typeof config.research_persona,
|
||||
researchPersonaKeys: config.research_persona ? Object.keys(config.research_persona) : null,
|
||||
hasResearchPersonaFlag: config.persona_defaults?.has_research_persona,
|
||||
onboardingCompleted: config.onboarding_completed,
|
||||
personaScheduled: config.persona_scheduled,
|
||||
personaDefaults: config.persona_defaults
|
||||
@@ -236,7 +239,19 @@ export const ResearchTest: React.FC = () => {
|
||||
setPersonaData(config.persona_defaults || null);
|
||||
|
||||
// CASE 1: Research persona exists in database
|
||||
if (config.research_persona) {
|
||||
// Check both research_persona object and has_research_persona flag for robustness
|
||||
const hasPersonaObject = config.research_persona && typeof config.research_persona === 'object' && Object.keys(config.research_persona).length > 0;
|
||||
const hasPersonaFlag = config.persona_defaults?.has_research_persona === true;
|
||||
const hasPersona = hasPersonaObject || hasPersonaFlag;
|
||||
|
||||
console.log('[ResearchTest] 🔍 Persona check:', {
|
||||
hasPersonaObject,
|
||||
hasPersonaFlag,
|
||||
hasPersona,
|
||||
researchPersona: config.research_persona
|
||||
});
|
||||
|
||||
if (hasPersona && config.research_persona) {
|
||||
console.log('[ResearchTest] ✅ CASE 1: Research persona found in database');
|
||||
console.log('[ResearchTest] Persona details:', {
|
||||
defaultIndustry: config.research_persona.default_industry,
|
||||
|
||||
Reference in New Issue
Block a user