Replace all mock data with real backend integration
- Add database tables for email configs, settings, and activity logs - Create backend endpoints for dashboard stats, analytics, archives, email config, and settings - Create frontend service layer (admin, archive, email, settings services) - Update AdminDashboard to use real statistics and activity data - Update AnalyticsPage to fetch real analytics from backend - Update ArchivesPage with pagination and real archive operations - Update EmailConfigPage to manage real SMTP config and templates - Remove all mock data and replace with API calls throughout admin interface 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <[email protected]>
This commit is contained in:
@@ -9,120 +9,108 @@ import {
|
||||
AlertCircle,
|
||||
RotateCcw,
|
||||
Trash2,
|
||||
Eye
|
||||
Eye,
|
||||
ChevronLeft,
|
||||
ChevronRight
|
||||
} from 'lucide-react';
|
||||
import { format, parseISO } from 'date-fns';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
import { Button, Input, Card, Loading } from '../../components/common';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
interface ArchivedEvent {
|
||||
id: number;
|
||||
event_name: string;
|
||||
event_type: string;
|
||||
event_date: string;
|
||||
archived_at: string;
|
||||
archive_path: string;
|
||||
archive_size: number;
|
||||
photo_count: number;
|
||||
original_expiry: string;
|
||||
}
|
||||
|
||||
// Mock data - in real app this would come from API
|
||||
const mockArchives: ArchivedEvent[] = [
|
||||
{
|
||||
id: 1,
|
||||
event_name: 'Smith-Jones Wedding',
|
||||
event_type: 'wedding',
|
||||
event_date: '2024-06-15',
|
||||
archived_at: '2024-07-15T10:30:00Z',
|
||||
archive_path: '/archives/wedding-smith-jones-2024-06-15.zip',
|
||||
archive_size: 2147483648, // 2GB in bytes
|
||||
photo_count: 342,
|
||||
original_expiry: '2024-07-15'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
event_name: 'Birthday Emma 2024',
|
||||
event_type: 'birthday',
|
||||
event_date: '2024-05-20',
|
||||
archived_at: '2024-06-20T14:15:00Z',
|
||||
archive_path: '/archives/birthday-emma-2024-05-20.zip',
|
||||
archive_size: 536870912, // 512MB in bytes
|
||||
photo_count: 127,
|
||||
original_expiry: '2024-06-20'
|
||||
}
|
||||
];
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { archiveService } from '../../services/archive.service';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
export const ArchivesPage: React.FC = () => {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [filterType, setFilterType] = useState<string>('all');
|
||||
const [sortBy, setSortBy] = useState<'date' | 'size' | 'name'>('date');
|
||||
// const [selectedArchive, setSelectedArchive] = useState<number | null>(null);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// In real app, this would fetch archived events
|
||||
const { data: archives = mockArchives, isLoading } = useQuery({
|
||||
queryKey: ['admin-archives'],
|
||||
queryFn: async () => {
|
||||
// Simulate API call
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
return mockArchives;
|
||||
},
|
||||
// Fetch archives from API
|
||||
const { data: archivesData, isLoading } = useQuery({
|
||||
queryKey: ['admin-archives', currentPage],
|
||||
queryFn: () => archiveService.getArchives(currentPage, 20),
|
||||
});
|
||||
|
||||
const archives = archivesData?.archives || [];
|
||||
|
||||
const filteredArchives = archives.filter(archive => {
|
||||
if (filterType !== 'all' && archive.event_type !== filterType) {
|
||||
if (filterType !== 'all' && archive.eventType !== filterType) {
|
||||
return false;
|
||||
}
|
||||
if (searchTerm) {
|
||||
const term = searchTerm.toLowerCase();
|
||||
return archive.event_name.toLowerCase().includes(term);
|
||||
return archive.eventName.toLowerCase().includes(term);
|
||||
}
|
||||
return true;
|
||||
}).sort((a, b) => {
|
||||
switch (sortBy) {
|
||||
case 'name':
|
||||
return a.event_name.localeCompare(b.event_name);
|
||||
return a.eventName.localeCompare(b.eventName);
|
||||
case 'size':
|
||||
return b.archive_size - a.archive_size;
|
||||
return b.archiveSize - a.archiveSize;
|
||||
case 'date':
|
||||
default:
|
||||
return new Date(b.archived_at).getTime() - new Date(a.archived_at).getTime();
|
||||
return new Date(b.archivedAt).getTime() - new Date(a.archivedAt).getTime();
|
||||
}
|
||||
});
|
||||
|
||||
const formatFileSize = (bytes: number): string => {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
const getTotalSize = () => {
|
||||
return archives.reduce((sum, archive) => sum + archive.archive_size, 0);
|
||||
return archives.reduce((sum, archive) => sum + archive.archiveSize, 0);
|
||||
};
|
||||
|
||||
const handleDownload = (archive: ArchivedEvent) => {
|
||||
toast.info(`Downloading ${archive.event_name} archive...`);
|
||||
// In real app, this would trigger download
|
||||
};
|
||||
|
||||
const handleRestore = (archive: ArchivedEvent) => {
|
||||
if (confirm(`Are you sure you want to restore "${archive.event_name}"? This will make the gallery accessible again.`)) {
|
||||
// Mutations
|
||||
const restoreMutation = useMutation({
|
||||
mutationFn: (id: number) => archiveService.restoreArchive(id),
|
||||
onSuccess: () => {
|
||||
toast.success('Archive restored successfully');
|
||||
// In real app, this would restore the archive
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-archives'] });
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Failed to restore archive');
|
||||
}
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: number) => archiveService.deleteArchive(id),
|
||||
onSuccess: () => {
|
||||
toast.success('Archive deleted permanently');
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-archives'] });
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Failed to delete archive');
|
||||
}
|
||||
});
|
||||
|
||||
const handleDownload = async (archive: typeof archives[0]) => {
|
||||
try {
|
||||
toast.info(`Downloading ${archive.eventName} archive...`);
|
||||
await archiveService.downloadArchive(archive.id, `${archive.slug}-archive.zip`);
|
||||
toast.success('Download started');
|
||||
} catch (error) {
|
||||
toast.error('Failed to download archive');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = (archive: ArchivedEvent) => {
|
||||
if (confirm(`Are you sure you want to permanently delete the archive for "${archive.event_name}"? This action cannot be undone.`)) {
|
||||
toast.success('Archive deleted successfully');
|
||||
// In real app, this would delete the archive
|
||||
const handleRestore = (archive: typeof archives[0]) => {
|
||||
if (confirm(`Are you sure you want to restore "${archive.eventName}"? This will make the gallery accessible again.`)) {
|
||||
restoreMutation.mutate(archive.id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = (archive: typeof archives[0]) => {
|
||||
if (confirm(`Are you sure you want to permanently delete the archive for "${archive.eventName}"? This action cannot be undone.`)) {
|
||||
deleteMutation.mutate(archive.id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleViewDetails = (archive: typeof archives[0]) => {
|
||||
navigate(`/admin/archives/${archive.id}`);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
@@ -155,7 +143,7 @@ export const ArchivesPage: React.FC = () => {
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-neutral-600">Storage Used</p>
|
||||
<p className="text-2xl font-bold text-neutral-900">{formatFileSize(getTotalSize())}</p>
|
||||
<p className="text-2xl font-bold text-neutral-900">{archiveService.formatBytes(getTotalSize())}</p>
|
||||
</div>
|
||||
<HardDrive className="w-8 h-8 text-blue-600" />
|
||||
</div>
|
||||
@@ -166,7 +154,7 @@ export const ArchivesPage: React.FC = () => {
|
||||
<div>
|
||||
<p className="text-sm text-neutral-600">Total Photos</p>
|
||||
<p className="text-2xl font-bold text-neutral-900">
|
||||
{archives.reduce((sum, a) => sum + a.photo_count, 0).toLocaleString()}
|
||||
{archives.reduce((sum, a) => sum + a.photoCount, 0).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<FileArchive className="w-8 h-8 text-green-600" />
|
||||
@@ -179,7 +167,7 @@ export const ArchivesPage: React.FC = () => {
|
||||
<p className="text-sm text-neutral-600">Avg Archive Size</p>
|
||||
<p className="text-2xl font-bold text-neutral-900">
|
||||
{archives.length > 0
|
||||
? formatFileSize(getTotalSize() / archives.length)
|
||||
? archiveService.formatBytes(getTotalSize() / archives.length)
|
||||
: '0 Bytes'
|
||||
}
|
||||
</p>
|
||||
@@ -267,35 +255,35 @@ export const ArchivesPage: React.FC = () => {
|
||||
<tr key={archive.id} className="hover:bg-neutral-50">
|
||||
<td className="px-6 py-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-neutral-900">{archive.event_name}</p>
|
||||
<p className="text-sm font-medium text-neutral-900">{archive.eventName}</p>
|
||||
<p className="text-xs text-neutral-500">
|
||||
Event date: {format(parseISO(archive.event_date), 'MMM d, yyyy')}
|
||||
Event date: {format(parseISO(archive.eventDate), 'MMM d, yyyy')}
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-neutral-700 capitalize">
|
||||
{archive.event_type}
|
||||
{archive.eventType}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-neutral-700">
|
||||
<div>
|
||||
<p>{format(parseISO(archive.archived_at), 'MMM d, yyyy')}</p>
|
||||
<p>{format(parseISO(archive.archivedAt), 'MMM d, yyyy')}</p>
|
||||
<p className="text-xs text-neutral-500">
|
||||
{format(parseISO(archive.archived_at), 'h:mm a')}
|
||||
{format(parseISO(archive.archivedAt), 'h:mm a')}
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-neutral-700">
|
||||
{formatFileSize(archive.archive_size)}
|
||||
{archiveService.formatBytes(archive.archiveSize)}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-neutral-700">
|
||||
{archive.photo_count}
|
||||
{archive.photoCount}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => toast.info('Archive details coming soon')}
|
||||
onClick={() => handleViewDetails(archive)}
|
||||
leftIcon={<Eye className="w-4 h-4" />}
|
||||
>
|
||||
Details
|
||||
@@ -305,6 +293,7 @@ export const ArchivesPage: React.FC = () => {
|
||||
size="sm"
|
||||
onClick={() => handleDownload(archive)}
|
||||
leftIcon={<Download className="w-4 h-4" />}
|
||||
disabled={!archive.archivePath}
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
@@ -313,6 +302,7 @@ export const ArchivesPage: React.FC = () => {
|
||||
size="sm"
|
||||
onClick={() => handleRestore(archive)}
|
||||
leftIcon={<RotateCcw className="w-4 h-4" />}
|
||||
disabled={restoreMutation.isPending}
|
||||
>
|
||||
Restore
|
||||
</Button>
|
||||
@@ -322,6 +312,7 @@ export const ArchivesPage: React.FC = () => {
|
||||
onClick={() => handleDelete(archive)}
|
||||
leftIcon={<Trash2 className="w-4 h-4" />}
|
||||
className="text-red-600 hover:text-red-700"
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
@@ -335,6 +326,40 @@ export const ArchivesPage: React.FC = () => {
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Pagination */}
|
||||
{archivesData?.pagination && archivesData.pagination.totalPages > 1 && (
|
||||
<div className="mt-6 flex items-center justify-between">
|
||||
<div className="text-sm text-neutral-600">
|
||||
Showing {((currentPage - 1) * archivesData.pagination.limit) + 1} to{' '}
|
||||
{Math.min(currentPage * archivesData.pagination.limit, archivesData.pagination.total)} of{' '}
|
||||
{archivesData.pagination.total} archives
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage(prev => Math.max(1, prev - 1))}
|
||||
disabled={currentPage === 1}
|
||||
leftIcon={<ChevronLeft className="w-4 h-4" />}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<span className="px-3 text-sm">
|
||||
Page {currentPage} of {archivesData.pagination.totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage(prev => Math.min(archivesData.pagination.totalPages, prev + 1))}
|
||||
disabled={currentPage === archivesData.pagination.totalPages}
|
||||
rightIcon={<ChevronRight className="w-4 h-4" />}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Storage Warning */}
|
||||
<div className="mt-6 p-4 bg-amber-50 border border-amber-200 rounded-lg">
|
||||
<div className="flex items-start gap-3">
|
||||
|
||||
Reference in New Issue
Block a user