493 lines
16 KiB
TypeScript
493 lines
16 KiB
TypeScript
import React, { useState, useCallback, useEffect, useMemo } from 'react';
|
|
import {
|
|
Dialog,
|
|
DialogTitle,
|
|
DialogContent,
|
|
DialogActions,
|
|
Button,
|
|
Grid,
|
|
Box,
|
|
Typography,
|
|
TextField,
|
|
InputAdornment,
|
|
CircularProgress,
|
|
Card,
|
|
CardMedia,
|
|
CardContent,
|
|
IconButton,
|
|
Alert,
|
|
Tooltip,
|
|
Stack,
|
|
} from '@mui/material';
|
|
import {
|
|
Search,
|
|
Close,
|
|
CheckCircle,
|
|
Favorite,
|
|
FavoriteBorder,
|
|
Collections,
|
|
} from '@mui/icons-material';
|
|
import { useContentAssets, ContentAsset } from '../../hooks/useContentAssets';
|
|
import { fetchMediaBlobUrl } from '../../utils/fetchMediaBlobUrl';
|
|
|
|
export interface AssetLibraryImageModalProps {
|
|
open: boolean;
|
|
onClose: () => void;
|
|
onSelect: (asset: ContentAsset) => void;
|
|
title?: string;
|
|
sourceModule?: string | string[]; // Optional filter by source module(s) (e.g., 'youtube_creator', 'podcast_maker', or ['youtube_creator', 'podcast_maker'])
|
|
allowFavoritesOnly?: boolean; // Optional favorites-only filter toggle
|
|
}
|
|
|
|
/**
|
|
* Reusable modal to browse and pick images from the Asset Library.
|
|
* Image-only, with search and optional favorites/source filtering.
|
|
*/
|
|
export const AssetLibraryImageModal: React.FC<AssetLibraryImageModalProps> = ({
|
|
open,
|
|
onClose,
|
|
onSelect,
|
|
title = 'Select Image from Asset Library',
|
|
sourceModule,
|
|
allowFavoritesOnly = false,
|
|
}) => {
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
const [selectedAsset, setSelectedAsset] = useState<ContentAsset | null>(null);
|
|
const [page, setPage] = useState(0);
|
|
const [favoritesOnly, setFavoritesOnly] = useState(false);
|
|
const [imageBlobUrls, setImageBlobUrls] = useState<Map<number, string>>(new Map());
|
|
const [loadingImages, setLoadingImages] = useState<Set<number>>(new Set());
|
|
const pageSize = 24;
|
|
|
|
// Filter for images only
|
|
const filters = {
|
|
asset_type: 'image' as const,
|
|
source_module: sourceModule,
|
|
search: searchQuery || undefined,
|
|
favorites_only: allowFavoritesOnly && favoritesOnly ? true : undefined,
|
|
limit: pageSize,
|
|
offset: page * pageSize,
|
|
};
|
|
|
|
const { assets, loading, error, total, toggleFavorite, refetch } = useContentAssets(filters);
|
|
|
|
// Check if a URL requires authentication (internal API endpoints)
|
|
const isAuthenticatedUrl = useCallback((url: string): boolean => {
|
|
if (!url) return false;
|
|
return url.includes('/api/podcast/') ||
|
|
url.includes('/api/youtube/') ||
|
|
url.includes('/api/story/') ||
|
|
(url.startsWith('/') && !url.startsWith('//'));
|
|
}, []);
|
|
|
|
// Load blob URLs for authenticated images
|
|
useEffect(() => {
|
|
if (!open || assets.length === 0) {
|
|
// Clean up blob URLs when modal closes or no assets
|
|
setImageBlobUrls(prev => {
|
|
prev.forEach((url) => {
|
|
if (url.startsWith('blob:')) {
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
});
|
|
return new Map();
|
|
});
|
|
setLoadingImages(new Set());
|
|
return;
|
|
}
|
|
|
|
const loadBlobUrls = async () => {
|
|
const newBlobUrls = new Map<number, string>();
|
|
const newLoadingImages = new Set<number>();
|
|
|
|
for (const asset of assets) {
|
|
if (!asset.file_url) continue;
|
|
|
|
// Check if this is an authenticated endpoint
|
|
if (isAuthenticatedUrl(asset.file_url)) {
|
|
newLoadingImages.add(asset.id);
|
|
try {
|
|
const blobUrl = await fetchMediaBlobUrl(asset.file_url);
|
|
if (blobUrl) {
|
|
newBlobUrls.set(asset.id, blobUrl);
|
|
}
|
|
} catch (err) {
|
|
console.error(`[AssetLibraryImageModal] Failed to load image for asset ${asset.id}:`, err);
|
|
} finally {
|
|
newLoadingImages.delete(asset.id);
|
|
}
|
|
} else {
|
|
// External URL, use directly
|
|
newBlobUrls.set(asset.id, asset.file_url);
|
|
}
|
|
}
|
|
|
|
setImageBlobUrls(prev => {
|
|
// Clean up old blob URLs that are no longer needed
|
|
prev.forEach((url, id) => {
|
|
if (!newBlobUrls.has(id) && url.startsWith('blob:')) {
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
});
|
|
return newBlobUrls;
|
|
});
|
|
setLoadingImages(newLoadingImages);
|
|
};
|
|
|
|
loadBlobUrls();
|
|
|
|
// Cleanup function
|
|
return () => {
|
|
// Don't clean up here - let the next effect handle it
|
|
};
|
|
}, [assets, open, isAuthenticatedUrl]);
|
|
|
|
// Cleanup blob URLs on unmount
|
|
useEffect(() => {
|
|
return () => {
|
|
imageBlobUrls.forEach((url) => {
|
|
if (url.startsWith('blob:')) {
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
});
|
|
};
|
|
}, []);
|
|
|
|
const handleSelect = useCallback(() => {
|
|
if (selectedAsset) {
|
|
onSelect(selectedAsset);
|
|
handleClose();
|
|
}
|
|
}, [selectedAsset, onSelect]);
|
|
|
|
const handleClose = useCallback(() => {
|
|
onClose();
|
|
setSelectedAsset(null);
|
|
setSearchQuery('');
|
|
setPage(0);
|
|
setFavoritesOnly(false);
|
|
}, [onClose]);
|
|
|
|
const handleAssetClick = useCallback((asset: ContentAsset) => {
|
|
setSelectedAsset(asset);
|
|
}, []);
|
|
|
|
const handleFavoriteToggle = useCallback(
|
|
async (assetId: number, e: React.MouseEvent) => {
|
|
e.stopPropagation();
|
|
try {
|
|
await toggleFavorite(assetId);
|
|
refetch();
|
|
} catch (err) {
|
|
console.error('Error toggling favorite:', err);
|
|
}
|
|
},
|
|
[toggleFavorite, refetch]
|
|
);
|
|
|
|
return (
|
|
<Dialog
|
|
open={open}
|
|
onClose={handleClose}
|
|
maxWidth="lg"
|
|
fullWidth
|
|
PaperProps={{
|
|
sx: {
|
|
borderRadius: 2,
|
|
maxHeight: '90vh',
|
|
backgroundColor: '#ffffff',
|
|
},
|
|
}}
|
|
>
|
|
<DialogTitle>
|
|
<Stack direction="row" justifyContent="space-between" alignItems="center">
|
|
<Stack direction="row" spacing={1} alignItems="center">
|
|
<Collections sx={{ color: '#FF0000' }} />
|
|
<Typography variant="h6" sx={{ fontWeight: 600, color: '#111827' }}>
|
|
{title}
|
|
</Typography>
|
|
</Stack>
|
|
<IconButton onClick={handleClose} size="small" sx={{ color: '#6b7280' }}>
|
|
<Close />
|
|
</IconButton>
|
|
</Stack>
|
|
</DialogTitle>
|
|
|
|
<DialogContent dividers sx={{ backgroundColor: '#f9fafb' }}>
|
|
{/* Search and Filters */}
|
|
<Box sx={{ mb: 3 }}>
|
|
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} alignItems="center">
|
|
<TextField
|
|
fullWidth
|
|
placeholder="Search images by title, description, or tags..."
|
|
value={searchQuery}
|
|
onChange={(e) => {
|
|
setSearchQuery(e.target.value);
|
|
setPage(0);
|
|
}}
|
|
InputProps={{
|
|
startAdornment: (
|
|
<InputAdornment position="start">
|
|
<Search sx={{ color: '#9ca3af' }} />
|
|
</InputAdornment>
|
|
),
|
|
}}
|
|
sx={{
|
|
'& .MuiOutlinedInput-root': {
|
|
backgroundColor: '#ffffff',
|
|
'& fieldset': {
|
|
borderColor: '#d1d5db',
|
|
},
|
|
},
|
|
}}
|
|
/>
|
|
{allowFavoritesOnly && (
|
|
<Button
|
|
variant={favoritesOnly ? 'contained' : 'outlined'}
|
|
startIcon={<Favorite />}
|
|
onClick={() => {
|
|
setFavoritesOnly(!favoritesOnly);
|
|
setPage(0);
|
|
}}
|
|
sx={{
|
|
minWidth: 160,
|
|
borderColor: '#d1d5db',
|
|
color: favoritesOnly ? '#ffffff' : '#6b7280',
|
|
bgcolor: favoritesOnly ? '#ef4444' : 'transparent',
|
|
'&:hover': {
|
|
borderColor: '#9ca3af',
|
|
bgcolor: favoritesOnly ? '#dc2626' : '#f9fafb',
|
|
},
|
|
}}
|
|
>
|
|
{favoritesOnly ? 'Favorites' : 'All Images'}
|
|
</Button>
|
|
)}
|
|
</Stack>
|
|
<Typography variant="body2" sx={{ color: '#6b7280', mt: 1.5 }}>
|
|
{loading
|
|
? 'Loading...'
|
|
: total > 0
|
|
? `${total} image${total !== 1 ? 's' : ''} found`
|
|
: 'No images found'}
|
|
</Typography>
|
|
</Box>
|
|
|
|
{/* Error State */}
|
|
{error && (
|
|
<Alert severity="error" sx={{ mb: 2 }}>
|
|
{error}
|
|
</Alert>
|
|
)}
|
|
|
|
{/* Loading State */}
|
|
{loading && assets.length === 0 ? (
|
|
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
|
|
<CircularProgress />
|
|
</Box>
|
|
) : assets.length === 0 ? (
|
|
/* Empty State */
|
|
<Box sx={{ textAlign: 'center', py: 8 }}>
|
|
<Collections sx={{ fontSize: 64, color: '#d1d5db', mb: 2 }} />
|
|
<Typography variant="h6" sx={{ color: '#6b7280', mb: 1 }}>
|
|
{searchQuery ? 'No images found matching your search.' : 'No images in your asset library yet.'}
|
|
</Typography>
|
|
<Typography variant="body2" sx={{ color: '#9ca3af' }}>
|
|
{searchQuery ? 'Try a different search term.' : 'Generate some images first to see them here.'}
|
|
</Typography>
|
|
</Box>
|
|
) : (
|
|
/* Image Grid */
|
|
<Box
|
|
sx={{
|
|
maxHeight: 'calc(90vh - 280px)',
|
|
overflowY: 'auto',
|
|
'&::-webkit-scrollbar': {
|
|
width: '8px',
|
|
},
|
|
'&::-webkit-scrollbar-track': {
|
|
backgroundColor: '#f1f5f9',
|
|
},
|
|
'&::-webkit-scrollbar-thumb': {
|
|
backgroundColor: '#cbd5e1',
|
|
borderRadius: '4px',
|
|
'&:hover': {
|
|
backgroundColor: '#94a3b8',
|
|
},
|
|
},
|
|
}}
|
|
>
|
|
<Grid container spacing={2}>
|
|
{assets.map((asset) => (
|
|
<Grid item xs={6} sm={4} md={3} key={asset.id}>
|
|
<Card
|
|
sx={{
|
|
cursor: 'pointer',
|
|
position: 'relative',
|
|
border: selectedAsset?.id === asset.id ? '2px solid #FF0000' : '1px solid #e5e7eb',
|
|
borderRadius: 2,
|
|
overflow: 'hidden',
|
|
transition: 'all 0.2s ease-in-out',
|
|
'&:hover': {
|
|
boxShadow: 4,
|
|
borderColor: selectedAsset?.id === asset.id ? '#FF0000' : '#9ca3af',
|
|
transform: 'translateY(-2px)',
|
|
},
|
|
}}
|
|
onClick={() => handleAssetClick(asset)}
|
|
>
|
|
{/* Image */}
|
|
<Box sx={{ position: 'relative', paddingTop: '100%' }}>
|
|
{loadingImages.has(asset.id) ? (
|
|
<Box
|
|
sx={{
|
|
position: 'absolute',
|
|
top: 0,
|
|
left: 0,
|
|
width: '100%',
|
|
height: '100%',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
bgcolor: '#f3f4f6',
|
|
}}
|
|
>
|
|
<CircularProgress size={24} />
|
|
</Box>
|
|
) : (
|
|
<CardMedia
|
|
component="img"
|
|
image={imageBlobUrls.get(asset.id) || asset.file_url}
|
|
alt={asset.title || 'Asset'}
|
|
sx={{
|
|
position: 'absolute',
|
|
top: 0,
|
|
left: 0,
|
|
width: '100%',
|
|
height: '100%',
|
|
objectFit: 'cover',
|
|
bgcolor: '#f3f4f6', // Fallback background while loading
|
|
}}
|
|
onError={(e) => {
|
|
// Fallback if image fails to load
|
|
const target = e.target as HTMLImageElement;
|
|
target.style.display = 'none';
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{/* Selected Indicator */}
|
|
{selectedAsset?.id === asset.id && (
|
|
<Box
|
|
sx={{
|
|
position: 'absolute',
|
|
top: 8,
|
|
right: 8,
|
|
bgcolor: '#FF0000',
|
|
borderRadius: '50%',
|
|
p: 0.5,
|
|
boxShadow: '0 2px 4px rgba(0,0,0,0.2)',
|
|
}}
|
|
>
|
|
<CheckCircle sx={{ color: 'white', fontSize: 20 }} />
|
|
</Box>
|
|
)}
|
|
|
|
{/* Favorite Button */}
|
|
<Tooltip title={asset.is_favorite ? 'Remove from favorites' : 'Add to favorites'}>
|
|
<IconButton
|
|
size="small"
|
|
sx={{
|
|
position: 'absolute',
|
|
top: 8,
|
|
left: 8,
|
|
bgcolor: 'rgba(255, 255, 255, 0.9)',
|
|
'&:hover': { bgcolor: 'white' },
|
|
transition: 'all 0.2s',
|
|
}}
|
|
onClick={(e) => handleFavoriteToggle(asset.id, e)}
|
|
>
|
|
{asset.is_favorite ? (
|
|
<Favorite sx={{ color: '#ef4444', fontSize: 18 }} />
|
|
) : (
|
|
<FavoriteBorder sx={{ color: '#6b7280', fontSize: 18 }} />
|
|
)}
|
|
</IconButton>
|
|
</Tooltip>
|
|
</Box>
|
|
|
|
{/* Title */}
|
|
{asset.title && (
|
|
<CardContent sx={{ p: 1.5, '&:last-child': { pb: 1.5 } }}>
|
|
<Typography
|
|
variant="caption"
|
|
sx={{
|
|
display: 'block',
|
|
fontWeight: 500,
|
|
color: '#111827',
|
|
overflow: 'hidden',
|
|
textOverflow: 'ellipsis',
|
|
whiteSpace: 'nowrap',
|
|
}}
|
|
title={asset.title}
|
|
>
|
|
{asset.title}
|
|
</Typography>
|
|
{asset.source_module && (
|
|
<Typography
|
|
variant="caption"
|
|
sx={{
|
|
display: 'block',
|
|
color: '#6b7280',
|
|
fontSize: '0.7rem',
|
|
mt: 0.25,
|
|
}}
|
|
>
|
|
{asset.source_module.replace('_', ' ')}
|
|
</Typography>
|
|
)}
|
|
</CardContent>
|
|
)}
|
|
</Card>
|
|
</Grid>
|
|
))}
|
|
</Grid>
|
|
|
|
{/* Load More (if needed) */}
|
|
{total > (page + 1) * pageSize && (
|
|
<Box sx={{ display: 'flex', justifyContent: 'center', mt: 3 }}>
|
|
<Button variant="outlined" onClick={() => setPage(page + 1)} disabled={loading}>
|
|
{loading ? <CircularProgress size={20} /> : 'Load More'}
|
|
</Button>
|
|
</Box>
|
|
)}
|
|
</Box>
|
|
)}
|
|
</DialogContent>
|
|
|
|
<DialogActions sx={{ px: 3, py: 2, backgroundColor: '#ffffff', borderTop: '1px solid #e5e7eb' }}>
|
|
<Button onClick={handleClose} sx={{ color: '#6b7280' }}>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
variant="contained"
|
|
color="error"
|
|
onClick={handleSelect}
|
|
disabled={!selectedAsset}
|
|
startIcon={selectedAsset ? <CheckCircle /> : undefined}
|
|
sx={{
|
|
minWidth: 140,
|
|
'&:disabled': {
|
|
backgroundColor: '#e5e7eb',
|
|
color: '#9ca3af',
|
|
},
|
|
}}
|
|
>
|
|
Select Image
|
|
</Button>
|
|
</DialogActions>
|
|
</Dialog>
|
|
);
|
|
};
|
|
|