AI Image Studio Phase 1

This commit is contained in:
ajaysi
2025-11-20 09:06:00 +05:30
parent e96525347b
commit eede21ad42
58 changed files with 12951 additions and 8 deletions

View File

@@ -0,0 +1,194 @@
import React from 'react';
import {
Box,
Paper,
Typography,
Stack,
Chip,
Divider,
alpha,
} from '@mui/material';
import {
AttachMoney,
TrendingUp,
Speed,
Info,
} from '@mui/icons-material';
import { motion } from 'framer-motion';
const MotionPaper = motion(Paper);
interface CostEstimate {
provider: string;
model?: string;
operation: string;
num_images: number;
cost_per_image: number;
total_cost: number;
currency: string;
estimated: boolean;
}
interface CostEstimatorProps {
estimate: CostEstimate;
}
export const CostEstimator: React.FC<CostEstimatorProps> = ({ estimate }) => {
// Format currency
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: estimate.currency || 'USD',
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(amount);
};
// Get cost level (low, medium, high)
const getCostLevel = () => {
if (estimate.total_cost === 0) return { label: 'Free', color: '#10b981' };
if (estimate.total_cost < 0.50) return { label: 'Low Cost', color: '#10b981' };
if (estimate.total_cost < 2.00) return { label: 'Medium Cost', color: '#f59e0b' };
return { label: 'Premium Cost', color: '#8b5cf6' };
};
const costLevel = getCostLevel();
return (
<MotionPaper
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
elevation={0}
sx={{
background: `linear-gradient(135deg, ${alpha(costLevel.color, 0.05)}, ${alpha(costLevel.color, 0.02)})`,
border: `1px solid ${alpha(costLevel.color, 0.2)}`,
borderRadius: 2,
p: 2,
}}
>
<Stack spacing={2}>
{/* Header */}
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Stack direction="row" spacing={1} alignItems="center">
<Box
sx={{
width: 32,
height: 32,
borderRadius: 1,
background: `linear-gradient(135deg, ${costLevel.color}, ${alpha(costLevel.color, 0.7)})`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#fff',
}}
>
<AttachMoney sx={{ fontSize: 20 }} />
</Box>
<Box>
<Typography variant="subtitle2" sx={{ fontWeight: 700, lineHeight: 1.2 }}>
Cost Estimate
</Typography>
{estimate.estimated && (
<Typography variant="caption" sx={{ color: 'text.secondary', fontSize: 10 }}>
Estimated pricing
</Typography>
)}
</Box>
</Stack>
<Chip
label={costLevel.label}
size="small"
sx={{
background: costLevel.color,
color: '#fff',
fontWeight: 700,
fontSize: 11,
}}
/>
</Box>
<Divider />
{/* Cost Breakdown */}
<Stack spacing={1.5}>
{/* Per Image Cost */}
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="body2" sx={{ color: 'text.secondary', fontSize: 13 }}>
Cost per image
</Typography>
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: 13 }}>
{formatCurrency(estimate.cost_per_image)}
</Typography>
</Box>
{/* Number of Images */}
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="body2" sx={{ color: 'text.secondary', fontSize: 13 }}>
Number of images
</Typography>
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: 13 }}>
×{estimate.num_images}
</Typography>
</Box>
{/* Provider */}
{estimate.provider && (
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="body2" sx={{ color: 'text.secondary', fontSize: 13 }}>
Provider
</Typography>
<Chip
label={estimate.provider}
size="small"
sx={{
height: 20,
fontSize: 11,
fontWeight: 600,
background: alpha('#667eea', 0.1),
color: '#667eea',
}}
/>
</Box>
)}
<Divider />
{/* Total Cost */}
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="body1" sx={{ fontWeight: 700 }}>
Total Cost
</Typography>
<Typography
variant="h6"
sx={{
fontWeight: 700,
color: costLevel.color,
}}
>
{formatCurrency(estimate.total_cost)}
</Typography>
</Box>
</Stack>
{/* Info Note */}
<Box
sx={{
background: alpha('#667eea', 0.05),
borderRadius: 1,
p: 1.5,
display: 'flex',
gap: 1,
}}
>
<Info sx={{ fontSize: 16, color: '#667eea', flexShrink: 0, mt: 0.2 }} />
<Typography variant="caption" sx={{ color: 'text.secondary', lineHeight: 1.5 }}>
Costs are estimated and may vary. You will only be charged for successfully generated images. Failed generations are not billed.
</Typography>
</Box>
</Stack>
</MotionPaper>
);
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,262 @@
import React, { useState } from 'react';
import {
Box,
Card,
CardContent,
Typography,
Button,
Stack,
IconButton,
Tooltip,
Dialog,
DialogContent,
} from '@mui/material';
import UploadIcon from '@mui/icons-material/CloudUpload';
import DeleteIcon from '@mui/icons-material/DeleteOutline';
import BrushIcon from '@mui/icons-material/Brush';
import { alpha } from '@mui/material/styles';
import { ImageMaskEditor } from './ImageMaskEditor';
interface EditImageUploaderProps {
baseImage?: string | null;
maskImage?: string | null;
backgroundImage?: string | null;
lightingImage?: string | null;
requiresMask?: boolean;
requiresBackground?: boolean;
requiresLighting?: boolean;
onBaseImageChange: (value: string | null) => void;
onMaskImageChange: (value: string | null) => void;
onBackgroundImageChange: (value: string | null) => void;
onLightingImageChange: (value: string | null) => void;
onOpenMaskEditor?: () => 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;
value?: string | null;
onChange: (value: string | null) => void;
}> = ({ label, helper, 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">
<Box>
<Typography variant="subtitle2" fontWeight={700}>
{label}
</Typography>
{helper && (
<Typography variant="caption" color="text.secondary">
{helper}
</Typography>
)}
</Box>
{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' }}
/>
</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 EditImageUploader: React.FC<EditImageUploaderProps> = ({
baseImage,
maskImage,
backgroundImage,
lightingImage,
requiresMask,
requiresBackground,
requiresLighting,
onBaseImageChange,
onMaskImageChange,
onBackgroundImageChange,
onLightingImageChange,
onOpenMaskEditor,
}) => {
return (
<Stack spacing={2.5}>
<UploadSlot
label="Primary Image"
helper="Required. Upload the image you want to edit."
value={baseImage}
onChange={onBaseImageChange}
/>
{requiresMask && (
<>
<Card
variant="outlined"
sx={{
borderRadius: 3,
borderStyle: maskImage ? 'solid' : 'dashed',
borderColor: maskImage ? alpha('#667eea', 0.4) : alpha('#cbd5f5', 0.8),
background: maskImage ? alpha('#667eea', 0.08) : alpha('#667eea', 0.02),
position: 'relative',
}}
>
<CardContent>
<Stack spacing={1.5}>
<Stack direction="row" alignItems="center" justifyContent="space-between">
<Box>
<Typography variant="subtitle2" fontWeight={700}>
Mask (Optional)
</Typography>
<Typography variant="caption" color="text.secondary">
White reveals areas to edit, black preserves original pixels.
</Typography>
</Box>
{maskImage && (
<Tooltip title="Remove mask">
<IconButton size="small" onClick={() => onMaskImageChange(null)}>
<DeleteIcon fontSize="small" />
</IconButton>
</Tooltip>
)}
</Stack>
{maskImage ? (
<Box
sx={{
borderRadius: 2,
overflow: 'hidden',
border: '1px solid rgba(255,255,255,0.2)',
}}
>
<img
src={maskImage}
alt="Mask preview"
style={{ width: '100%', display: 'block', objectFit: 'cover' }}
/>
</Box>
) : (
<Stack direction="row" spacing={1}>
<Button
variant="outlined"
component="label"
startIcon={<UploadIcon />}
sx={{
flex: 1,
borderRadius: 2,
borderStyle: 'dashed',
py: 2,
color: '#667eea',
borderColor: alpha('#667eea', 0.6),
}}
>
Upload Mask
<input hidden type="file" accept="image/*" onChange={async (e) => {
const file = e.target.files?.[0];
if (!file) return;
const dataUrl = await readFileAsDataURL(file);
onMaskImageChange(dataUrl);
}} />
</Button>
{baseImage && onOpenMaskEditor && (
<Button
variant="contained"
startIcon={<BrushIcon />}
onClick={onOpenMaskEditor}
sx={{
borderRadius: 2,
py: 2,
background: 'linear-gradient(90deg, #667eea, #764ba2)',
'&:hover': {
background: 'linear-gradient(90deg, #5568d3, #65408b)',
},
}}
>
Create Mask
</Button>
)}
</Stack>
)}
</Stack>
</CardContent>
</Card>
</>
)}
{requiresBackground && (
<UploadSlot
label="Background Reference"
helper="Provide a new background reference image."
value={backgroundImage}
onChange={onBackgroundImageChange}
/>
)}
{requiresLighting && (
<UploadSlot
label="Lighting Reference"
helper="Optional. Match subject lighting to this reference."
value={lightingImage}
onChange={onLightingImageChange}
/>
)}
</Stack>
);
};

View File

@@ -0,0 +1,136 @@
import React from 'react';
import {
Box,
Card,
CardContent,
Chip,
CircularProgress,
Grid,
Stack,
Typography,
} from '@mui/material';
import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome';
import BoltIcon from '@mui/icons-material/Bolt';
import { alpha } from '@mui/material/styles';
import { EditOperationMeta } from '../../hooks/useImageStudio';
interface EditOperationsToolbarProps {
operations: Record<string, EditOperationMeta>;
selectedOperation: string;
onSelect: (key: string) => void;
loading?: boolean;
}
export const EditOperationsToolbar: React.FC<EditOperationsToolbarProps> = ({
operations,
selectedOperation,
onSelect,
loading,
}) => {
if (loading) {
return (
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
minHeight: 160,
}}
>
<CircularProgress />
</Box>
);
}
const entries = Object.entries(operations);
return (
<Grid container spacing={2}>
{entries.map(([key, meta]) => {
const isSelected = selectedOperation === key;
return (
<Grid item xs={12} md={6} key={key}>
<Card
onClick={() => onSelect(key)}
sx={{
cursor: 'pointer',
borderRadius: 3,
borderWidth: 2,
borderStyle: 'solid',
borderColor: isSelected ? alpha('#667eea', 0.8) : 'transparent',
background: isSelected
? 'linear-gradient(135deg, rgba(102, 126, 234, 0.2), rgba(118, 75, 162, 0.2))'
: 'rgba(255,255,255,0.08)',
transition: 'all 0.2s ease',
'&:hover': {
borderColor: alpha('#667eea', 0.6),
},
}}
>
<CardContent>
<Stack spacing={1.2}>
<Stack direction="row" justifyContent="space-between" alignItems="center">
<Typography variant="subtitle1" fontWeight={700}>
{meta.label}
</Typography>
<Chip
size="small"
icon={<AutoAwesomeIcon sx={{ fontSize: 16 }} />}
label={meta.provider}
sx={{
textTransform: 'capitalize',
background: alpha('#1f2937', 0.6),
color: '#fff',
}}
/>
</Stack>
<Typography variant="body2" color="text.secondary">
{meta.description}
</Typography>
<Stack direction="row" spacing={1}>
{meta.async && (
<Chip
size="small"
icon={<BoltIcon sx={{ fontSize: 16 }} />}
label="Async"
sx={{
background: alpha('#f59e0b', 0.15),
color: '#f59e0b',
fontWeight: 600,
}}
/>
)}
{meta.fields?.mask && (
<Chip
size="small"
label="Mask"
sx={{
background: alpha('#10b981', 0.15),
color: '#10b981',
fontWeight: 600,
}}
/>
)}
{meta.fields?.background && (
<Chip
size="small"
label="Background"
sx={{
background: alpha('#6366f1', 0.2),
color: '#6366f1',
fontWeight: 600,
}}
/>
)}
</Stack>
</Stack>
</CardContent>
</Card>
</Grid>
);
})}
</Grid>
);
};

View File

@@ -0,0 +1,189 @@
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 { EditResult } from '../../hooks/useImageStudio';
interface EditResultViewerProps {
originalImage?: string | null;
result?: EditResult | null;
isProcessing?: boolean;
onReset?: () => void;
}
export const EditResultViewer: React.FC<EditResultViewerProps> = ({
originalImage,
result,
isProcessing,
onReset,
}) => {
const handleDownload = () => {
if (!result?.image_base64) return;
const link = document.createElement('a');
link.href = result.image_base64;
link.download = `edit-${result.operation}-${Date.now()}.png`;
link.click();
};
if (!originalImage) {
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 an image to preview edits.
</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 && !originalImage} 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}>
<Box sx={{ 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={originalImage}
alt="Original reference"
style={{ width: '100%', display: 'block' }}
/>
</Box>
</Box>
<Box sx={{ flex: 1 }}>
<Typography variant="caption" color="text.secondary">
Edited
</Typography>
<Box
sx={{
mt: 1,
borderRadius: 2,
minHeight: 180,
border: '1px solid rgba(255,255,255,0.1)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
position: 'relative',
}}
>
{isProcessing && (
<Stack alignItems="center" spacing={1} py={6}>
<CircularProgress />
<Typography variant="body2" color="text.secondary">
Applying edits...
</Typography>
</Stack>
)}
{!isProcessing && result?.image_base64 && (
<img
src={result.image_base64}
alt="Edited result"
style={{ width: '100%', display: 'block' }}
/>
)}
{!isProcessing && !result && (
<Typography variant="body2" color="text.secondary">
No edits yet. Configure options and apply.
</Typography>
)}
</Box>
</Box>
</Stack>
{result && (
<>
<Divider sx={{ my: 2 }} />
<Stack direction="row" spacing={2} flexWrap="wrap" useFlexGap>
<ChipLabel label="Operation" value={result.operation} />
<ChipLabel label="Provider" value={result.provider} />
<ChipLabel label="Resolution" value={`${result.width}×${result.height}`} />
{result.metadata?.style_preset && (
<ChipLabel label="Style" value={result.metadata.style_preset} />
)}
</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,414 @@
import React, { useEffect, useMemo, useState } from 'react';
import {
Box,
Grid,
Paper,
Stack,
Typography,
TextField,
Alert,
Slider,
Divider,
Chip,
Dialog,
DialogContent,
IconButton,
} from '@mui/material';
import { alpha } from '@mui/material/styles';
import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh';
import CloseIcon from '@mui/icons-material/Close';
import { motion, type Variants, type Easing } from 'framer-motion';
import {
useImageStudio,
EditOperationMeta,
EditImageRequestPayload,
} from '../../hooks/useImageStudio';
import { EditImageUploader } from './EditImageUploader';
import { EditOperationsToolbar } from './EditOperationsToolbar';
import { EditResultViewer } from './EditResultViewer';
import { ImageStudioLayout } from './ImageStudioLayout';
import { OperationButton } from '../shared/OperationButton';
import { ImageMaskEditor } from './ImageMaskEditor';
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 EditStudio: React.FC = () => {
const {
loadEditOperations,
editOperations,
isLoadingEditOps,
processEdit,
isProcessingEdit,
editResult,
editError,
clearEditResult,
} = useImageStudio();
const [operation, setOperation] = useState<string>('remove_background');
const [baseImage, setBaseImage] = useState<string | null>(null);
const [maskImage, setMaskImage] = useState<string | null>(null);
const [backgroundImage, setBackgroundImage] = useState<string | null>(null);
const [lightingImage, setLightingImage] = useState<string | null>(null);
const [prompt, setPrompt] = useState('');
const [negativePrompt, setNegativePrompt] = useState('');
const [searchPrompt, setSearchPrompt] = useState('');
const [selectPrompt, setSelectPrompt] = useState('');
const [expansion, setExpansion] = useState({
left: 0,
right: 0,
up: 0,
down: 0,
});
const [localError, setLocalError] = useState<string | null>(null);
const [showMaskEditor, setShowMaskEditor] = useState(false);
useEffect(() => {
loadEditOperations();
}, [loadEditOperations]);
useEffect(() => {
const keys = Object.keys(editOperations);
if (keys.length && !keys.includes(operation)) {
setOperation(keys[0]);
}
}, [editOperations, operation]);
const operationMeta: EditOperationMeta | undefined = editOperations[operation];
const fields = operationMeta?.fields || {};
const canSubmit = useMemo(() => {
if (!baseImage) return false;
if (fields.prompt && !prompt.trim()) return false;
if (fields.search_prompt && !searchPrompt.trim()) return false;
if (fields.select_prompt && !selectPrompt.trim()) return false;
if (fields.background && !backgroundImage && fields.lighting && !lightingImage) return false;
return true;
}, [baseImage, fields, prompt, searchPrompt, selectPrompt, backgroundImage, lightingImage]);
const editOperation = useMemo(() => ({
provider: 'stability',
operation_type: 'image_editing',
actual_provider_name: 'stability',
}), []);
const handleExpansionChange = (key: keyof typeof expansion, value: number) => {
setExpansion(prev => ({ ...prev, [key]: value }));
};
const buildPayload = (): EditImageRequestPayload | null => {
if (!baseImage) {
setLocalError('Please upload an image to edit.');
return null;
}
if (fields.prompt && !prompt.trim()) {
setLocalError('This operation requires a prompt.');
return null;
}
if (fields.search_prompt && !searchPrompt.trim()) {
setLocalError('Please provide a search prompt.');
return null;
}
if (fields.select_prompt && !selectPrompt.trim()) {
setLocalError('Please provide a selection prompt.');
return null;
}
if (fields.background && !backgroundImage && fields.lighting && !lightingImage) {
setLocalError('Provide at least a background or lighting reference.');
return null;
}
const payload: EditImageRequestPayload = {
image_base64: baseImage,
operation,
prompt: prompt || undefined,
negative_prompt: negativePrompt || undefined,
mask_base64: fields.mask ? maskImage || undefined : undefined,
search_prompt: fields.search_prompt ? searchPrompt || undefined : undefined,
select_prompt: fields.select_prompt ? selectPrompt || undefined : undefined,
background_image_base64: fields.background ? backgroundImage || undefined : undefined,
lighting_image_base64: fields.lighting ? lightingImage || undefined : undefined,
expand_left: fields.expansion ? expansion.left : undefined,
expand_right: fields.expansion ? expansion.right : undefined,
expand_up: fields.expansion ? expansion.up : undefined,
expand_down: fields.expansion ? expansion.down : undefined,
output_format: 'png',
options: {},
};
return payload;
};
const handleApply = async () => {
setLocalError(null);
try {
const payload = buildPayload();
if (!payload) return;
await processEdit(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',
}}
>
Edit Studio
</Typography>
<Typography variant="body1" color="text.secondary">
Advanced AI editing for marketers and content teams. Remove backgrounds, inpaint, recolor,
and relight assets in one flow.
</Typography>
</Stack>
{(localError || editError) && (
<Alert
severity="error"
sx={{ mb: 3 }}
onClose={() => {
setLocalError(null);
}}
>
{localError || editError}
</Alert>
)}
<Grid container spacing={3}>
<Grid item xs={12} md={5}>
<Stack spacing={3}>
<EditImageUploader
baseImage={baseImage}
maskImage={maskImage}
backgroundImage={backgroundImage}
lightingImage={lightingImage}
requiresMask={fields.mask}
requiresBackground={fields.background}
requiresLighting={fields.lighting}
onBaseImageChange={setBaseImage}
onMaskImageChange={setMaskImage}
onBackgroundImageChange={setBackgroundImage}
onLightingImageChange={setLightingImage}
onOpenMaskEditor={() => setShowMaskEditor(true)}
/>
<Divider sx={{ borderColor: 'rgba(255,255,255,0.08)' }} />
<Stack spacing={1.5}>
<Stack direction="row" spacing={1} alignItems="center">
<AutoFixHighIcon sx={{ color: '#a78bfa' }} />
<Typography variant="subtitle1" fontWeight={700}>
Operations
</Typography>
</Stack>
<EditOperationsToolbar
operations={editOperations}
selectedOperation={operation}
onSelect={key => {
setOperation(key);
setLocalError(null);
clearEditResult();
}}
loading={isLoadingEditOps}
/>
</Stack>
</Stack>
</Grid>
<Grid item xs={12} md={7}>
<Stack spacing={3}>
<Paper
variant="outlined"
sx={{
borderRadius: 3,
background: alpha('#0f172a', 0.7),
borderColor: 'rgba(255,255,255,0.05)',
p: 3,
}}
>
<Stack spacing={2}>
{fields.prompt && (
<TextField
multiline
minRows={3}
label="Prompt"
value={prompt}
onChange={e => setPrompt(e.target.value)}
placeholder="Describe what you want to change..."
fullWidth
/>
)}
{fields.negative_prompt && (
<TextField
label="Negative Prompt"
value={negativePrompt}
onChange={e => setNegativePrompt(e.target.value)}
placeholder="Elements to avoid..."
fullWidth
/>
)}
{fields.search_prompt && (
<TextField
label="Search Prompt"
value={searchPrompt}
onChange={e => setSearchPrompt(e.target.value)}
placeholder="What should be replaced?"
fullWidth
/>
)}
{fields.select_prompt && (
<TextField
label="Select Prompt"
value={selectPrompt}
onChange={e => setSelectPrompt(e.target.value)}
placeholder="Describe what should be recolored"
fullWidth
/>
)}
{fields.expansion && (
<Box>
<Stack direction="row" spacing={1} alignItems="center" mb={1}>
<Typography variant="subtitle2" fontWeight={700}>
Canvas Expansion (px)
</Typography>
<Chip size="small" label="Outpaint" />
</Stack>
{(['left', 'right', 'up', 'down'] as const).map(dir => (
<Box key={dir} sx={{ mb: 1.5 }}>
<Typography variant="caption" color="text.secondary">
{dir.toUpperCase()}
</Typography>
<Slider
value={expansion[dir]}
onChange={(_, value) =>
handleExpansionChange(dir, value as number)
}
step={10}
min={0}
max={512}
valueLabelDisplay="auto"
/>
</Box>
))}
</Box>
)}
</Stack>
</Paper>
<OperationButton
operation={editOperation}
label="Apply Edit"
startIcon={<AutoFixHighIcon />}
onClick={handleApply}
disabled={!canSubmit}
loading={isProcessingEdit}
checkOnMount
sx={{
borderRadius: 999,
alignSelf: 'flex-start',
px: 4,
py: 1.5,
textTransform: 'none',
fontWeight: 700,
background: 'linear-gradient(90deg, #7c3aed, #2563eb)',
}}
/>
<EditResultViewer
originalImage={baseImage}
result={editResult}
isProcessing={isProcessingEdit}
onReset={() => {
clearEditResult();
setPrompt('');
setNegativePrompt('');
setSearchPrompt('');
setSelectPrompt('');
setMaskImage(null);
setBackgroundImage(null);
setLightingImage(null);
}}
/>
</Stack>
</Grid>
</Grid>
{/* Mask Editor Dialog */}
<Dialog
open={showMaskEditor}
onClose={() => setShowMaskEditor(false)}
maxWidth="lg"
fullWidth
PaperProps={{
sx: {
background: alpha('#0f172a', 0.95),
backdropFilter: 'blur(20px)',
},
}}
>
<DialogContent sx={{ p: 0 }}>
<Box sx={{ position: 'relative' }}>
<IconButton
onClick={() => setShowMaskEditor(false)}
sx={{
position: 'absolute',
top: 8,
right: 8,
zIndex: 1,
bgcolor: alpha('#000', 0.5),
color: '#fff',
'&:hover': {
bgcolor: alpha('#000', 0.7),
},
}}
>
<CloseIcon />
</IconButton>
<ImageMaskEditor
baseImage={baseImage}
maskImage={maskImage}
onMaskChange={(mask) => {
setMaskImage(mask);
setShowMaskEditor(false);
}}
onClose={() => setShowMaskEditor(false)}
/>
</Box>
</DialogContent>
</Dialog>
</MotionPaper>
</ImageStudioLayout>
);
};

View File

@@ -0,0 +1,426 @@
import React, { useRef, useEffect, useState, useCallback } from 'react';
import {
Box,
Paper,
Stack,
Typography,
IconButton,
Slider,
Button,
Chip,
Tooltip,
} from '@mui/material';
import {
Brush,
DeleteOutline,
Clear,
ZoomIn,
ZoomOut,
Undo,
} from '@mui/icons-material';
import { alpha } from '@mui/material/styles';
interface ImageMaskEditorProps {
baseImage: string | null;
maskImage: string | null;
onMaskChange: (maskBase64: string | null) => void;
onClose?: () => void;
}
type BrushMode = 'paint' | 'erase';
export const ImageMaskEditor: React.FC<ImageMaskEditorProps> = ({
baseImage,
maskImage,
onMaskChange,
onClose,
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const maskCanvasRef = useRef<HTMLCanvasElement>(null);
const imageRef = useRef<HTMLImageElement | null>(null);
const [isDrawing, setIsDrawing] = useState(false);
const [brushMode, setBrushMode] = useState<BrushMode>('paint');
const [brushSize, setBrushSize] = useState(20);
const [zoom, setZoom] = useState(1);
const [history, setHistory] = useState<ImageData[]>([]);
const [historyIndex, setHistoryIndex] = useState(-1);
// Initialize canvases
useEffect(() => {
if (!baseImage || !canvasRef.current || !maskCanvasRef.current) return;
const canvas = canvasRef.current;
const maskCanvas = maskCanvasRef.current;
const ctx = canvas.getContext('2d');
const maskCtx = maskCanvas.getContext('2d');
if (!ctx || !maskCtx) return;
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => {
imageRef.current = img;
// Set canvas sizes to match image
canvas.width = img.width;
canvas.height = img.height;
maskCanvas.width = img.width;
maskCanvas.height = img.height;
// Draw base image on display canvas
ctx.drawImage(img, 0, 0);
// Initialize mask canvas (black = preserve, white = edit)
maskCtx.fillStyle = '#000000';
maskCtx.fillRect(0, 0, maskCanvas.width, maskCanvas.height);
// If existing mask, load it
if (maskImage) {
const maskImg = new Image();
maskImg.onload = () => {
maskCtx.drawImage(maskImg, 0, 0);
// Redraw display
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(img, 0, 0);
const maskData = maskCtx.getImageData(0, 0, maskCanvas.width, maskCanvas.height);
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
for (let i = 0; i < maskData.data.length; i += 4) {
const maskValue = maskData.data[i];
if (maskValue > 128) {
imageData.data[i] = Math.min(255, imageData.data[i] * 0.7 + 255 * 0.3);
imageData.data[i + 1] = imageData.data[i + 1] * 0.7;
imageData.data[i + 2] = imageData.data[i + 2] * 0.7;
}
}
ctx.putImageData(imageData, 0, 0);
// Save initial state to history
const imageDataForHistory = maskCtx.getImageData(0, 0, maskCanvas.width, maskCanvas.height);
setHistory([imageDataForHistory]);
setHistoryIndex(0);
};
maskImg.src = maskImage;
} else {
// Redraw display
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(img, 0, 0);
// Save initial state to history
const imageDataForHistory = maskCtx.getImageData(0, 0, maskCanvas.width, maskCanvas.height);
setHistory([imageDataForHistory]);
setHistoryIndex(0);
}
};
img.src = baseImage;
}, [baseImage, maskImage]);
const redraw = useCallback(() => {
if (!canvasRef.current || !maskCanvasRef.current || !imageRef.current) return;
const ctx = canvasRef.current.getContext('2d');
const maskCtx = maskCanvasRef.current.getContext('2d');
if (!ctx || !maskCtx) return;
// Draw base image
ctx.clearRect(0, 0, canvasRef.current.width, canvasRef.current.height);
ctx.drawImage(imageRef.current, 0, 0);
// Overlay mask as red tint (white areas in mask = red overlay)
const maskData = maskCtx.getImageData(0, 0, maskCanvasRef.current.width, maskCanvasRef.current.height);
const imageData = ctx.getImageData(0, 0, canvasRef.current.width, canvasRef.current.height);
for (let i = 0; i < maskData.data.length; i += 4) {
const maskValue = maskData.data[i]; // Grayscale value
if (maskValue > 128) { // White area = masked (to be edited)
// Apply red overlay
imageData.data[i] = Math.min(255, imageData.data[i] * 0.7 + 255 * 0.3); // Red tint
imageData.data[i + 1] = imageData.data[i + 1] * 0.7; // Reduce green
imageData.data[i + 2] = imageData.data[i + 2] * 0.7; // Reduce blue
}
}
ctx.putImageData(imageData, 0, 0);
}, []);
const saveHistory = useCallback(() => {
if (!maskCanvasRef.current) return;
const ctx = maskCanvasRef.current.getContext('2d');
if (!ctx) return;
const imageData = ctx.getImageData(0, 0, maskCanvasRef.current.width, maskCanvasRef.current.height);
const newHistory = history.slice(0, historyIndex + 1);
newHistory.push(imageData);
if (newHistory.length > 20) newHistory.shift();
setHistory(newHistory);
setHistoryIndex(newHistory.length - 1);
}, [history, historyIndex]);
const undo = useCallback(() => {
if (historyIndex <= 0 || !maskCanvasRef.current) return;
const ctx = maskCanvasRef.current.getContext('2d');
if (!ctx) return;
const prevIndex = historyIndex - 1;
ctx.putImageData(history[prevIndex], 0, 0);
setHistoryIndex(prevIndex);
redraw();
}, [history, historyIndex, redraw]);
const getCoordinates = (e: React.MouseEvent<HTMLCanvasElement> | React.TouchEvent<HTMLCanvasElement>) => {
if (!canvasRef.current) return null;
const canvas = canvasRef.current;
const rect = canvas.getBoundingClientRect();
const scaleX = canvas.width / rect.width;
const scaleY = canvas.height / rect.height;
const clientX = 'touches' in e ? e.touches[0]?.clientX : e.clientX;
const clientY = 'touches' in e ? e.touches[0]?.clientY : e.clientY;
if (clientX === undefined || clientY === undefined) return null;
return {
x: (clientX - rect.left) * scaleX,
y: (clientY - rect.top) * scaleY,
};
};
const draw = useCallback((x: number, y: number) => {
if (!maskCanvasRef.current) return;
const ctx = maskCanvasRef.current.getContext('2d');
if (!ctx) return;
ctx.globalCompositeOperation = brushMode === 'paint' ? 'source-over' : 'destination-out';
ctx.fillStyle = brushMode === 'paint' ? '#ffffff' : '#000000';
ctx.beginPath();
ctx.arc(x, y, brushSize / 2, 0, Math.PI * 2);
ctx.fill();
redraw();
}, [brushMode, brushSize, redraw]);
const handleMouseDown = (e: React.MouseEvent<HTMLCanvasElement>) => {
const coords = getCoordinates(e);
if (!coords) return;
setIsDrawing(true);
draw(coords.x, coords.y);
};
const handleMouseMove = (e: React.MouseEvent<HTMLCanvasElement>) => {
if (!isDrawing) return;
const coords = getCoordinates(e);
if (!coords) return;
draw(coords.x, coords.y);
};
const handleMouseUp = () => {
if (isDrawing) {
setIsDrawing(false);
saveHistory();
exportMask();
}
};
const handleTouchStart = (e: React.TouchEvent<HTMLCanvasElement>) => {
e.preventDefault();
const coords = getCoordinates(e);
if (!coords) return;
setIsDrawing(true);
draw(coords.x, coords.y);
};
const handleTouchMove = (e: React.TouchEvent<HTMLCanvasElement>) => {
e.preventDefault();
if (!isDrawing) return;
const coords = getCoordinates(e);
if (!coords) return;
draw(coords.x, coords.y);
};
const handleTouchEnd = () => {
if (isDrawing) {
setIsDrawing(false);
saveHistory();
exportMask();
}
};
const exportMask = useCallback(() => {
if (!maskCanvasRef.current) return;
const maskBase64 = maskCanvasRef.current.toDataURL('image/png');
onMaskChange(maskBase64);
}, [onMaskChange]);
const clearMask = () => {
if (!maskCanvasRef.current) return;
const ctx = maskCanvasRef.current.getContext('2d');
if (!ctx) return;
ctx.fillStyle = '#000000';
ctx.fillRect(0, 0, maskCanvasRef.current.width, maskCanvasRef.current.height);
redraw();
saveHistory();
onMaskChange(null);
};
if (!baseImage) {
return (
<Paper
sx={{
p: 3,
textAlign: 'center',
background: alpha('#0f172a', 0.7),
border: '1px dashed rgba(255,255,255,0.2)',
}}
>
<Typography color="text.secondary">
Upload an image first to create a mask
</Typography>
</Paper>
);
}
return (
<Paper
elevation={0}
sx={{
background: alpha('#0f172a', 0.8),
border: '1px solid rgba(255,255,255,0.1)',
borderRadius: 3,
overflow: 'hidden',
}}
>
<Stack spacing={2} p={2}>
{/* Header */}
<Stack direction="row" alignItems="center" justifyContent="space-between">
<Typography variant="h6" fontWeight={700}>
Mask Editor
</Typography>
{onClose && (
<IconButton size="small" onClick={onClose}>
<Clear fontSize="small" />
</IconButton>
)}
</Stack>
{/* Toolbar */}
<Stack direction="row" spacing={1} alignItems="center" flexWrap="wrap">
<Tooltip title="Paint (add to mask)">
<IconButton
size="small"
onClick={() => setBrushMode('paint')}
sx={{
bgcolor: brushMode === 'paint' ? alpha('#667eea', 0.2) : 'transparent',
}}
>
<Brush fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Erase (remove from mask)">
<IconButton
size="small"
onClick={() => setBrushMode('erase')}
sx={{
bgcolor: brushMode === 'erase' ? alpha('#667eea', 0.2) : 'transparent',
}}
>
<DeleteOutline fontSize="small" />
</IconButton>
</Tooltip>
<Box sx={{ width: 100, mx: 1 }}>
<Typography variant="caption" color="text.secondary">
Size: {brushSize}px
</Typography>
<Slider
size="small"
value={brushSize}
onChange={(_, value) => setBrushSize(value as number)}
min={5}
max={100}
step={5}
/>
</Box>
<Tooltip title="Undo">
<IconButton size="small" onClick={undo} disabled={historyIndex <= 0}>
<Undo fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Clear mask">
<IconButton size="small" onClick={clearMask}>
<Clear fontSize="small" />
</IconButton>
</Tooltip>
<Chip
size="small"
label={brushMode === 'paint' ? 'Paint Mode' : 'Erase Mode'}
sx={{ ml: 'auto' }}
/>
</Stack>
{/* Canvas Container */}
<Box
sx={{
position: 'relative',
border: '1px solid rgba(255,255,255,0.1)',
borderRadius: 2,
overflow: 'auto',
maxHeight: '60vh',
background: '#000',
}}
>
<Box
sx={{
display: 'inline-block',
transform: `scale(${zoom})`,
transformOrigin: 'top left',
}}
>
<canvas
ref={canvasRef}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
style={{
display: 'block',
cursor: brushMode === 'paint' ? 'crosshair' : 'grab',
maxWidth: '100%',
height: 'auto',
}}
/>
{/* Hidden mask canvas */}
<canvas ref={maskCanvasRef} style={{ display: 'none' }} />
</Box>
</Box>
{/* Zoom Controls */}
<Stack direction="row" spacing={1} alignItems="center">
<IconButton size="small" onClick={() => setZoom(Math.max(0.5, zoom - 0.25))}>
<ZoomOut fontSize="small" />
</IconButton>
<Typography variant="caption" sx={{ minWidth: 60, textAlign: 'center' }}>
{Math.round(zoom * 100)}%
</Typography>
<IconButton size="small" onClick={() => setZoom(Math.min(2, zoom + 0.25))}>
<ZoomIn fontSize="small" />
</IconButton>
<Button
size="small"
variant="outlined"
onClick={() => setZoom(1)}
sx={{ ml: 'auto' }}
>
Reset Zoom
</Button>
</Stack>
{/* Instructions */}
<Typography variant="caption" color="text.secondary" sx={{ fontStyle: 'italic' }}>
💡 Tip: Paint areas you want to edit (shown in red overlay). White areas in the mask
will be modified, black areas will be preserved.
</Typography>
</Stack>
</Paper>
);
};

View File

@@ -0,0 +1,504 @@
import React, { useState } from 'react';
import {
Box,
Card,
CardMedia,
CardActions,
IconButton,
Grid,
Dialog,
DialogContent,
DialogActions,
Button,
Typography,
Chip,
Stack,
Tooltip,
alpha,
Paper,
Divider,
} from '@mui/material';
import {
Download,
Favorite,
FavoriteBorder,
ZoomIn,
Close,
Share,
Edit,
ContentCopy,
CheckCircle,
} from '@mui/icons-material';
import { motion, AnimatePresence, type Variants, type Easing } from 'framer-motion';
const MotionCard = motion(Card);
const MotionBox = motion(Box);
const galleryEase: Easing = [0.4, 0, 0.2, 1];
interface ImageResult {
image_base64: string;
width: number;
height: number;
provider: string;
model: string;
seed?: number;
variation: number;
metadata?: any;
}
interface ImageResultsGalleryProps {
results: ImageResult[];
onImageSelect?: (image: ImageResult) => void;
}
const cardVariants: Variants = {
hidden: { opacity: 0, scale: 0.8 },
visible: {
opacity: 1,
scale: 1,
transition: { duration: 0.4, ease: galleryEase },
},
hover: {
y: -8,
transition: { duration: 0.2, ease: galleryEase },
},
};
export const ImageResultsGallery: React.FC<ImageResultsGalleryProps> = ({
results,
onImageSelect,
}) => {
const [selectedImage, setSelectedImage] = useState<ImageResult | null>(null);
const [favorites, setFavorites] = useState<Set<number>>(new Set());
const [downloadedImages, setDownloadedImages] = useState<Set<number>>(new Set());
// Handle favorite toggle
const toggleFavorite = (index: number) => {
setFavorites((prev) => {
const newFavorites = new Set(prev);
if (newFavorites.has(index)) {
newFavorites.delete(index);
} else {
newFavorites.add(index);
}
return newFavorites;
});
};
// Handle download
const handleDownload = (image: ImageResult, index: number) => {
try {
const link = document.createElement('a');
link.href = `data:image/png;base64,${image.image_base64}`;
link.download = `generated-image-${Date.now()}-v${image.variation}.png`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
// Mark as downloaded
setDownloadedImages((prev) => new Set(prev).add(index));
// Remove downloaded indicator after 2 seconds
setTimeout(() => {
setDownloadedImages((prev) => {
const newSet = new Set(prev);
newSet.delete(index);
return newSet;
});
}, 2000);
} catch (error) {
console.error('Download failed:', error);
}
};
// Handle copy to clipboard
const handleCopy = async (image: ImageResult) => {
try {
// Convert base64 to blob
const response = await fetch(`data:image/png;base64,${image.image_base64}`);
const blob = await response.blob();
// Copy to clipboard
await navigator.clipboard.write([
new ClipboardItem({ 'image/png': blob }),
]);
alert('Image copied to clipboard!');
} catch (error) {
console.error('Copy failed:', error);
alert('Failed to copy image');
}
};
return (
<>
<Grid container spacing={2}>
<AnimatePresence mode="popLayout">
{results.map((result, index) => {
const isFavorite = favorites.has(index);
const isDownloaded = downloadedImages.has(index);
return (
<Grid item xs={12} sm={6} key={`${result.variation}-${index}`}>
<MotionCard
variants={cardVariants}
initial="hidden"
animate="visible"
whileHover="hover"
sx={{
position: 'relative',
borderRadius: 3,
overflow: 'hidden',
border: isFavorite ? '2px solid #f59e0b' : '1px solid #e2e8f0',
boxShadow: isFavorite
? '0 8px 24px rgba(245, 158, 11, 0.2)'
: '0 4px 12px rgba(0,0,0,0.05)',
transition: 'all 0.3s ease',
'&:hover': {
boxShadow: '0 12px 32px rgba(102, 126, 234, 0.2)',
},
}}
>
{/* Favorite Badge */}
{isFavorite && (
<Box
sx={{
position: 'absolute',
top: 12,
right: 12,
zIndex: 2,
background: 'linear-gradient(135deg, #f59e0b, #d97706)',
color: '#fff',
borderRadius: 2,
px: 1,
py: 0.5,
display: 'flex',
alignItems: 'center',
gap: 0.5,
fontSize: 12,
fontWeight: 700,
boxShadow: '0 4px 12px rgba(245, 158, 11, 0.4)',
}}
>
<Favorite sx={{ fontSize: 14 }} />
Favorite
</Box>
)}
{/* Downloaded Indicator */}
{isDownloaded && (
<MotionBox
initial={{ scale: 0 }}
animate={{ scale: 1 }}
exit={{ scale: 0 }}
sx={{
position: 'absolute',
top: 12,
left: 12,
zIndex: 2,
background: '#10b981',
color: '#fff',
borderRadius: 2,
px: 1,
py: 0.5,
display: 'flex',
alignItems: 'center',
gap: 0.5,
fontSize: 12,
fontWeight: 700,
}}
>
<CheckCircle sx={{ fontSize: 14 }} />
Downloaded
</MotionBox>
)}
{/* Image */}
<Box
sx={{
position: 'relative',
paddingTop: `${(result.height / result.width) * 100}%`,
overflow: 'hidden',
background: '#f8fafc',
cursor: 'pointer',
}}
onClick={() => setSelectedImage(result)}
>
<CardMedia
component="img"
image={`data:image/png;base64,${result.image_base64}`}
alt={`Generated variation ${result.variation}`}
sx={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
objectFit: 'cover',
transition: 'transform 0.3s ease',
'&:hover': {
transform: 'scale(1.05)',
},
}}
/>
{/* Hover Overlay */}
<Box
sx={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'linear-gradient(to top, rgba(0,0,0,0.6), transparent)',
opacity: 0,
transition: 'opacity 0.3s ease',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
'&:hover': {
opacity: 1,
},
}}
>
<IconButton
sx={{
background: '#fff',
'&:hover': {
background: '#f8fafc',
},
}}
>
<ZoomIn />
</IconButton>
</Box>
</Box>
{/* Metadata */}
<Box sx={{ p: 2 }}>
<Stack direction="row" spacing={1} mb={1} flexWrap="wrap" useFlexGap>
<Chip
label={`Variation ${result.variation}`}
size="small"
sx={{
height: 22,
fontSize: 11,
fontWeight: 600,
background: 'linear-gradient(90deg, #667eea, #764ba2)',
color: '#fff',
}}
/>
<Chip
label={`${result.width}×${result.height}`}
size="small"
sx={{
height: 22,
fontSize: 11,
fontWeight: 600,
background: '#f1f5f9',
}}
/>
<Chip
label={result.provider}
size="small"
sx={{
height: 22,
fontSize: 11,
fontWeight: 600,
background: alpha('#667eea', 0.1),
color: '#667eea',
}}
/>
</Stack>
</Box>
{/* Actions */}
<CardActions sx={{ px: 2, pb: 2, pt: 0, gap: 1 }}>
<Tooltip title={isFavorite ? 'Remove from favorites' : 'Add to favorites'}>
<IconButton
size="small"
onClick={() => toggleFavorite(index)}
sx={{
color: isFavorite ? '#f59e0b' : 'text.secondary',
'&:hover': {
background: alpha('#f59e0b', 0.1),
color: '#f59e0b',
},
}}
>
{isFavorite ? <Favorite /> : <FavoriteBorder />}
</IconButton>
</Tooltip>
<Tooltip title="Download image">
<IconButton
size="small"
onClick={() => handleDownload(result, index)}
sx={{
color: 'text.secondary',
'&:hover': {
background: alpha('#10b981', 0.1),
color: '#10b981',
},
}}
>
<Download />
</IconButton>
</Tooltip>
<Tooltip title="Copy to clipboard">
<IconButton
size="small"
onClick={() => handleCopy(result)}
sx={{
color: 'text.secondary',
'&:hover': {
background: alpha('#667eea', 0.1),
color: '#667eea',
},
}}
>
<ContentCopy />
</IconButton>
</Tooltip>
<Box sx={{ flex: 1 }} />
<Tooltip title="View full size">
<IconButton
size="small"
onClick={() => setSelectedImage(result)}
sx={{
color: 'text.secondary',
'&:hover': {
background: alpha('#667eea', 0.1),
color: '#667eea',
},
}}
>
<ZoomIn />
</IconButton>
</Tooltip>
</CardActions>
</MotionCard>
</Grid>
);
})}
</AnimatePresence>
</Grid>
{/* Full Size Dialog */}
<Dialog
open={!!selectedImage}
onClose={() => setSelectedImage(null)}
maxWidth="lg"
fullWidth
PaperProps={{
sx: {
borderRadius: 3,
background: '#1e293b',
},
}}
>
<DialogContent sx={{ p: 0, position: 'relative' }}>
{selectedImage && (
<>
<IconButton
onClick={() => setSelectedImage(null)}
sx={{
position: 'absolute',
top: 16,
right: 16,
zIndex: 2,
background: 'rgba(0, 0, 0, 0.6)',
color: '#fff',
'&:hover': {
background: 'rgba(0, 0, 0, 0.8)',
},
}}
>
<Close />
</IconButton>
<img
src={`data:image/png;base64,${selectedImage.image_base64}`}
alt="Full size"
style={{
width: '100%',
height: 'auto',
display: 'block',
}}
/>
{/* Metadata Overlay */}
<Paper
sx={{
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
background: 'rgba(0, 0, 0, 0.8)',
backdropFilter: 'blur(10px)',
p: 2,
}}
>
<Stack direction="row" spacing={2} alignItems="center">
<Stack direction="row" spacing={1}>
<Chip
label={`${selectedImage.width}×${selectedImage.height}`}
size="small"
sx={{
background: 'rgba(255, 255, 255, 0.2)',
color: '#fff',
fontWeight: 600,
}}
/>
<Chip
label={selectedImage.provider}
size="small"
sx={{
background: 'rgba(255, 255, 255, 0.2)',
color: '#fff',
fontWeight: 600,
}}
/>
<Chip
label={selectedImage.model}
size="small"
sx={{
background: 'rgba(255, 255, 255, 0.2)',
color: '#fff',
fontWeight: 600,
}}
/>
</Stack>
<Box sx={{ flex: 1 }} />
<Button
startIcon={<Download />}
onClick={() => {
const index = results.findIndex(r => r === selectedImage);
if (index !== -1) {
handleDownload(selectedImage, index);
}
}}
sx={{
background: 'linear-gradient(90deg, #667eea, #764ba2)',
color: '#fff',
fontWeight: 600,
'&:hover': {
background: 'linear-gradient(90deg, #5568d3, #65408b)',
},
}}
>
Download
</Button>
</Stack>
</Paper>
</>
)}
</DialogContent>
</Dialog>
</>
);
};

View File

@@ -0,0 +1,62 @@
import React from 'react';
import { Paper, Grid, Stack, Typography, Divider } from '@mui/material';
import { useNavigate } from 'react-router-dom';
import { ImageStudioLayout } from './ImageStudioLayout';
import { studioModules } from './dashboard/modules';
import { ModuleCard } from './dashboard/ModuleCard';
export const ImageStudioDashboard: React.FC = () => {
const navigate = useNavigate();
const [hoveredModule, setHoveredModule] = React.useState<string>('');
return (
<ImageStudioLayout>
<Paper
elevation={0}
sx={{
maxWidth: 1400,
mx: 'auto',
borderRadius: 4,
border: '1px solid rgba(255,255,255,0.08)',
background: 'rgba(15,23,42,0.72)',
p: { xs: 3, md: 5 },
backdropFilter: 'blur(25px)',
}}
>
<Stack spacing={1}>
<Typography
variant="h3"
fontWeight={800}
sx={{
background: 'linear-gradient(120deg,#ede9fe,#c7d2fe)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
}}
>
AI Image Studio
</Typography>
<Typography variant="body1" color="text.secondary">
One hub for every visual workflow: generate, edit, upscale, transform, optimize, and manage
assets built for content and marketing teams.
</Typography>
</Stack>
<Divider sx={{ my: 3, borderColor: 'rgba(255,255,255,0.08)' }} />
<Grid container spacing={3}>
{studioModules.map(module => (
<Grid item xs={12} md={6} key={module.key}>
<ModuleCard
module={module}
isHovered={hoveredModule === module.key}
onMouseEnter={() => setHoveredModule(module.key)}
onMouseLeave={() => setHoveredModule('')}
onNavigate={navigate}
/>
</Grid>
))}
</Grid>
</Paper>
</ImageStudioLayout>
);
};

View File

@@ -0,0 +1,76 @@
import React from 'react';
import { Box } from '@mui/material';
import { motion } from 'framer-motion';
import type { Variants } from 'framer-motion';
const MotionBox = motion(Box);
const sparkleVariants: Variants = {
initial: { scale: 0, rotate: 0 },
animate: {
scale: [0, 1, 0],
rotate: [0, 180, 360],
transition: {
duration: 2,
repeat: Infinity,
ease: 'easeInOut',
},
},
};
interface ImageStudioLayoutProps {
children: React.ReactNode;
}
export const ImageStudioLayout: React.FC<ImageStudioLayoutProps> = ({ children }) => (
<Box
sx={{
minHeight: '100vh',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 50%, #f093fb 100%)',
py: 4,
px: 2,
position: 'relative',
overflow: 'hidden',
}}
>
<Box
sx={{
position: 'fixed',
inset: 0,
pointerEvents: 'none',
zIndex: 0,
}}
>
{[...Array(20)].map((_, i) => (
<MotionBox
key={i}
variants={sparkleVariants}
initial="initial"
animate="animate"
transition={{ delay: i * 0.1 }}
sx={{
position: 'absolute',
width: 4,
height: 4,
borderRadius: '50%',
background: 'rgba(255, 255, 255, 0.6)',
left: `${Math.random() * 100}%`,
top: `${Math.random() * 100}%`,
}}
/>
))}
</Box>
<Box
sx={{
maxWidth: 1400,
mx: 'auto',
position: 'relative',
zIndex: 1,
}}
>
{children}
</Box>
</Box>
);

View File

@@ -0,0 +1,454 @@
import React, { useState, useMemo, useCallback } from 'react';
import {
Box,
Card,
CardContent,
Typography,
Chip,
Grid,
TextField,
InputAdornment,
IconButton,
Collapse,
Button,
Stack,
alpha,
Tooltip,
Badge,
} from '@mui/material';
import {
Search,
Instagram,
Facebook,
Twitter,
LinkedIn,
YouTube,
Pinterest,
TrendingUp,
PhotoLibrary,
Star,
Close,
Check,
} from '@mui/icons-material';
import { motion, AnimatePresence, type Variants, type Easing } from 'framer-motion';
const MotionCard = motion(Card);
const templateCardEase: Easing = [0.4, 0, 1, 1];
interface Template {
id: string;
name: string;
category: string;
platform?: string;
aspect_ratio: {
ratio: string;
width: number;
height: number;
label: string;
};
description: string;
recommended_provider: string;
style_preset: string;
quality: string;
use_cases: string[];
}
interface TemplateSelectorProps {
templates: Template[];
selectedTemplateId: string | null;
onSelectTemplate: (template: Template) => void;
isLoading?: boolean;
}
// Platform icons mapping
const platformIcons: Record<string, React.ReactElement> = {
instagram: <Instagram />,
facebook: <Facebook />,
twitter: <Twitter />,
linkedin: <LinkedIn />,
youtube: <YouTube />,
pinterest: <Pinterest />,
};
// Platform colors
const platformColors: Record<string, string> = {
instagram: '#E4405F',
facebook: '#1877F2',
twitter: '#1DA1F2',
linkedin: '#0A66C2',
youtube: '#FF0000',
pinterest: '#E60023',
blog: '#10b981',
email: '#8b5cf6',
website: '#f59e0b',
};
const cardVariants: Variants = {
hidden: { opacity: 0, scale: 0.9 },
visible: {
opacity: 1,
scale: 1,
transition: { duration: 0.3, ease: templateCardEase },
},
hover: {
y: -4,
transition: { duration: 0.2, ease: templateCardEase },
},
};
export const TemplateSelector: React.FC<TemplateSelectorProps> = ({
templates,
selectedTemplateId,
onSelectTemplate,
isLoading,
}) => {
const [searchQuery, setSearchQuery] = useState('');
const [selectedPlatform, setSelectedPlatform] = useState<string | null>(null);
const [showAll, setShowAll] = useState(false);
const resolvePlatformColor = useCallback((platform?: string | null) => {
if (!platform) return '#667eea';
return platformColors[platform as keyof typeof platformColors] || '#667eea';
}, []);
// Get unique platforms
const platforms = useMemo(() => {
const uniquePlatforms = new Set(templates.map(t => t.platform).filter(Boolean));
return Array.from(uniquePlatforms);
}, [templates]);
// Filter templates
const filteredTemplates = useMemo(() => {
let filtered = templates;
// Filter by platform
if (selectedPlatform) {
filtered = filtered.filter(t => t.platform === selectedPlatform);
}
// Filter by search
if (searchQuery) {
const query = searchQuery.toLowerCase();
filtered = filtered.filter(t =>
t.name.toLowerCase().includes(query) ||
t.description.toLowerCase().includes(query) ||
t.use_cases.some(uc => uc.toLowerCase().includes(query))
);
}
return filtered;
}, [templates, selectedPlatform, searchQuery]);
// Display templates (show 6 or all)
const displayTemplates = showAll ? filteredTemplates : filteredTemplates.slice(0, 6);
return (
<Box>
<Typography
variant="subtitle2"
sx={{
fontWeight: 600,
mb: 2,
display: 'flex',
alignItems: 'center',
gap: 0.5,
}}
>
<PhotoLibrary sx={{ fontSize: 18, color: '#667eea' }} />
Platform Templates
{selectedTemplateId && (
<Chip
size="small"
label="Selected"
icon={<Check sx={{ fontSize: 14 }} />}
sx={{
ml: 1,
height: 22,
background: '#10b981',
color: '#fff',
fontWeight: 600,
fontSize: 11,
}}
/>
)}
</Typography>
{/* Search */}
<TextField
fullWidth
size="small"
placeholder="Search templates..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<Search sx={{ color: 'text.secondary' }} />
</InputAdornment>
),
endAdornment: searchQuery && (
<InputAdornment position="end">
<IconButton size="small" onClick={() => setSearchQuery('')}>
<Close sx={{ fontSize: 18 }} />
</IconButton>
</InputAdornment>
),
}}
sx={{
mb: 2,
'& .MuiOutlinedInput-root': {
borderRadius: 2,
background: '#fff',
'&:hover fieldset': {
borderColor: '#667eea',
},
'&.Mui-focused fieldset': {
borderColor: '#667eea',
},
},
}}
/>
{/* Platform Filter */}
<Box sx={{ mb: 2 }}>
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
<Chip
label="All"
onClick={() => setSelectedPlatform(null)}
sx={{
background: !selectedPlatform
? 'linear-gradient(90deg, #667eea, #764ba2)'
: 'transparent',
color: !selectedPlatform ? '#fff' : 'text.secondary',
fontWeight: 600,
border: !selectedPlatform ? 'none' : '1px solid #e2e8f0',
'&:hover': {
background: !selectedPlatform
? 'linear-gradient(90deg, #5568d3, #65408b)'
: alpha('#667eea', 0.1),
},
}}
/>
{platforms.map((platform) => {
const label = platform ? platform.charAt(0).toUpperCase() + platform.slice(1) : 'Unknown';
const icon = platform ? platformIcons[platform] : undefined;
const color = resolvePlatformColor(platform);
const isSelected = selectedPlatform === platform;
return (
<Chip
key={platform || 'unknown'}
icon={icon}
label={label}
onClick={() => setSelectedPlatform(platform || null)}
sx={{
background: isSelected ? color : 'transparent',
color: isSelected ? '#fff' : 'text.secondary',
fontWeight: 600,
border: isSelected ? 'none' : '1px solid #e2e8f0',
'&:hover': {
background: isSelected ? color : alpha(color, 0.1),
},
}}
/>
);
})}
</Stack>
</Box>
{/* Templates Grid */}
<Grid container spacing={1.5}>
<AnimatePresence mode="wait">
{displayTemplates.map((template) => {
const isSelected = selectedTemplateId === template.id;
const platformColor = resolvePlatformColor(template.platform || 'blog');
return (
<Grid item xs={12} sm={6} key={template.id}>
<MotionCard
variants={cardVariants}
initial="hidden"
animate="visible"
whileHover="hover"
onClick={() => onSelectTemplate(template)}
sx={{
cursor: 'pointer',
borderRadius: 2,
border: isSelected ? `2px solid ${platformColor}` : '1px solid #e2e8f0',
background: isSelected
? `linear-gradient(135deg, ${alpha(platformColor, 0.05)}, ${alpha(platformColor, 0.02)})`
: '#fff',
boxShadow: isSelected
? `0 4px 12px ${alpha(platformColor, 0.2)}`
: '0 1px 3px rgba(0,0,0,0.05)',
transition: 'all 0.2s ease',
'&:hover': {
boxShadow: `0 8px 24px ${alpha(platformColor, 0.3)}`,
border: `2px solid ${platformColor}`,
},
}}
>
<CardContent sx={{ p: 2, '&:last-child': { pb: 2 } }}>
<Stack spacing={1}>
{/* Header */}
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1 }}>
{/* Platform Icon */}
<Box
sx={{
width: 32,
height: 32,
borderRadius: 1,
background: `linear-gradient(135deg, ${platformColor}, ${alpha(platformColor, 0.7)})`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#fff',
flexShrink: 0,
}}
>
{platformIcons[template.platform || 'blog'] || <PhotoLibrary sx={{ fontSize: 18 }} />}
</Box>
{/* Template Info */}
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography
variant="subtitle2"
sx={{
fontWeight: 700,
fontSize: 13,
mb: 0.5,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{template.name}
</Typography>
<Stack direction="row" spacing={0.5} alignItems="center">
<Chip
label={template.aspect_ratio.ratio}
size="small"
sx={{
height: 18,
fontSize: 10,
fontWeight: 600,
background: alpha(platformColor, 0.1),
color: platformColor,
}}
/>
<Chip
label={`${template.aspect_ratio.width}×${template.aspect_ratio.height}`}
size="small"
sx={{
height: 18,
fontSize: 10,
fontWeight: 600,
background: '#f1f5f9',
color: 'text.secondary',
}}
/>
</Stack>
</Box>
{/* Selection Indicator */}
{isSelected && (
<Box
sx={{
width: 24,
height: 24,
borderRadius: '50%',
background: platformColor,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<Check sx={{ fontSize: 16, color: '#fff' }} />
</Box>
)}
</Box>
{/* Description */}
<Typography
variant="caption"
sx={{
color: 'text.secondary',
fontSize: 11,
lineHeight: 1.4,
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
}}
>
{template.description}
</Typography>
{/* Quality Badge */}
{template.quality === 'premium' && (
<Chip
icon={<Star sx={{ fontSize: 12 }} />}
label="Premium"
size="small"
sx={{
height: 20,
fontSize: 10,
fontWeight: 600,
background: 'linear-gradient(90deg, #f59e0b, #d97706)',
color: '#fff',
width: 'fit-content',
}}
/>
)}
</Stack>
</CardContent>
</MotionCard>
</Grid>
);
})}
</AnimatePresence>
</Grid>
{/* Show More/Less Button */}
{filteredTemplates.length > 6 && (
<Button
fullWidth
variant="outlined"
onClick={() => setShowAll(!showAll)}
sx={{
mt: 2,
borderRadius: 2,
borderColor: 'divider',
color: 'text.secondary',
'&:hover': {
borderColor: '#667eea',
background: alpha('#667eea', 0.05),
},
}}
>
{showAll ? 'Show Less' : `Show All (${filteredTemplates.length})`}
</Button>
)}
{/* No Results */}
{filteredTemplates.length === 0 && (
<Box sx={{ textAlign: 'center', py: 4 }}>
<Typography variant="body2" color="text.secondary">
No templates found matching your criteria
</Typography>
<Button
size="small"
onClick={() => {
setSearchQuery('');
setSelectedPlatform(null);
}}
sx={{ mt: 1 }}
>
Clear Filters
</Button>
</Box>
)}
</Box>
);
};

View File

@@ -0,0 +1,329 @@
import React, { useState, useMemo } from 'react';
import {
Box,
Grid,
Stack,
Typography,
Button,
ToggleButtonGroup,
ToggleButton,
TextField,
MenuItem,
Alert,
Slider,
} from '@mui/material';
import CloudUploadIcon from '@mui/icons-material/CloudUpload';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import ZoomInIcon from '@mui/icons-material/ZoomIn';
import UpgradeIcon from '@mui/icons-material/Upgrade';
import { ImageStudioLayout } from './ImageStudioLayout';
import { GlassyCard, SectionHeader, AsyncStatusBanner } from './ui';
import { OperationButton } from '../shared/OperationButton';
import { useImageStudio } from '../../hooks/useImageStudio';
const modeOptions = [
{ value: 'fast', label: 'Fast (4x)', description: 'Quick upscale with minimal changes' },
{ value: 'conservative', label: 'Conservative 4K', description: 'Preserve details for print' },
{ value: 'creative', label: 'Creative 4K', description: 'Add artistic enhancements' },
{ value: 'auto', label: 'Auto', description: 'Let AI choose best mode' },
];
const presetOptions = [
{ value: 'web', label: 'Web (2048px)' },
{ value: 'print', label: 'Print (3072px)' },
{ value: 'social', label: 'Social (1080px)' },
];
export const UpscaleStudio: React.FC = () => {
const {
processUpscale,
clearUpscaleResult,
isUpscaling,
upscaleResult,
upscaleError,
} = useImageStudio();
const [mode, setMode] = useState<'fast' | 'conservative' | 'creative' | 'auto'>('auto');
const [preset, setPreset] = useState<string>('web');
const [prompt, setPrompt] = useState('');
const [imageBase64, setImageBase64] = useState<string | null>(null);
const [zoom, setZoom] = useState<number>(1);
const [localError, setLocalError] = useState<string | null>(null);
const handleFile = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => setImageBase64(reader.result as string);
reader.onerror = () => setLocalError('Failed to read image');
reader.readAsDataURL(file);
};
const handleUpscale = async () => {
setLocalError(null);
if (!imageBase64) {
setLocalError('Upload an image to upscale.');
return;
}
clearUpscaleResult();
try {
const payload = {
image_base64: imageBase64,
mode,
preset,
prompt: prompt || undefined,
};
await processUpscale(payload);
} catch {
// handled via hook state
}
};
const canUpscale = Boolean(imageBase64) && !isUpscaling;
const upscaleOperation = useMemo(() => ({
provider: 'stability',
operation_type: 'image_upscale',
actual_provider_name: 'stability',
model: mode,
}), [mode]);
return (
<ImageStudioLayout>
<Stack spacing={3}>
<SectionHeader
title="Upscale Studio"
subtitle="Enhance resolution with fast 4x or 4K upscales powered by Stability AI."
status="beta"
/>
{(localError || upscaleError) && (
<Alert severity="error" onClose={() => setLocalError(null)}>
{localError || upscaleError}
</Alert>
)}
<AsyncStatusBanner
state={
isUpscaling ? 'running' : upscaleResult ? 'success' : localError || upscaleError ? 'error' : 'idle'
}
message={
isUpscaling
? 'Upscaling your image...'
: upscaleResult
? 'Upscale complete!'
: localError || upscaleError || undefined
}
/>
<Grid container spacing={3}>
<Grid item xs={12} md={5}>
<GlassyCard sx={{ p: 3 }}>
<Stack spacing={3}>
<Stack spacing={1}>
<Typography variant="subtitle2">Upload Image</Typography>
<Button
variant="outlined"
component="label"
startIcon={<CloudUploadIcon />}
sx={{ borderRadius: 2 }}
>
Select Image
<input hidden type="file" accept="image/*" onChange={handleFile} />
</Button>
{imageBase64 && (
<Box
sx={{
borderRadius: 2,
overflow: 'hidden',
border: '1px solid rgba(255,255,255,0.1)',
}}
>
<img src={imageBase64} alt="Original" style={{ width: '100%' }} />
</Box>
)}
</Stack>
<Stack spacing={1}>
<Typography variant="subtitle2">Mode</Typography>
<ToggleButtonGroup
exclusive
value={mode}
onChange={(_, value) => {
if (value) setMode(value);
}}
fullWidth
color="primary"
>
{modeOptions.map(option => (
<ToggleButton key={option.value} value={option.value}>
{option.label}
</ToggleButton>
))}
</ToggleButtonGroup>
</Stack>
<TextField
select
label="Preset"
value={preset}
onChange={e => setPreset(e.target.value)}
fullWidth
>
{presetOptions.map(option => (
<MenuItem key={option.value} value={option.value}>
{option.label}
</MenuItem>
))}
</TextField>
{(mode === 'conservative' || mode === 'creative') && (
<TextField
label="Prompt (optional)"
fullWidth
multiline
minRows={2}
value={prompt}
onChange={e => setPrompt(e.target.value)}
placeholder="Describe how you want the upscale to enhance the image"
/>
)}
<OperationButton
operation={upscaleOperation}
label="Upscale Image"
startIcon={<UpgradeIcon />}
onClick={handleUpscale}
disabled={!canUpscale}
loading={isUpscaling}
checkOnMount
sx={{
borderRadius: 999,
textTransform: 'none',
fontWeight: 700,
}}
/>
</Stack>
</GlassyCard>
</Grid>
<Grid item xs={12} md={7}>
<GlassyCard sx={{ p: 3 }}>
<Stack spacing={2}>
<Stack direction="row" spacing={1} alignItems="center">
<ZoomInIcon sx={{ color: '#c4b5fd' }} />
<Typography variant="h6" fontWeight={700}>
Result Preview
</Typography>
</Stack>
{!upscaleResult && (
<Typography variant="body2" color="text.secondary">
Upload an image and click Upscale Image to see the results here.
</Typography>
)}
{upscaleResult && (
<Stack spacing={2}>
<Grid container spacing={2}>
<Grid item xs={12} md={6}>
<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)',
height: 320,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<img
src={imageBase64 || ''}
alt="Original"
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
/>
</Box>
</Grid>
<Grid item xs={12} md={6}>
<Typography variant="caption" color="text.secondary">
Upscaled
</Typography>
<Box
sx={{
mt: 1,
borderRadius: 2,
overflow: 'hidden',
border: '1px solid rgba(255,255,255,0.1)',
height: 320,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
position: 'relative',
}}
>
<Box
sx={{
transform: `scale(${zoom})`,
transformOrigin: 'center',
transition: 'transform 0.2s ease',
}}
>
<img
src={upscaleResult.image_base64}
alt="Upscaled"
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
/>
</Box>
</Box>
</Grid>
</Grid>
<Stack spacing={1}>
<Typography variant="caption" color="text.secondary">
Zoom ({Math.round(zoom * 100)}%)
</Typography>
<Slider
value={zoom}
min={1}
max={3}
step={0.1}
onChange={(_, value) => setZoom(value as number)}
/>
</Stack>
<Stack direction="row" spacing={1} alignItems="center">
<CheckCircleIcon sx={{ color: '#10b981' }} />
<Typography variant="body2" color="text.secondary">
{upscaleResult.width}×{upscaleResult.height} · Mode: {upscaleResult.mode}
</Typography>
</Stack>
<Button
variant="outlined"
onClick={() => {
const link = document.createElement('a');
link.href = upscaleResult.image_base64;
link.download = `upscaled-${Date.now()}.png`;
link.click();
}}
sx={{ alignSelf: 'flex-start' }}
>
Download
</Button>
</Stack>
)}
</Stack>
</GlassyCard>
</Grid>
</Grid>
</Stack>
</ImageStudioLayout>
);
};

View File

@@ -0,0 +1,263 @@
import React from 'react';
import {
Box,
Paper,
Stack,
Typography,
Chip,
Button,
Tooltip,
Divider,
} from '@mui/material';
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
import LaunchIcon from '@mui/icons-material/Launch';
import LockIcon from '@mui/icons-material/Lock';
import { alpha } from '@mui/material/styles';
import { ModuleConfig } from './types';
import { statusStyles } from './constants';
import { ModuleInfoCard } from './ModuleInfoCard';
import {
CreateEffectPreview,
EditEffectPreview,
UpscaleEffectPreview,
TransformEffectPreview,
SocialOptimizerEffectPreview,
ControlEffectPreview,
} from './previews';
interface ModuleCardProps {
module: ModuleConfig;
isHovered: boolean;
onMouseEnter: () => void;
onMouseLeave: () => void;
onNavigate: (route: string) => void;
}
export const ModuleCard: React.FC<ModuleCardProps> = ({
module,
isHovered,
onMouseEnter,
onMouseLeave,
onNavigate,
}) => {
const status = statusStyles[module.status];
const disabled = module.status !== 'live';
const hasPreview =
module.key === 'create' ||
module.key === 'edit' ||
module.key === 'upscale' ||
module.key === 'transform' ||
module.key === 'optimizer' ||
module.key === 'control';
const renderPreview = () => {
switch (module.key) {
case 'create':
return <CreateEffectPreview />;
case 'edit':
return <EditEffectPreview />;
case 'upscale':
return <UpscaleEffectPreview />;
case 'transform':
return <TransformEffectPreview />;
case 'optimizer':
return <SocialOptimizerEffectPreview />;
case 'control':
return <ControlEffectPreview />;
default:
return null;
}
};
return (
<Paper
sx={{
height: '100%',
borderRadius: 4,
p: 3,
border: '1px solid rgba(255,255,255,0.06)',
background: alpha('#111827', 0.8),
display: 'flex',
flexDirection: 'column',
gap: 1.5,
position: 'relative',
transition: 'transform 0.3s ease, box-shadow 0.3s ease',
boxShadow: isHovered
? '0 20px 45px rgba(124,58,237,0.25)'
: '0 10px 25px rgba(15,23,42,0.35)',
transform: isHovered ? 'translateY(-4px)' : 'translateY(0)',
overflow: 'hidden',
'&::after': {
content: '""',
position: 'absolute',
inset: 0,
background:
module.key === 'create'
? 'radial-gradient(circle at top, rgba(124,58,237,0.25), transparent 60%)'
: module.key === 'edit'
? 'linear-gradient(120deg, rgba(8,145,178,0.25), transparent)'
: module.key === 'upscale'
? 'linear-gradient(90deg, rgba(248,113,113,0.25), transparent)'
: 'linear-gradient(120deg, rgba(59,130,246,0.15), transparent)',
opacity: isHovered ? 1 : 0.35,
pointerEvents: 'none',
},
}}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
<Stack direction="row" justifyContent="space-between" alignItems="flex-start">
<Stack direction="row" spacing={1} alignItems="center">
<Box
sx={{
width: 44,
height: 44,
borderRadius: 2,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: alpha('#6366f1', 0.2),
color: '#c7d2fe',
fontSize: 22,
}}
>
{module.icon}
</Box>
<Stack spacing={0.5}>
<Typography variant="h6" fontWeight={700}>
{module.title}
</Typography>
<Typography variant="body2" color="text.secondary">
{module.subtitle}
</Typography>
</Stack>
</Stack>
<Chip
label={status.label}
size="small"
sx={{
backgroundColor: alpha(status.color, 0.2),
color: status.color,
fontWeight: 700,
}}
/>
</Stack>
<Stack direction="row" spacing={1} alignItems="center">
<Typography
variant="body2"
sx={{
color: hasPreview
? 'rgba(248,250,252,0.92)'
: 'rgba(148,163,184,0.95)',
}}
>
{module.description}
</Typography>
</Stack>
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
{module.highlights.map(item => (
<Chip
key={item}
size="small"
label={item}
sx={{
background: 'linear-gradient(120deg, rgba(99,102,241,0.35), rgba(14,165,233,0.35))',
color: '#f1f5f9',
border: '1px solid rgba(255,255,255,0.3)',
fontWeight: 600,
letterSpacing: 0.2,
}}
/>
))}
</Stack>
{hasPreview && (
<>
{renderPreview()}
<ModuleInfoCard module={module} />
</>
)}
{!hasPreview && (
<Box
sx={{
position: 'absolute',
inset: 16,
borderRadius: 3,
border: '1px solid rgba(255,255,255,0.1)',
background: 'rgba(15,23,42,0.92)',
backdropFilter: 'blur(12px)',
display: 'flex',
flexDirection: 'column',
gap: 1,
padding: 2,
opacity: isHovered ? 1 : 0,
pointerEvents: 'none',
transition: 'opacity 0.2s ease',
}}
>
<Typography variant="overline" sx={{ color: '#a5b4fc', letterSpacing: 1 }}>
Pricing & How it works
</Typography>
<Stack spacing={0.5}>
<Typography variant="body2" fontWeight={700}>
{module.pricing.estimate}
</Typography>
<Typography variant="caption" color="text.secondary">
{module.pricing.notes}
</Typography>
</Stack>
<Divider sx={{ borderColor: 'rgba(255,255,255,0.08)' }} />
<Typography variant="subtitle2" fontWeight={700}>
{module.example.title}
</Typography>
<Stack component="ul" spacing={0.5} sx={{ pl: 2, m: 0 }}>
{module.example.steps.map(step => (
<Typography
component="li"
key={step}
variant="body2"
color="text.secondary"
>
{step}
</Typography>
))}
</Stack>
<Typography variant="caption" color="text.secondary">
ETA: {module.example.eta}
</Typography>
</Box>
)}
<Stack direction="row" spacing={1} alignItems="center" mt="auto">
<Tooltip title={module.help}>
<InfoOutlinedIcon sx={{ color: 'rgba(255,255,255,0.6)', fontSize: 20 }} />
</Tooltip>
<Button
variant="contained"
disabled={disabled}
startIcon={disabled ? <LockIcon /> : <LaunchIcon />}
onClick={() => {
if (!disabled && module.route) {
onNavigate(module.route);
}
}}
sx={{
borderRadius: 999,
textTransform: 'none',
fontWeight: 700,
ml: 'auto',
background: disabled
? 'rgba(148,163,184,0.2)'
: 'linear-gradient(90deg,#7c3aed,#2563eb)',
}}
>
{disabled ? 'Coming Soon' : 'Open'}
</Button>
</Stack>
</Paper>
);
};

View File

@@ -0,0 +1,43 @@
import React from 'react';
import { Paper, Stack, Typography, Divider } from '@mui/material';
import { ModuleConfig } from './types';
export const ModuleInfoCard: React.FC<{ module: ModuleConfig }> = ({ module }) => (
<Paper
variant="outlined"
sx={{
mt: 1.5,
borderRadius: 3,
borderColor: 'rgba(255,255,255,0.12)',
backgroundColor: 'rgba(15,23,42,0.65)',
p: 2,
}}
>
<Stack spacing={1}>
<Typography variant="caption" sx={{ color: '#a5b4fc', letterSpacing: 1 }}>
Pricing & Workflow
</Typography>
<Typography variant="body2" fontWeight={600}>
{module.pricing.estimate}
</Typography>
<Typography variant="caption" color="text.secondary">
{module.pricing.notes}
</Typography>
<Divider sx={{ borderColor: 'rgba(255,255,255,0.08)' }} />
<Typography variant="subtitle2" fontWeight={700}>
{module.example.title}
</Typography>
<Stack component="ul" spacing={0.5} sx={{ pl: 2, m: 0 }}>
{module.example.steps.map(step => (
<Typography key={step} component="li" variant="body2" color="text.secondary">
{step}
</Typography>
))}
</Stack>
<Typography variant="caption" color="text.secondary">
ETA: {module.example.eta}
</Typography>
</Stack>
</Paper>
);

View File

@@ -0,0 +1,93 @@
export const createExamples = [
{
id: 'ig-hero',
label: 'Instagram hero',
prompt:
'"Cinematic coffee shop hero shot, golden hour lighting, stylish barista pouring latte art, 4k, depth of field, film grain"',
provider: 'WaveSpeed Ideogram V3 Turbo',
image:
'https://images.unsplash.com/photo-1509042239860-f550ce710b93?auto=format&fit=crop&w=1200&q=80',
description:
'Polished hero visual for carousel slides and blog headers with photorealistic signage.',
price: '$0.18',
eta: '~4s',
},
{
id: 'linkedin-thought',
label: 'LinkedIn thought-leadership',
prompt:
'"Minimalist workspace flat lay, teal gradients, AI workflow diagrams, overhead view, ultra clean, 8k render"',
provider: 'Gemini Imagen',
image:
'https://images.unsplash.com/photo-1487017159836-4e23ece2e4cf?auto=format&fit=crop&w=1200&q=80',
description: 'Clean layout for LinkedIn posts that need professional, text-friendly framing.',
price: '$0.11',
eta: '~3s',
},
{
id: 'tiktok-hook',
label: 'TikTok hook frame',
prompt:
'"Vibrant neon studio, bold typography reading Growth Hacks, 9:16 layout, dynamic lighting, energetic vibe"',
provider: 'WaveSpeed Qwen Image',
image:
'https://images.unsplash.com/photo-1504196606672-aef5c9cefc92?auto=format&fit=crop&w=1200&q=80',
description: 'High-energy vertical frame to start TikTok/Reels with bold colors and legible copy.',
price: '$0.07',
eta: '~2s',
},
];
export const upscaleSamples = {
lowRes: 'https://images.unsplash.com/photo-1498050108023-c5249f4df085?auto=format&fit=crop&w=600&q=30',
hiRes: 'https://images.unsplash.com/photo-1498050108023-c5249f4df085?auto=format&fit=crop&w=1600&q=80',
};
export const transformAssets = {
storyboard: '/images/scene_1_Welcome_to_the_Cloud_Kitchen___ae6436d9.png',
video: '/videos/scene_1_user_33Gz1FPI86V_0a5d0d71.mp4',
script:
"Welcome to the Cloud Kitchen! Meet Ava, your virtual chef companion. Let's explore how she runs three delivery brands from one AI-powered hub.",
};
export const controlAssets = {
inputImage: '/images/scene_1_Welcome_to_the_Cloud_Kitchen___ae6436d9.png',
outputVideo: '/videos/text-video-voiceover.mp4',
prompt:
"A confident woman in her 40s stands on a stage with a microphone. The background shows a large LED screen with abstract visuals. She smiles and begins speaking to the audience: \"Good evening everyone. Tonight, I want to share three powerful lessons about leadership and innovation.\" Her lip movements match her voice, and she uses expressive hand gestures while speaking.",
seed: 2133312826,
resolution: '720p',
duration: 5,
};
export const editBeforeAfter = [
{
before: 'https://images.unsplash.com/photo-1455587734955-081b22074882?auto=format&fit=crop&w=800&q=80',
after: 'https://images.unsplash.com/photo-1487412720507-e7ab37603c6f?auto=format&fit=crop&w=800&q=80',
prompt: 'Inpainted background swap with studio lighting and relit subject',
},
{
before: 'https://images.unsplash.com/photo-1472506200026-38c43d5fbf97?auto=format&fit=crop&w=800&q=80',
after: 'https://images.unsplash.com/photo-1469474968028-56623f02e42e?auto=format&fit=crop&w=800&q=80',
prompt: 'Recolored wardrobe + added morning haze',
},
{
before: 'https://images.unsplash.com/photo-1434389677669-e08b4cac3105?auto=format&fit=crop&w=800&q=80',
after: 'https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?auto=format&fit=crop&w=800&q=80',
prompt: 'Reframed hero crop with dramatic sky replacement',
},
];
export const platformPresets = [
{ label: 'IG Feed 1:1', top: '10%', left: '5%', width: '35%', height: '35%' },
{ label: 'TikTok 9:16', top: '5%', right: '5%', width: '25%', height: '60%' },
{ label: 'LinkedIn 1.91:1', bottom: '8%', left: '10%', width: '55%', height: '25%' },
{ label: 'Pinterest 2:3', bottom: '12%', right: '8%', width: '22%', height: '30%' },
];
export const statusStyles = {
live: { label: 'Live', color: '#10b981' },
'coming soon': { label: 'Coming Soon', color: '#f97316' },
planning: { label: 'In Planning', color: '#d1d5db' },
};

View File

@@ -0,0 +1,7 @@
export * from './types';
export * from './constants';
export { ModuleInfoCard } from './ModuleInfoCard';
export { ModuleCard } from './ModuleCard';
export { studioModules } from './modules';
export * from './previews';

View File

@@ -0,0 +1,208 @@
import React from 'react';
import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome';
import BrushIcon from '@mui/icons-material/Brush';
import UpgradeIcon from '@mui/icons-material/Upgrade';
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 { ModuleConfig } from './types';
export const studioModules: ModuleConfig[] = [
{
key: 'create',
title: 'Create Studio',
subtitle: 'Text-to-image generation',
description:
'Generate photorealistic visuals with Stability, WaveSpeed, HuggingFace, and Gemini. Templates, smart providers, and enterprise prompt controls included.',
highlights: ['Smart provider routing', 'Platform templates', 'Cost preview'],
status: 'live',
route: '/image-generator',
icon: <AutoAwesomeIcon />,
help: 'Ideal for blog headers, social posts, ad creatives, and brand assets.',
pricing: {
estimate: '$0.12 - $0.48 / image (credit aware)',
notes: 'Auto-select suggests lowest-cost provider before generation.',
},
example: {
title: 'Instagram carousel hero image',
steps: [
'Choose Instagram template + 4:5 ratio',
'Prompt helper enriches "fall coffee launch" copy',
'Preview cost/time → generate 3 variations',
],
eta: '~4s per variation',
},
},
{
key: 'edit',
title: 'Edit Studio',
subtitle: 'AI-powered editing',
description:
'Remove backgrounds, inpaint, outpaint, recolor, and relight images with Stability AI workflows and Hugging Face conversational edits.',
highlights: ['Object removal', 'Canvas expansion', 'Relight + background swap'],
status: 'live',
route: '/image-editor',
icon: <BrushIcon />,
help: 'Upload existing assets and enhance them with precise AI tools.',
pricing: {
estimate: '$0.08 - $0.30 / edit (based on area + ops)',
notes: 'Bulk edits share the same upload to save credits.',
},
example: {
title: 'Replace dull background for LinkedIn hero',
steps: [
'Upload portrait → auto mask detects subject',
'Use "Replace background" preset → choose corporate loft style',
'Relight + save layered history for future tweaks',
],
eta: '~6s render',
},
},
{
key: 'upscale',
title: 'Upscale Studio',
subtitle: 'Resolution enhancement',
description:
'Fast 4x upscale, conservative 4K, and creative 4K pipelines powered by Stability AI. Perfect for print, campaigns, and hero imagery.',
highlights: ['Fast 4x mode', '4K creative', 'Side-by-side preview'],
status: 'live',
route: '/image-upscale',
icon: <UpgradeIcon />,
help: 'Upscale images to 4K-ready assets with one click.',
pricing: {
estimate: '$0.10 (Fast) · $0.32 (Creative 4K)',
notes: 'Queue batches overnight to reduce credit burn.',
},
example: {
title: 'Print-ready hero panel',
steps: [
'Upload 1024 hero → auto-detect recommends Creative 4K',
'Preview side-by-side → confirm texture preservation',
'Schedule overnight batch with 6 variants',
],
eta: 'Fast = 1s · 4K = 6s',
},
},
{
key: 'transform',
title: 'Transform Studio',
subtitle: 'Image → Video / Avatar / 3D',
description:
'WaveSpeed WAN 2.5 (image-to-video), Hunyuan Avatar, and Stable Fast 3D to convert images into motion, avatars, or 3D assets.',
highlights: ['Image-to-video', 'Talking avatars', '3D export'],
status: 'coming soon',
icon: <TransformIcon />,
help: 'Designed for campaign teasers, explainers, and immersive media.',
pricing: {
estimate: '$0.50 (10s video 480p) · $3.60 (avatar 2 min)',
notes: 'Text-to-speech add-on billed separately per 15s.',
},
example: {
title: 'Product launch teaser video',
steps: [
'Pick motion preset "Medium pan + glow"',
'Upload hero shot + 8s script for TTS',
'Preview storyboard → export 1080p MP4',
],
eta: '~15s generation',
},
},
{
key: 'optimizer',
title: 'Social Optimizer',
subtitle: 'Platform-ready exports',
description:
'Smart resize, safe zones, and engagement tips for Instagram, TikTok, LinkedIn, YouTube, Pinterest, and more in one click.',
highlights: ['Text safe zones', 'Batch export', 'Platform presets'],
status: 'planning',
icon: <ShareIcon />,
help: 'Ship consistent assets across every social surface.',
pricing: {
estimate: '$0.02 - $0.06 / rendition',
notes: 'Unlimited exports on Pro + Enterprise tiers.',
},
example: {
title: 'One hero → 6 platform exports',
steps: [
'Add source image → auto-detect focal subject',
'Select IG, TikTok, LinkedIn, Pinterest presets',
'Review safe zones overlay → export ZIP + schedule',
],
eta: '~2s / platform',
},
},
{
key: 'control',
title: 'Control Studio',
subtitle: 'Sketch, structure & style',
description:
'Sketch-to-image, structure control, and advanced style transfer so creative directors can steer outputs precisely.',
highlights: ['Sketch control', 'Style libraries', 'Strength sliders'],
status: 'planning',
icon: <EditNoteIcon />,
help: 'For art directors who need total control over AI outputs.',
pricing: {
estimate: '$0.20 / render with dual-control',
notes: 'Saved reference boards reuse controls at $0.05.',
},
example: {
title: 'Storyboard consistency pack',
steps: [
'Upload wireframe + art-style JPEG',
'Set control strength 60% structure / 40% style',
'Generate 8 shots → auto-tag to Asset Library',
],
eta: '~8s per shot',
},
},
{
key: 'batch',
title: 'Batch Processor',
subtitle: 'Scale campaigns',
description:
'Queue generators, edits, upscales, and exports for entire campaigns with cost previews, scheduling, and monitoring.',
highlights: ['Bulk prompts', 'Usage tracking', 'Schedule windows'],
status: 'planning',
icon: <LibraryBooksIcon />,
help: 'Turn one brief into dozens of deliverables automatically.',
pricing: {
estimate: 'Dynamic · e.g. 25-image pack ≈ $9',
notes: 'Warns when batch exceeds remaining credits.',
},
example: {
title: 'Evergreen blog refresh',
steps: [
'Upload CSV prompts grouped by persona',
'Assign module per row (Create, Edit, Upscale)',
'Schedule weekend window + email digest',
],
eta: 'Depends on queue size',
},
},
{
key: 'library',
title: 'Asset Library',
subtitle: 'Searchable visual archive',
description:
'AI-tagged collections, favorites, history, and collaboration. Filters by platform, persona, use case, or campaign.',
highlights: ['AI tagging', 'Version history', 'Shareable collections'],
status: 'planning',
icon: <LibraryBooksIcon />,
help: 'Centralize every visual produced inside ALwrity.',
pricing: {
estimate: 'Included in tier · extra storage $5 / 100GB',
notes: 'Enterprise adds S3 export + governance logs.',
},
example: {
title: 'Campaign war-room board',
steps: [
'Filter by persona + platform → pin hero assets',
'Share read-only board with agency partner',
'Track usage + cost per asset inside analytics tab',
],
eta: 'Instant search (<500ms)',
},
},
];

View File

@@ -0,0 +1,122 @@
import React from 'react';
import { Box, Stack, Typography, Chip, Button } from '@mui/material';
import { controlAssets } from '../constants';
export const ControlEffectPreview: React.FC = () => {
const [videoKey, setVideoKey] = React.useState(0);
return (
<Box
sx={{
mt: 2,
borderRadius: 3,
border: '1px solid rgba(255,255,255,0.08)',
background: 'rgba(15,23,42,0.5)',
p: { xs: 2, md: 3 },
}}
>
<Stack direction={{ xs: 'column', md: 'row' }} spacing={2} alignItems="stretch">
<Box
sx={{
flex: 1,
borderRadius: 3,
border: '1px solid rgba(255,255,255,0.12)',
background: 'linear-gradient(135deg,#8b5cf6,#a855f7)',
color: '#f3e8ff',
p: 2,
display: 'flex',
flexDirection: 'column',
gap: 1.5,
}}
>
<Typography variant="overline" sx={{ letterSpacing: 2, color: '#e9d5ff' }}>
Control Input
</Typography>
<Box
component="img"
src={controlAssets.inputImage}
alt="Control reference"
sx={{ width: '100%', borderRadius: 2, border: '2px solid rgba(255,255,255,0.2)', boxShadow: '0 10px 25px rgba(139,92,246,0.3)' }}
/>
<Stack spacing={1}>
<Typography variant="caption" sx={{ color: '#e9d5ff', fontWeight: 600 }}>
Prompt
</Typography>
<Typography variant="body2" sx={{ fontSize: '0.875rem', lineHeight: 1.5 }}>
{controlAssets.prompt}
</Typography>
<Stack direction="row" spacing={1} flexWrap="wrap">
<Chip
size="small"
label={`Seed ${controlAssets.seed}`}
sx={{ background: 'rgba(255,255,255,0.2)', color: '#0f172a', borderRadius: 999 }}
/>
<Chip
size="small"
label={controlAssets.resolution}
sx={{ background: 'rgba(255,255,255,0.2)', color: '#0f172a', borderRadius: 999 }}
/>
<Chip
size="small"
label={`${controlAssets.duration}s`}
sx={{ background: 'rgba(255,255,255,0.2)', color: '#0f172a', borderRadius: 999 }}
/>
</Stack>
</Stack>
</Box>
<Box
sx={{
flex: 1.5,
borderRadius: 3,
border: '1px solid rgba(255,255,255,0.12)',
background: '#020617',
p: { xs: 1, md: 2 },
}}
>
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 1 }}>
<Typography variant="overline" sx={{ letterSpacing: 2, color: '#a78bfa' }}>
Generated Output
</Typography>
<Chip
label="WAN 2.5"
size="small"
sx={{ background: 'rgba(167,139,250,0.15)', color: '#a78bfa', borderRadius: 999 }}
/>
<Button size="small" onClick={() => setVideoKey(prev => prev + 1)} sx={{ ml: 'auto', color: '#a78bfa', textTransform: 'none' }}>
Reset preview
</Button>
</Stack>
<Box
sx={{
borderRadius: 2,
overflow: 'hidden',
border: '1px solid rgba(255,255,255,0.08)',
position: 'relative',
}}
>
<video key={videoKey} controls poster={controlAssets.inputImage} style={{ width: '100%', display: 'block' }} src={controlAssets.outputVideo} />
<Box
sx={{
position: 'absolute',
top: 12,
left: 12,
background: 'rgba(15,23,42,0.7)',
borderRadius: 999,
px: 1.5,
py: 0.5,
color: '#f8fafc',
fontSize: 12,
}}
>
Voiceover · {controlAssets.duration}s
</Box>
</Box>
</Box>
</Stack>
<Typography variant="caption" color="text.secondary" sx={{ mt: 1.5 }}>
Alibaba WAN 2.5 converts text or images into videos (480p/720p/1080p) with synced audio, faster and more affordable than Google Veo3.
</Typography>
</Box>
);
};

View File

@@ -0,0 +1,116 @@
import React from 'react';
import { Box, Stack, Typography, Chip } from '@mui/material';
import { createExamples } from '../constants';
export const CreateEffectPreview: React.FC = () => {
const [textHovered, setTextHovered] = React.useState(false);
const [exampleIndex, setExampleIndex] = React.useState(0);
const example = createExamples[exampleIndex];
const imageWidth = textHovered ? '20%' : '70%';
const textWidth = textHovered ? '80%' : '30%';
return (
<Box
sx={{
borderRadius: 3,
border: '3px solid',
borderImage:
'linear-gradient(135deg, rgba(124,58,237,0.8), rgba(14,165,233,0.8), rgba(16,185,129,0.8)) 1',
overflow: 'hidden',
height: { xs: 240, md: 280 },
display: 'flex',
background: '#0f172a',
mt: 1,
}}
>
<Box
sx={{
flex: '0 0 auto',
width: imageWidth,
transition: 'width 0.4s ease, filter 0.4s ease',
backgroundImage: `url(${example.image})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
filter: textHovered ? 'saturate(1.1)' : 'saturate(1)',
position: 'relative',
}}
>
<Stack
direction="row"
spacing={1}
sx={{
position: 'absolute',
bottom: 16,
left: '50%',
transform: 'translateX(-50%)',
background: 'rgba(15,23,42,0.8)',
borderRadius: 999,
px: 1.5,
py: 0.5,
boxShadow: '0 10px 20px rgba(2,6,23,0.45)',
}}
>
{createExamples.map((_, idx) => (
<Box
key={_.id}
onClick={() => setExampleIndex(idx)}
sx={{
width: 32,
height: 10,
borderRadius: 999,
background: idx === exampleIndex ? '#c4b5fd' : 'rgba(255,255,255,0.3)',
cursor: 'pointer',
transition: 'background 0.2s ease',
}}
/>
))}
</Stack>
</Box>
<Box
sx={{
flex: '0 0 auto',
width: textWidth,
background: 'rgba(248,250,252,0.95)',
color: '#0f172a',
p: 3,
display: 'flex',
flexDirection: 'column',
gap: 1,
boxShadow: '-12px 0 24px rgba(15,23,42,0.25)',
transition: 'width 0.4s ease',
}}
onMouseEnter={() => setTextHovered(true)}
onMouseLeave={() => setTextHovered(false)}
>
<Stack spacing={0.5} sx={{ overflowY: textHovered ? 'auto' : 'hidden', pr: 1 }}>
<Typography variant="overline" sx={{ letterSpacing: 1.5, color: '#818cf8' }}>
{example.label}
</Typography>
<Typography variant="subtitle2" fontWeight={700}>
Prompt
</Typography>
<Typography variant="body2">{example.prompt}</Typography>
<Typography variant="body2">{example.description}</Typography>
<Stack direction="row" spacing={1} flexWrap="wrap">
<Chip
size="small"
label={`Price ${example.price}`}
sx={{ background: '#ede9fe', color: '#4c1d95', borderRadius: 999, fontWeight: 600 }}
/>
<Chip
size="small"
label={`Turnaround ${example.eta}`}
sx={{ background: '#cffafe', color: '#0f766e', borderRadius: 999, fontWeight: 600 }}
/>
<Chip
size="small"
label={example.provider}
sx={{ background: '#dcfce7', color: '#166534', borderRadius: 999, fontWeight: 600 }}
/>
</Stack>
</Stack>
</Box>
</Box>
);
};

View File

@@ -0,0 +1,134 @@
import React from 'react';
import { Box, Stack, Typography, Chip, Tooltip } from '@mui/material';
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
import { editBeforeAfter } from '../constants';
export const EditEffectPreview: React.FC = () => {
const [exampleIndex, setExampleIndex] = React.useState(0);
const pair = editBeforeAfter[exampleIndex];
return (
<Box
sx={{
mt: 2,
borderRadius: 3,
border: '1px solid rgba(255,255,255,0.08)',
background: 'rgba(15,23,42,0.5)',
p: 2,
}}
>
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 2 }}>
<Typography variant="overline" sx={{ color: '#fcd34d', letterSpacing: 2 }}>
Before After
</Typography>
<Tooltip
title={
<Stack spacing={1}>
<Typography variant="body2">
Hover to reveal the original upload vs. AI-edited output. Perfect for showing background swaps,
inpainting, or relighting.
</Typography>
<Stack direction="row" spacing={1} flexWrap="wrap">
{['Erase objects cleanly', 'Smart relight', 'Replace backgrounds'].map(label => (
<Chip
key={label}
size="small"
label={label}
sx={{ background: 'rgba(236,252,203,0.12)', color: '#fef3c7', borderRadius: 999 }}
/>
))}
</Stack>
</Stack>
}
>
<InfoOutlinedIcon sx={{ fontSize: 18, color: 'rgba(252,211,77,0.85)', cursor: 'pointer' }} />
</Tooltip>
</Stack>
<Box
sx={{
'--gap': '8px',
display: 'grid',
position: 'relative',
width: '100%',
borderRadius: 3,
overflow: 'hidden',
border: '4px solid #22d3ee',
minHeight: { xs: 260, md: 300 },
'& > img': {
'--progress': 'calc(-1 * var(--gap))',
gridArea: '1 / 1',
width: '100%',
height: '100%',
objectFit: 'cover',
transition: 'clip-path 0.4s 0.1s',
},
'& > img:first-of-type': {
clipPath: 'polygon(0 0, calc(100% + var(--progress)) 0, 0 calc(100% + var(--progress)))',
},
'& > img:last-of-type': {
clipPath: 'polygon(100% 100%, 100% calc(0% - var(--progress)), calc(0% - var(--progress)) 100%)',
},
'&:hover > img:last-of-type, &:hover > img:first-of-type:hover': {
'--progress': 'calc(50% - var(--gap))',
},
'&:hover > img:first-of-type, &:hover > img:first-of-type:hover + img': {
'--progress': 'calc(-50% - var(--gap))',
},
}}
>
<Box component="img" src={pair.before} alt="Original asset" />
<Box component="img" src={pair.after} alt="Edited asset" />
<Stack
direction="row"
spacing={1}
sx={{
position: 'absolute',
top: 12,
right: 12,
background: 'rgba(15,23,42,0.8)',
borderRadius: 999,
px: 1,
py: 0.5,
boxShadow: '0 10px 20px rgba(2,6,23,0.5)',
}}
>
{editBeforeAfter.map((_, idx) => (
<Box
key={idx}
onClick={() => setExampleIndex(idx)}
sx={{
width: 10,
height: 10,
borderRadius: '50%',
background: idx === exampleIndex ? '#f472b6' : 'rgba(255,255,255,0.4)',
cursor: 'pointer',
}}
/>
))}
</Stack>
<Stack
direction="row"
spacing={1}
sx={{
position: 'absolute',
bottom: 12,
left: '50%',
transform: 'translateX(-50%)',
background: 'rgba(15,23,42,0.85)',
px: 1.5,
py: 0.75,
borderRadius: 999,
boxShadow: '0 10px 25px rgba(2,6,23,0.6)',
}}
>
<Chip label="Original" size="small" sx={{ background: '#fef3c7', color: '#78350f', fontWeight: 600 }} />
<Chip label="Edited" size="small" sx={{ background: '#a5b4fc', color: '#1e1b4b', fontWeight: 600 }} />
</Stack>
</Box>
<Typography variant="caption" color="text.secondary" sx={{ mt: 1.5 }}>
Prompt used: {pair.prompt}
</Typography>
</Box>
);
};

View File

@@ -0,0 +1,78 @@
import React from 'react';
import { Box, Stack, Typography, Chip } from '@mui/material';
import { transformAssets, platformPresets } from '../constants';
export const SocialOptimizerEffectPreview: React.FC = () => (
<Box
sx={{
mt: 2,
borderRadius: 3,
border: '1px solid rgba(255,255,255,0.08)',
background: 'rgba(15,23,42,0.5)',
p: { xs: 2, md: 3 },
}}
>
<Typography variant="overline" sx={{ letterSpacing: 2, color: '#fcd34d' }}>
Platform Auto-Crop
</Typography>
<Typography variant="body2" color="text.secondary">
Smart resize finds the focal point and generates safe-zone aware crops for every surface.
</Typography>
<Box
sx={{
mt: 2,
borderRadius: 3,
border: '1px solid rgba(255,255,255,0.1)',
background: '#020617',
p: 2,
position: 'relative',
minHeight: 280,
overflow: 'hidden',
}}
>
<Box
component="img"
src={transformAssets.storyboard}
alt="Source creative"
sx={{ width: '100%', height: '100%', objectFit: 'cover', borderRadius: 2, filter: 'brightness(0.8)' }}
/>
{platformPresets.map(frame => (
<Box
key={frame.label}
sx={{
position: 'absolute',
border: '2px solid rgba(248,250,252,0.8)',
borderRadius: 2,
boxShadow: '0 10px 20px rgba(2,6,23,0.45)',
transition: 'transform 0.2s ease',
cursor: 'pointer',
'&:hover': { transform: 'scale(1.05)' },
...frame,
}}
>
<Box
sx={{
position: 'absolute',
top: -24,
left: 0,
background: 'rgba(15,23,42,0.85)',
color: '#f8fafc',
px: 1,
py: 0.25,
borderRadius: 999,
fontSize: 11,
}}
>
{frame.label}
</Box>
</Box>
))}
</Box>
<Stack direction="row" spacing={1} flexWrap="wrap" sx={{ mt: 2 }}>
{['Safe zones', 'Focal cropping', 'Batch export'].map(label => (
<Chip key={label} size="small" label={label} sx={{ background: 'rgba(15,118,110,0.2)', color: '#5eead4', borderRadius: 999 }} />
))}
</Stack>
</Box>
);

View File

@@ -0,0 +1,114 @@
import React from 'react';
import { Box, Stack, Typography, Chip, Button } from '@mui/material';
import { transformAssets } from '../constants';
export const TransformEffectPreview: React.FC = () => {
const [videoKey, setVideoKey] = React.useState(0);
return (
<Box
sx={{
mt: 2,
borderRadius: 3,
border: '1px solid rgba(255,255,255,0.08)',
background: 'rgba(15,23,42,0.5)',
p: { xs: 2, md: 3 },
}}
>
<Stack direction={{ xs: 'column', md: 'row' }} spacing={2} alignItems="stretch">
<Box
sx={{
flex: 1,
borderRadius: 3,
border: '1px solid rgba(255,255,255,0.12)',
background: 'linear-gradient(135deg,#0ea5e9,#6366f1)',
color: '#e0f2fe',
p: 2,
minHeight: 260,
display: 'flex',
flexDirection: 'column',
gap: 1.2,
}}
>
<Typography variant="overline" sx={{ letterSpacing: 2, color: '#cffafe' }}>
Storyboard Prompt
</Typography>
<Typography variant="body2">{transformAssets.script}</Typography>
<Stack direction="row" spacing={1} flexWrap="wrap">
{['Image-to-video', 'WAN 2.5', '10s duration'].map(label => (
<Chip
key={label}
size="small"
label={label}
sx={{ background: 'rgba(255,255,255,0.2)', color: '#0f172a', borderRadius: 999 }}
/>
))}
</Stack>
<Box
sx={{
mt: 'auto',
borderRadius: 2,
overflow: 'hidden',
border: '1px solid rgba(255,255,255,0.25)',
boxShadow: '0 20px 45px rgba(2,6,23,0.45)',
}}
>
<Box component="img" src={transformAssets.storyboard} alt="Storyboard still" sx={{ width: '100%', display: 'block' }} />
</Box>
</Box>
<Box
sx={{
flex: 1.5,
borderRadius: 3,
border: '1px solid rgba(255,255,255,0.12)',
background: '#020617',
p: { xs: 1, md: 2 },
}}
>
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 1 }}>
<Typography variant="overline" sx={{ letterSpacing: 2, color: '#38bdf8' }}>
Render Preview
</Typography>
<Chip
label="1080p"
size="small"
sx={{ background: 'rgba(56,189,248,0.15)', color: '#38bdf8', borderRadius: 999 }}
/>
<Button size="small" onClick={() => setVideoKey(prev => prev + 1)} sx={{ ml: 'auto', color: '#38bdf8', textTransform: 'none' }}>
Reset preview
</Button>
</Stack>
<Box
sx={{
borderRadius: 2,
overflow: 'hidden',
border: '1px solid rgba(255,255,255,0.08)',
position: 'relative',
}}
>
<video key={videoKey} controls poster={transformAssets.storyboard} style={{ width: '100%', display: 'block' }} src={transformAssets.video} />
<Box
sx={{
position: 'absolute',
top: 12,
left: 12,
background: 'rgba(15,23,42,0.7)',
borderRadius: 999,
px: 1.5,
py: 0.5,
color: '#f8fafc',
fontSize: 12,
}}
>
Scene 1 · Cloud Kitchen
</Box>
</Box>
</Box>
</Stack>
<Typography variant="caption" color="text.secondary" sx={{ mt: 1.5 }}>
Convert hero images into narrated clips with motion presets, subtitles, and audio uploads.
</Typography>
</Box>
);
};

View File

@@ -0,0 +1,107 @@
import React from 'react';
import { Box, Stack, Typography, Chip } from '@mui/material';
import { upscaleSamples } from '../constants';
export const UpscaleEffectPreview: React.FC = () => (
<Box
sx={{
mt: 2,
borderRadius: 3,
border: '1px solid rgba(255,255,255,0.08)',
background: 'rgba(15,23,42,0.5)',
p: { xs: 2, md: 3 },
}}
>
<Stack direction="row" spacing={1} alignItems="center" justifyContent="space-between">
<Box>
<Typography variant="overline" sx={{ letterSpacing: 2, color: '#f9a8d4' }}>
4× Upscale Showcase
</Typography>
<Typography variant="body2" color="text.secondary">
Flip the panels to compare the low-res upload with the 4K-ready output.
</Typography>
</Box>
<Chip
label="Fast vs Creative"
size="small"
sx={{ background: 'rgba(236,72,153,0.15)', color: '#f9a8d4', borderRadius: 999 }}
/>
</Stack>
<Box
sx={{
mt: 2,
display: 'flex',
gap: 2,
justifyContent: 'center',
flexWrap: 'wrap',
'&:hover .flip-left': { transform: 'rotateY(-180deg)' },
'&:hover .flip-right': { transform: 'rotateY(180deg)' },
}}
>
{[
{ key: 'low', label: 'Before 600×400', className: 'flip-left', image: upscaleSamples.lowRes },
{ key: 'high', label: 'After 2400×1600', className: 'flip-right', image: upscaleSamples.hiRes },
].map(card => (
<Box key={card.key} sx={{ perspective: 1000, width: { xs: 140, sm: 180 }, height: { xs: 200, sm: 240 } }}>
<Box
className={card.className}
sx={{ position: 'relative', width: '100%', height: '100%', transition: '0.6s', transformStyle: 'preserve-3d' }}
>
<Box
className="front"
sx={{
position: 'absolute',
inset: 0,
backfaceVisibility: 'hidden',
borderRadius: 3,
border: '1px solid rgba(255,255,255,0.1)',
background:
card.key === 'low'
? 'linear-gradient(135deg,#4c1d95,#9333ea)'
: 'linear-gradient(135deg,#0f766e,#14b8a6)',
display: 'flex',
alignItems: 'center',
justifyContent: card.key === 'low' ? 'flex-end' : 'flex-start',
px: 2,
color: '#fff',
}}
>
<Box
sx={{
width: 80,
height: 80,
borderRadius: '50%',
background: 'rgba(255,255,255,0.2)',
border: '2px solid rgba(255,255,255,0.8)',
}}
/>
<Typography variant="caption" sx={{ ml: card.key === 'low' ? -6 : 2, mr: card.key === 'low' ? 2 : -6 }}>
{card.label}
</Typography>
</Box>
<Box
className="back"
sx={{
position: 'absolute',
inset: 0,
backfaceVisibility: 'hidden',
borderRadius: 3,
transform: 'rotateY(180deg)',
overflow: 'hidden',
border: '1px solid rgba(255,255,255,0.15)',
}}
>
<Box component="img" src={card.image} alt={card.label} sx={{ width: '100%', height: '100%', objectFit: 'cover' }} />
</Box>
</Box>
</Box>
))}
</Box>
<Typography variant="caption" color="text.secondary" sx={{ mt: 1.5 }}>
Try creative upscaling for texture enhancement, or fast mode for previews.
</Typography>
</Box>
);

View File

@@ -0,0 +1,7 @@
export { CreateEffectPreview } from './CreateEffectPreview';
export { EditEffectPreview } from './EditEffectPreview';
export { UpscaleEffectPreview } from './UpscaleEffectPreview';
export { TransformEffectPreview } from './TransformEffectPreview';
export { SocialOptimizerEffectPreview } from './SocialOptimizerEffectPreview';
export { ControlEffectPreview } from './ControlEffectPreview';

View File

@@ -0,0 +1,25 @@
import React from 'react';
export type ModuleStatus = 'live' | 'coming soon' | 'planning';
export type ModuleConfig = {
key: string;
title: string;
subtitle: string;
description: string;
highlights: string[];
status: ModuleStatus;
route?: string;
icon: React.ReactNode;
help: string;
pricing: {
estimate: string;
notes: string;
};
example: {
title: string;
steps: string[];
eta: string;
};
};

View File

@@ -0,0 +1,10 @@
export { CreateStudio } from './CreateStudio';
export { TemplateSelector } from './TemplateSelector';
export { ImageResultsGallery } from './ImageResultsGallery';
export { CostEstimator } from './CostEstimator';
export { EditStudio } from './EditStudio';
export { UpscaleStudio } from './UpscaleStudio';
export { ImageStudioDashboard } from './ImageStudioDashboard';
export { ImageStudioLayout } from './ImageStudioLayout';
export { ImageMaskEditor } from './ImageMaskEditor';

View File

@@ -0,0 +1,62 @@
import React from 'react';
import { Alert, LinearProgress, Stack, Typography } from '@mui/material';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import ErrorIcon from '@mui/icons-material/ErrorOutline';
import HourglassTopIcon from '@mui/icons-material/HourglassTop';
interface AsyncStatusBannerProps {
state: 'idle' | 'running' | 'success' | 'error';
message?: string;
}
export const AsyncStatusBanner: React.FC<AsyncStatusBannerProps> = ({
state,
message,
}) => {
if (state === 'idle') return null;
if (state === 'running') {
return (
<Stack spacing={1}>
<LinearProgress
sx={{
height: 6,
borderRadius: 999,
'& .MuiLinearProgress-bar': {
background: 'linear-gradient(90deg,#7c3aed,#2563eb)',
},
}}
/>
<Stack direction="row" alignItems="center" spacing={1}>
<HourglassTopIcon sx={{ color: '#93c5fd' }} fontSize="small" />
<Typography variant="caption" color="text.secondary">
{message || 'Processing request…'}
</Typography>
</Stack>
</Stack>
);
}
if (state === 'success') {
return (
<Alert
icon={<CheckCircleIcon />}
severity="success"
sx={{ borderRadius: 2 }}
>
{message || 'Completed successfully.'}
</Alert>
);
}
return (
<Alert
icon={<ErrorIcon />}
severity="error"
sx={{ borderRadius: 2 }}
>
{message || 'Something went wrong.'}
</Alert>
);
};

View File

@@ -0,0 +1,35 @@
import React from 'react';
import { Paper, PaperProps } from '@mui/material';
import { motion } from 'framer-motion';
import { cardLiftVariants } from './motionPresets';
export interface GlassyCardProps extends PaperProps {
animateOnMount?: boolean;
}
export const GlassyCard: React.FC<GlassyCardProps> = ({
children,
animateOnMount = true,
sx,
...rest
}) => (
<Paper
component={motion.div}
variants={cardLiftVariants}
initial={animateOnMount ? 'hidden' : false}
animate={animateOnMount ? 'visible' : false}
whileHover="hover"
elevation={0}
sx={{
borderRadius: 4,
border: '1px solid rgba(255,255,255,0.08)',
background: 'rgba(15,23,42,0.72)',
backdropFilter: 'blur(24px)',
...sx,
}}
{...rest}
>
{children}
</Paper>
);

View File

@@ -0,0 +1,38 @@
import React from 'react';
import { Box, Skeleton, type BoxProps } from '@mui/material';
import { motion } from 'framer-motion';
import { shimmerVariants } from './motionPresets';
interface LoadingSkeletonProps extends BoxProps {
lines?: number;
}
export const LoadingSkeleton: React.FC<LoadingSkeletonProps> = ({
lines = 3,
sx,
...rest
}) => (
<Box
component={motion.div}
variants={shimmerVariants}
initial="initial"
animate="animate"
sx={{ width: '100%', ...sx }}
{...rest}
>
{Array.from({ length: lines }).map((_, idx) => (
<Skeleton
key={idx}
variant="rectangular"
height={16}
animation="wave"
sx={{
my: 1,
borderRadius: 1,
bgcolor: 'rgba(148,163,184,0.15)',
}}
/>
))}
</Box>
);

View File

@@ -0,0 +1,52 @@
import React from 'react';
import { Stack, Typography, Chip, type StackProps } from '@mui/material';
import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome';
interface SectionHeaderProps extends StackProps {
title: string;
subtitle?: string;
status?: 'live' | 'beta' | 'coming';
}
const statusStyles: Record<
NonNullable<SectionHeaderProps['status']>,
{ label: string; color: string; bg: string }
> = {
live: { label: 'Live', color: '#10b981', bg: 'rgba(16,185,129,0.15)' },
beta: { label: 'Beta', color: '#f59e0b', bg: 'rgba(245,158,11,0.15)' },
coming: { label: 'Coming Soon', color: '#94a3b8', bg: 'rgba(148,163,184,0.15)' },
};
export const SectionHeader: React.FC<SectionHeaderProps> = ({
title,
subtitle,
status,
sx,
...rest
}) => (
<Stack spacing={0.5} sx={{ color: '#f8fafc', ...sx }} {...rest}>
<Stack direction="row" spacing={1} alignItems="center">
<AutoAwesomeIcon sx={{ color: '#c4b5fd' }} fontSize="small" />
<Typography variant="h5" fontWeight={800}>
{title}
</Typography>
{status && (
<Chip
size="small"
label={statusStyles[status].label}
sx={{
backgroundColor: statusStyles[status].bg,
color: statusStyles[status].color,
fontWeight: 700,
}}
/>
)}
</Stack>
{subtitle && (
<Typography variant="body2" color="text.secondary">
{subtitle}
</Typography>
)}
</Stack>
);

View File

@@ -0,0 +1,34 @@
import React from 'react';
import { Chip, type ChipProps } from '@mui/material';
export interface StatusChipProps extends Omit<ChipProps, 'color'> {
tone?: 'success' | 'warning' | 'info' | 'neutral';
}
const toneStyles: Record<
NonNullable<StatusChipProps['tone']>,
{ bg: string; color: string }
> = {
success: { bg: 'rgba(16,185,129,0.15)', color: '#10b981' },
warning: { bg: 'rgba(245,158,11,0.15)', color: '#f59e0b' },
info: { bg: 'rgba(59,130,246,0.15)', color: '#3b82f6' },
neutral: { bg: 'rgba(148,163,184,0.2)', color: '#cbd5f5' },
};
export const StatusChip: React.FC<StatusChipProps> = ({
tone = 'neutral',
sx,
...rest
}) => (
<Chip
size="small"
sx={{
backgroundColor: toneStyles[tone].bg,
color: toneStyles[tone].color,
fontWeight: 700,
...sx,
}}
{...rest}
/>
);

View File

@@ -0,0 +1,7 @@
export * from './motionPresets';
export * from './GlassyCard';
export * from './SectionHeader';
export * from './StatusChip';
export * from './AsyncStatusBanner';
export * from './LoadingSkeleton';

View File

@@ -0,0 +1,35 @@
import { type Variants, type Easing } from 'framer-motion';
export const easeOutSmooth: Easing = [0.4, 0, 0.2, 1];
export const easeEmphasis: Easing = [0.22, 0.61, 0.36, 1];
export const fadeSlideVariants: Variants = {
hidden: { opacity: 0, y: 24 },
visible: {
opacity: 1,
y: 0,
transition: { duration: 0.5, ease: easeOutSmooth },
},
};
export const cardLiftVariants: Variants = {
hidden: { opacity: 0, scale: 0.95 },
visible: {
opacity: 1,
scale: 1,
transition: { duration: 0.45, ease: easeEmphasis },
},
hover: {
y: -4,
transition: { duration: 0.2, ease: easeOutSmooth },
},
};
export const shimmerVariants: Variants = {
initial: { opacity: 0.4 },
animate: {
opacity: [0.4, 0.7, 0.4],
transition: { duration: 1.6, repeat: Infinity, ease: 'easeInOut' },
},
};