Add video support, media filters, and translations
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { api } from '../../config/api';
|
||||
|
||||
interface AdminAuthenticatedVideoProps extends React.VideoHTMLAttributes<HTMLVideoElement> {
|
||||
src: string;
|
||||
fallback?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const AdminAuthenticatedVideo: React.FC<AdminAuthenticatedVideoProps> = ({
|
||||
src,
|
||||
fallback,
|
||||
...props
|
||||
}) => {
|
||||
const [videoSrc, setVideoSrc] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let objectUrl: string | null = null;
|
||||
|
||||
const loadVideo = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(false);
|
||||
setVideoSrc(null);
|
||||
|
||||
const response = await api.get(src, { responseType: 'blob' });
|
||||
|
||||
if (!cancelled) {
|
||||
objectUrl = URL.createObjectURL(response.data);
|
||||
setVideoSrc(objectUrl);
|
||||
setLoading(false);
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setError(true);
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (src) {
|
||||
loadVideo();
|
||||
}
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (objectUrl) {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
}
|
||||
};
|
||||
}, [src]);
|
||||
|
||||
if (loading) {
|
||||
return <div className="w-full h-full bg-neutral-200 animate-pulse" />;
|
||||
}
|
||||
|
||||
if (error || !videoSrc) {
|
||||
return fallback ? (
|
||||
<>{fallback}</>
|
||||
) : (
|
||||
<div className="w-full h-full bg-neutral-100 flex items-center justify-center text-neutral-400">
|
||||
<span className="text-xs">Failed to load</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<video
|
||||
src={videoSrc}
|
||||
controls
|
||||
preload="metadata"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Check, Download, Trash2, Eye, Package, MessageSquare, Star } from 'lucide-react';
|
||||
import { Check, Download, Trash2, Eye, Package, MessageSquare, Star, Video } from 'lucide-react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { AdminPhoto } from '../../services/photos.service';
|
||||
import { photosService } from '../../services/photos.service';
|
||||
@@ -20,6 +21,7 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
|
||||
onPhotoClick,
|
||||
onPhotosDeleted
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [selectedPhotos, setSelectedPhotos] = useState<Set<number>>(new Set());
|
||||
const [isSelectionMode, setIsSelectionMode] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
@@ -126,7 +128,7 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
|
||||
onClick={toggleSelectionMode}
|
||||
leftIcon={<Package className="w-4 h-4" />}
|
||||
>
|
||||
{isSelectionMode ? 'Cancel Selection' : 'Select Photos'}
|
||||
{isSelectionMode ? t('gallery.cancelSelection', 'Cancel Selection') : t('gallery.selectPhotos', 'Select Photos')}
|
||||
</Button>
|
||||
|
||||
{(isSelectionMode || selectedPhotos.size > 0) && (
|
||||
@@ -136,13 +138,13 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
|
||||
size="sm"
|
||||
onClick={handleSelectAll}
|
||||
>
|
||||
{selectedPhotos.size === photos.length ? 'Deselect All' : 'Select All'}
|
||||
{selectedPhotos.size === photos.length ? t('gallery.deselectAll', 'Deselect All') : t('gallery.selectAll', 'Select All')}
|
||||
</Button>
|
||||
|
||||
{selectedPhotos.size > 0 && (
|
||||
<>
|
||||
<span className="text-sm text-neutral-600">
|
||||
{selectedPhotos.size} selected
|
||||
{t('gallery.photosSelected', { count: selectedPhotos.size })}
|
||||
</span>
|
||||
<button
|
||||
onClick={handleDeleteSelected}
|
||||
@@ -150,7 +152,7 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
|
||||
className="px-3 py-1.5 text-sm font-medium text-white bg-red-600 hover:bg-red-700 disabled:bg-red-400 rounded-lg flex items-center gap-2"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
Delete Selected
|
||||
{t('gallery.deleteSelected', 'Delete Selected')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
@@ -159,7 +161,7 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-neutral-600">
|
||||
{photos.length} photo{photos.length !== 1 ? 's' : ''}
|
||||
{t('gallery.photosCount', { count: photos.length })}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -170,6 +172,9 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
|
||||
const commentCount = photo.comment_count ?? 0;
|
||||
const averageRating = photo.average_rating ?? 0;
|
||||
const likeCount = photo.like_count ?? 0;
|
||||
const isVideo = (photo.media_type === 'video') ||
|
||||
(photo.mime_type && photo.mime_type.startsWith('video/')) ||
|
||||
photo.type === 'video';
|
||||
return (
|
||||
<div
|
||||
key={photo.id}
|
||||
@@ -259,6 +264,15 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isVideo && (
|
||||
<div className="absolute bottom-2 left-2 pointer-events-none">
|
||||
<span className="px-2 py-1 text-[11px] font-semibold bg-black/70 text-white rounded flex items-center gap-1">
|
||||
<Video className="w-3 h-3" />
|
||||
{t('common.video', 'Video')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Feedback Indicators (moved to bottom-right to avoid covering category) */}
|
||||
{(commentCount > 0 || averageRating > 0 || likeCount > 0) && (
|
||||
@@ -284,7 +298,7 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
|
||||
|
||||
{photos.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-neutral-500">No photos uploaded yet</p>
|
||||
<p className="text-neutral-500">{t('gallery.noMedia', 'No media uploaded yet')}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { photosService } from '../../services/photos.service';
|
||||
import { feedbackService, type PhotoFeedback, type FeedbackSummary } from '../../services/feedback.service';
|
||||
import { Button } from '../common';
|
||||
import { AdminAuthenticatedImage } from './AdminAuthenticatedImage';
|
||||
import { AdminAuthenticatedVideo } from './AdminAuthenticatedVideo';
|
||||
|
||||
type AdminFeedbackResponse = {
|
||||
feedback: PhotoFeedback[];
|
||||
@@ -39,6 +40,11 @@ export const AdminPhotoViewer: React.FC<AdminPhotoViewerProps> = ({
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const currentPhoto = photos[currentIndex];
|
||||
const isVideo = currentPhoto
|
||||
? (currentPhoto.media_type === 'video' ||
|
||||
(currentPhoto.mime_type && String(currentPhoto.mime_type).startsWith('video/')) ||
|
||||
currentPhoto.type === 'video')
|
||||
: false;
|
||||
const averageRating = currentPhoto?.average_rating ?? 0;
|
||||
const likeCount = currentPhoto?.like_count ?? 0;
|
||||
const favoriteCount = currentPhoto?.favorite_count ?? 0;
|
||||
@@ -191,19 +197,35 @@ export const AdminPhotoViewer: React.FC<AdminPhotoViewerProps> = ({
|
||||
<div className="flex flex-col lg:flex-row gap-6 max-w-7xl mx-auto p-4 w-full h-full">
|
||||
{/* Image */}
|
||||
<div className="flex-1 flex items-center justify-center min-h-0">
|
||||
<AdminAuthenticatedImage
|
||||
src={currentPhoto.url}
|
||||
alt={currentPhoto.filename}
|
||||
className="max-w-full max-h-full object-contain"
|
||||
fallback={
|
||||
<div className="flex items-center justify-center text-neutral-400">
|
||||
<div className="text-center">
|
||||
<Eye className="w-12 h-12 mx-auto mb-2" />
|
||||
<p className="text-sm">Failed to load image</p>
|
||||
{isVideo ? (
|
||||
<AdminAuthenticatedVideo
|
||||
src={currentPhoto.url}
|
||||
className="max-w-full max-h-full bg-black"
|
||||
poster={currentPhoto.thumbnail_url || undefined}
|
||||
fallback={
|
||||
<div className="flex items-center justify-center text-neutral-400">
|
||||
<div className="text-center">
|
||||
<Eye className="w-12 h-12 mx-auto mb-2" />
|
||||
<p className="text-sm">Failed to load media</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<AdminAuthenticatedImage
|
||||
src={currentPhoto.url}
|
||||
alt={currentPhoto.filename}
|
||||
className="max-w-full max-h-full object-contain"
|
||||
fallback={
|
||||
<div className="flex items-center justify-center text-neutral-400">
|
||||
<div className="text-center">
|
||||
<Eye className="w-12 h-12 mx-auto mb-2" />
|
||||
<p className="text-sm">Failed to load image</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
import React from 'react';
|
||||
import { Search, Filter, SortAsc, SortDesc } from 'lucide-react';
|
||||
import { Input } from '../common';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface PhotoFiltersProps {
|
||||
categories: Array<{ id: number; name: string; slug: string }>;
|
||||
selectedCategory: number | null | undefined;
|
||||
categories: Array<{ id: number | string; name: string; slug: string }>;
|
||||
selectedCategory: number | string | null | undefined;
|
||||
searchTerm: string;
|
||||
sortBy: 'date' | 'name' | 'size' | 'rating';
|
||||
sortOrder: 'asc' | 'desc';
|
||||
onCategoryChange: (categoryId: number | null | undefined) => void;
|
||||
onCategoryChange: (categoryId: number | string | null | undefined) => void;
|
||||
onSearchChange: (search: string) => void;
|
||||
onSortChange: (sort: 'date' | 'name' | 'size' | 'rating', order: 'asc' | 'desc') => void;
|
||||
mediaType?: 'all' | 'photo' | 'video';
|
||||
onMediaTypeChange?: (mediaType: 'all' | 'photo' | 'video') => void;
|
||||
showMediaFilter?: boolean;
|
||||
}
|
||||
|
||||
export const PhotoFilters: React.FC<PhotoFiltersProps> = ({
|
||||
@@ -21,8 +25,12 @@ export const PhotoFilters: React.FC<PhotoFiltersProps> = ({
|
||||
sortOrder,
|
||||
onCategoryChange,
|
||||
onSearchChange,
|
||||
onSortChange
|
||||
onSortChange,
|
||||
mediaType = 'all',
|
||||
onMediaTypeChange,
|
||||
showMediaFilter = false
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const handleSortToggle = () => {
|
||||
onSortChange(sortBy, sortOrder === 'asc' ? 'desc' : 'asc');
|
||||
};
|
||||
@@ -34,7 +42,7 @@ export const PhotoFilters: React.FC<PhotoFiltersProps> = ({
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search by filename..."
|
||||
placeholder={t('gallery.searchByFilename', 'Search by filename...')}
|
||||
value={searchTerm}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
leftIcon={<Search className="w-5 h-5 text-neutral-400" />}
|
||||
@@ -46,11 +54,16 @@ export const PhotoFilters: React.FC<PhotoFiltersProps> = ({
|
||||
<Filter className="w-5 h-5 text-neutral-400" />
|
||||
<select
|
||||
value={selectedCategory === null ? '' : selectedCategory || ''}
|
||||
onChange={(e) => onCategoryChange(e.target.value === '' ? null : Number(e.target.value) || undefined)}
|
||||
onChange={(e) => {
|
||||
const raw = e.target.value;
|
||||
if (raw === '') return onCategoryChange(null);
|
||||
const numeric = Number(raw);
|
||||
onCategoryChange(Number.isNaN(numeric) ? raw : numeric);
|
||||
}}
|
||||
className="px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
>
|
||||
<option value="">All Categories</option>
|
||||
<option value="0">Uncategorized</option>
|
||||
<option value="">{t('gallery.allCategories', 'All Categories')}</option>
|
||||
<option value="0">{t('gallery.uncategorized', 'Uncategorized')}</option>
|
||||
{categories.map(cat => (
|
||||
<option key={cat.id} value={cat.id}>
|
||||
{cat.name}
|
||||
@@ -59,6 +72,21 @@ export const PhotoFilters: React.FC<PhotoFiltersProps> = ({
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{showMediaFilter && onMediaTypeChange && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Filter className="w-5 h-5 text-neutral-400" />
|
||||
<select
|
||||
value={mediaType}
|
||||
onChange={(e) => onMediaTypeChange(e.target.value as 'all' | 'photo' | 'video')}
|
||||
className="px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
>
|
||||
<option value="all">{t('gallery.allMedia', 'All media')}</option>
|
||||
<option value="photo">{t('gallery.photosOnly', 'Photos only')}</option>
|
||||
<option value="video">{t('gallery.videosOnly', 'Videos only')}</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sort Options */}
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
@@ -66,16 +94,16 @@ export const PhotoFilters: React.FC<PhotoFiltersProps> = ({
|
||||
onChange={(e) => onSortChange(e.target.value as 'date' | 'name' | 'size' | 'rating', sortOrder)}
|
||||
className="px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
>
|
||||
<option value="date">Sort by Date</option>
|
||||
<option value="name">Sort by Name</option>
|
||||
<option value="size">Sort by Size</option>
|
||||
<option value="rating">Sort by Rating</option>
|
||||
<option value="date">{t('gallery.sortByDate', 'Sort by Date')}</option>
|
||||
<option value="name">{t('gallery.sortByName', 'Sort by Name')}</option>
|
||||
<option value="size">{t('gallery.sortBySize', 'Sort by Size')}</option>
|
||||
<option value="rating">{t('gallery.sortByRating', 'Sort by Rating')}</option>
|
||||
</select>
|
||||
|
||||
<button
|
||||
onClick={handleSortToggle}
|
||||
className="p-2 border border-neutral-300 rounded-lg hover:bg-neutral-50 transition-colors"
|
||||
aria-label={sortOrder === 'asc' ? 'Sort descending' : 'Sort ascending'}
|
||||
aria-label={sortOrder === 'asc' ? t('gallery.sortDescending', 'Sort descending') : t('gallery.sortAscending', 'Sort ascending')}
|
||||
>
|
||||
{sortOrder === 'asc' ? (
|
||||
<SortAsc className="w-5 h-5 text-neutral-600" />
|
||||
@@ -87,4 +115,4 @@ export const PhotoFilters: React.FC<PhotoFiltersProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { Upload, X, Image, Loader2 } from 'lucide-react';
|
||||
import { Upload, X, Image, Loader2, Video } from 'lucide-react';
|
||||
import { Button } from '../common';
|
||||
import { clsx } from 'clsx';
|
||||
import { api } from '../../config/api';
|
||||
@@ -51,12 +51,18 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
|
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(e.target.files || []);
|
||||
const imageFiles = files.filter(file =>
|
||||
['image/jpeg', 'image/png', 'image/webp'].includes(file.type)
|
||||
);
|
||||
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp', 'video/mp4', 'video/quicktime', 'video/webm'];
|
||||
const allowedFiles = files.filter(file => allowedTypes.includes(file.type));
|
||||
const rejectedFiles = files.filter(file => !allowedTypes.includes(file.type));
|
||||
|
||||
if (rejectedFiles.length > 0) {
|
||||
toast.error(
|
||||
t('upload.unsupportedFiles', 'Some files were skipped because the format is not supported (use JPEG/PNG/WebP/MP4/MOV/WEBM).')
|
||||
);
|
||||
}
|
||||
|
||||
// Check total file count with existing files
|
||||
const totalFiles = selectedFiles.length + imageFiles.length;
|
||||
const totalFiles = selectedFiles.length + allowedFiles.length;
|
||||
if (totalFiles > maxFilesPerUpload) {
|
||||
const allowedNewFiles = maxFilesPerUpload - selectedFiles.length;
|
||||
if (allowedNewFiles <= 0) {
|
||||
@@ -70,11 +76,11 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
|
||||
t('upload.someFilesSkipped', { allowed: allowedNewFiles, limit: maxFilesPerUpload }) ||
|
||||
`Only ${allowedNewFiles} more files can be added (limit ${maxFilesPerUpload})`
|
||||
);
|
||||
setSelectedFiles(prev => [...prev, ...imageFiles.slice(0, allowedNewFiles)]);
|
||||
setSelectedFiles(prev => [...prev, ...allowedFiles.slice(0, allowedNewFiles)]);
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedFiles(prev => [...prev, ...imageFiles]);
|
||||
setSelectedFiles(prev => [...prev, ...allowedFiles]);
|
||||
};
|
||||
|
||||
const removeFile = (index: number) => {
|
||||
@@ -186,7 +192,7 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
|
||||
{/* Category Selection */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
||||
{t('upload.photoCategory')}
|
||||
{t('upload.mediaCategory', 'Media category')}
|
||||
</label>
|
||||
<select
|
||||
value={selectedCategoryId || ''}
|
||||
@@ -216,7 +222,7 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
|
||||
{t('upload.clickToUpload')}
|
||||
</p>
|
||||
<p className="text-sm text-neutral-500">
|
||||
{t('upload.fileRequirements', { limit: maxFilesPerUpload })}
|
||||
{t('upload.fileRequirementsMedia', { limit: maxFilesPerUpload }) || t('upload.fileRequirements', { limit: maxFilesPerUpload })}
|
||||
</p>
|
||||
<p
|
||||
className={clsx(
|
||||
@@ -236,7 +242,7 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
accept="image/jpeg,image/png,image/webp,video/mp4,video/quicktime,video/webm"
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
/>
|
||||
@@ -255,7 +261,11 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
|
||||
className="flex items-center justify-between p-2 bg-neutral-50 rounded-lg"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Image className="w-5 h-5 text-neutral-400" />
|
||||
{file.type.startsWith('video/') ? (
|
||||
<Video className="w-5 h-5 text-neutral-400" />
|
||||
) : (
|
||||
<Image className="w-5 h-5 text-neutral-400" />
|
||||
)}
|
||||
<div>
|
||||
<p className="text-sm font-medium text-neutral-700 truncate max-w-xs">
|
||||
{file.name}
|
||||
@@ -288,7 +298,9 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
|
||||
disabled={selectedFiles.length === 0 || isUploading}
|
||||
leftIcon={isUploading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Upload className="w-4 h-4" />}
|
||||
>
|
||||
{isUploading ? t('upload.uploading') : t('common.upload') + ` ${selectedFiles.length} ${t(selectedFiles.length === 1 ? 'common.photo' : 'common.photos')}`}
|
||||
{isUploading
|
||||
? t('upload.uploading')
|
||||
: t('upload.uploadAction', { count: selectedFiles.length }) || `Upload ${selectedFiles.length} files`}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ export const PhotoUploadModal: React.FC<PhotoUploadModalProps> = ({
|
||||
<div className="bg-white rounded-lg shadow-xl w-full max-w-2xl flex flex-col max-h-[90vh]">
|
||||
{/* Fixed Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-neutral-200">
|
||||
<h2 className="text-xl font-semibold text-neutral-900">{t('events.uploadPhotos')}</h2>
|
||||
<h2 className="text-xl font-semibold text-neutral-900">{t('upload.uploadMedia', t('events.uploadPhotos'))}</h2>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -56,4 +56,4 @@ export const PhotoUploadModal: React.FC<PhotoUploadModalProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
PhotoUploadModal.displayName = 'PhotoUploadModal';
|
||||
PhotoUploadModal.displayName = 'PhotoUploadModal';
|
||||
|
||||
@@ -17,6 +17,7 @@ export { AdminPhotoViewer } from './AdminPhotoViewer';
|
||||
export { PhotoFilters } from './PhotoFilters';
|
||||
export { PasswordResetModal } from './PasswordResetModal';
|
||||
export { AdminAuthenticatedImage } from './AdminAuthenticatedImage';
|
||||
export { AdminAuthenticatedVideo } from './AdminAuthenticatedVideo';
|
||||
export { ThemeCustomizerEnhanced } from './ThemeCustomizerEnhanced';
|
||||
export { ThemeDisplay } from './ThemeDisplay';
|
||||
export { ThemeEditorModal } from './ThemeEditorModal';
|
||||
@@ -29,4 +30,4 @@ export { BackupHistory } from './BackupHistory';
|
||||
export { RestoreWizard } from './RestoreWizard';
|
||||
export { FeedbackSettings } from './FeedbackSettings';
|
||||
export { FeedbackModerationPanel } from './FeedbackModerationPanel';
|
||||
export { WordFilterManager } from './WordFilterManager';
|
||||
export { WordFilterManager } from './WordFilterManager';
|
||||
|
||||
Reference in New Issue
Block a user