AI Researcher and Video Studio implementation complete

This commit is contained in:
ajaysi
2026-01-05 15:49:51 +05:30
parent b134e9dc7e
commit 0b63ae7fc1
200 changed files with 39535 additions and 1375 deletions

View File

@@ -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);

View 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>
);

View File

@@ -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>

View 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>
);
};

View 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>
);

View 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>
);
};

View 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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View 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>
);
};

View 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>
);
};

View File

@@ -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',

View File

@@ -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';

View File

@@ -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>
)}

View File

@@ -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,
});

View File

@@ -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>
);

View File

@@ -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 */}

View File

@@ -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}
/>
)}
</>
);
};

View File

@@ -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;

View File

@@ -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>
);
};

View File

@@ -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>
)}
</>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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;

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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';

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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 */}

View File

@@ -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>
);

View File

@@ -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>
);
};

View File

@@ -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;

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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;
};

View File

@@ -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;
};

View File

@@ -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,
};
};

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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)',
}}
>

View File

@@ -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

View 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>
);
};

View File

@@ -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>
);
};

View File

@@ -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 (510 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 (515 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.25x4x), 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'],
},
];

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -1,3 +1,5 @@
export { CreateVideoPreview } from './CreateVideoPreview';
export { AvatarVideoPreview } from './AvatarVideoPreview';
export { EnhanceVideoPreview } from './EnhanceVideoPreview';
export { VideoTranslatePreview } from './VideoTranslatePreview';
export { VideoBackgroundRemoverPreview } from './VideoBackgroundRemoverPreview';

View File

@@ -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[];
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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';

View File

@@ -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,
};
};

View File

@@ -0,0 +1 @@
export { EditVideo, default } from './EditVideo';

View File

@@ -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;

View File

@@ -0,0 +1 @@
export { LibraryVideo, default } from './LibraryVideo';