Refactor: Implement Data & Export tab modernization

This commit is contained in:
gpt-engineer-app[bot]
2025-10-14 20:08:43 +00:00
parent 53d8a130f5
commit 2eec20f653
4 changed files with 953 additions and 139 deletions

View File

@@ -1,216 +1,485 @@
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Progress } from '@/components/ui/progress';
import { Separator } from '@/components/ui/separator';
import { Badge } from '@/components/ui/badge';
import { useToast } from '@/hooks/use-toast';
import { Skeleton } from '@/components/ui/skeleton';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
import { useAuth } from '@/hooks/useAuth';
import { useProfile } from '@/hooks/useProfile';
import { supabase } from '@/integrations/supabase/client';
import { Download, BarChart3, Upload, FileText, History } from 'lucide-react';
import { handleError, handleSuccess, AppError } from '@/lib/errorHandler';
import { logger } from '@/lib/logger';
import { Download, Activity, BarChart3, AlertCircle, Clock } from 'lucide-react';
import type {
UserStatistics,
ActivityLogEntry,
ExportOptions,
ExportRequestResult
} from '@/types/data-export';
import {
exportOptionsSchema,
DEFAULT_EXPORT_OPTIONS
} from '@/lib/dataExportValidation';
export function DataExportTab() {
const { user } = useAuth();
const { data: profile } = useProfile(user?.id);
const {
toast
} = useToast();
const [exportLoading, setExportLoading] = useState(false);
const [exportProgress, setExportProgress] = useState(0);
const handleDataExport = async () => {
if (!user) return;
setExportLoading(true);
setExportProgress(0);
const [loading, setLoading] = useState(true);
const [exporting, setExporting] = useState(false);
const [statistics, setStatistics] = useState<UserStatistics | null>(null);
const [recentActivity, setRecentActivity] = useState<ActivityLogEntry[]>([]);
const [exportOptions, setExportOptions] = useState<ExportOptions>(DEFAULT_EXPORT_OPTIONS);
const [rateLimited, setRateLimited] = useState(false);
const [nextAvailableAt, setNextAvailableAt] = useState<string | null>(null);
useEffect(() => {
if (user && profile) {
loadStatistics();
loadRecentActivity();
}
}, [user, profile]);
const loadStatistics = async () => {
if (!user || !profile) return;
try {
// Simulate export progress
const steps = ['Collecting profile data...', 'Gathering reviews...', 'Collecting ride credits...', 'Gathering top lists...', 'Packaging data...', 'Preparing download...'];
for (let i = 0; i < steps.length; i++) {
await new Promise(resolve => setTimeout(resolve, 500));
setExportProgress((i + 1) / steps.length * 100);
}
// Fetch additional counts
const { count: photoCount } = await supabase
.from('photos')
.select('*', { count: 'exact', head: true })
.eq('submitted_by', user.id);
// In a real implementation, this would:
// 1. Fetch all user data from various tables
// 2. Package it into a structured format (JSON/CSV)
// 3. Create a downloadable file
// Note: user_lists table needs to be created for this to work
// For now, setting to 0 as placeholder
const listCount = 0;
const exportData = {
profile: profile,
export_date: new Date().toISOString(),
data_types: ['profile', 'reviews', 'ride_credits', 'top_lists']
const { count: submissionCount } = await supabase
.from('content_submissions')
.select('*', { count: 'exact', head: true })
.eq('user_id', user.id);
const stats: UserStatistics = {
ride_count: profile.ride_count || 0,
coaster_count: profile.coaster_count || 0,
park_count: profile.park_count || 0,
review_count: profile.review_count || 0,
reputation_score: profile.reputation_score || 0,
photo_count: photoCount || 0,
list_count: listCount || 0,
submission_count: submissionCount || 0,
account_created: profile.created_at,
last_updated: profile.updated_at
};
const blob = new Blob([JSON.stringify(exportData, null, 2)], {
type: 'application/json'
setStatistics(stats);
logger.info('User statistics loaded', {
userId: user.id,
action: 'load_statistics'
});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `thrillwiki-data-export-${new Date().toISOString().split('T')[0]}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast({
title: 'Export complete',
description: 'Your data has been exported and downloaded successfully.'
} catch (error) {
logger.error('Failed to load statistics', {
userId: user.id,
action: 'load_statistics',
error: error instanceof Error ? error.message : String(error)
});
} catch (error: any) {
toast({
title: 'Export failed',
description: error.message || 'Failed to export your data',
variant: 'destructive'
handleError(error, {
action: 'Load statistics',
userId: user.id
});
} finally {
setExportLoading(false);
setExportProgress(0);
}
};
const handleImportData = () => {
toast({
title: 'Coming soon',
description: 'Data import functionality will be available in a future update.'
const loadRecentActivity = async () => {
if (!user) return;
try {
const { data, error } = await supabase
.from('profile_audit_log')
.select('id, action, changes, created_at, changed_by, ip_address_hash, user_agent')
.eq('user_id', user.id)
.order('created_at', { ascending: false })
.limit(10);
if (error) {
logger.error('Failed to load activity log', {
userId: user.id,
action: 'load_activity_log',
error: error.message,
errorCode: error.code
});
throw error;
}
// Transform the data to match our type
const activityData: ActivityLogEntry[] = (data || []).map(item => ({
id: item.id,
action: item.action,
changes: item.changes as Record<string, any>,
created_at: item.created_at,
changed_by: item.changed_by,
ip_address_hash: item.ip_address_hash || undefined,
user_agent: item.user_agent || undefined
}));
setRecentActivity(activityData);
logger.info('Activity log loaded', {
userId: user.id,
action: 'load_activity_log',
count: activityData.length
});
} catch (error) {
logger.error('Error loading activity log', {
userId: user.id,
action: 'load_activity_log',
error: error instanceof Error ? error.message : String(error)
});
handleError(error, {
action: 'Load activity log',
userId: user.id
});
} finally {
setLoading(false);
}
};
const handleDataExport = async () => {
if (!user) return;
setExporting(true);
try {
// Validate export options
const validatedOptions = exportOptionsSchema.parse(exportOptions);
logger.info('Starting data export', {
userId: user.id,
action: 'export_data',
options: validatedOptions
});
// Call edge function for secure export
const { data, error } = await supabase.functions.invoke<ExportRequestResult>(
'export-user-data',
{
body: validatedOptions
}
);
if (error) {
logger.error('Edge function invocation failed', {
userId: user.id,
action: 'export_data',
error: error.message
});
throw error;
}
if (!data?.success) {
if (data?.rate_limited) {
setRateLimited(true);
setNextAvailableAt(data.next_available_at || null);
logger.warn('Export rate limited', {
userId: user.id,
action: 'export_data',
nextAvailableAt: data.next_available_at
});
handleError(
new AppError(
'Rate limited',
'RATE_LIMIT_EXCEEDED',
data.error || 'You can export your data once per hour. Please try again later.'
),
{ action: 'Export data', userId: user.id }
);
return;
}
throw new Error(data?.error || 'Export failed');
}
// Download the data as JSON
const blob = new Blob([JSON.stringify(data.data, null, 2)], {
type: 'application/json'
});
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `thrillwiki-data-export-${new Date().toISOString().split('T')[0]}.json`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
logger.info('Data export completed', {
userId: user.id,
action: 'export_data',
dataSize: JSON.stringify(data.data).length
});
handleSuccess(
'Data exported successfully',
'Your data has been downloaded as a JSON file.'
);
// Refresh activity log to show the export action
await loadRecentActivity();
} catch (error) {
logger.error('Data export failed', {
userId: user.id,
action: 'export_data',
error: error instanceof Error ? error.message : String(error)
});
handleError(error, {
action: 'Export data',
userId: user.id
});
} finally {
setExporting(false);
}
};
const formatActionName = (action: string): string => {
return action
.split('_')
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
};
const formatDate = (dateString: string): string => {
return new Date(dateString).toLocaleString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
};
return <div className="space-y-8">
if (loading) {
return (
<div className="space-y-8">
<Card>
<CardHeader>
<Skeleton className="h-6 w-48" />
<Skeleton className="h-4 w-full max-w-md" />
</CardHeader>
<CardContent className="space-y-4">
<Skeleton className="h-12 w-full" />
<Skeleton className="h-12 w-full" />
</CardContent>
</Card>
</div>
);
}
return (
<div className="space-y-8">
{/* Personal Statistics */}
<div className="space-y-4">
<div className="flex items-center gap-2">
<BarChart3 className="w-5 h-5" />
<h3 className="text-lg font-medium">Personal Statistics</h3>
</div>
<Card>
<CardHeader>
<CardDescription>
Overview of your activity and contributions to ThrillWiku.
Your activity and contribution statistics on ThrillWiki
</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="text-center p-4 bg-muted/50 rounded-lg">
<div className="text-2xl font-bold text-primary">
{profile?.review_count || 0}
{statistics && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="space-y-1">
<p className="text-sm text-muted-foreground">Reviews</p>
<p className="text-2xl font-bold">{statistics.review_count}</p>
</div>
<div className="text-sm text-muted-foreground">Reviews</div>
</div>
<div className="text-center p-4 bg-muted/50 rounded-lg">
<div className="text-2xl font-bold text-primary">
{profile?.ride_count || 0}
<div className="space-y-1">
<p className="text-sm text-muted-foreground">Rides Tracked</p>
<p className="text-2xl font-bold">{statistics.ride_count}</p>
</div>
<div className="text-sm text-muted-foreground">Rides</div>
</div>
<div className="text-center p-4 bg-muted/50 rounded-lg">
<div className="text-2xl font-bold text-primary">
{profile?.coaster_count || 0}
<div className="space-y-1">
<p className="text-sm text-muted-foreground">Coasters</p>
<p className="text-2xl font-bold">{statistics.coaster_count}</p>
</div>
<div className="text-sm text-muted-foreground">Coasters</div>
</div>
<div className="text-center p-4 bg-muted/50 rounded-lg">
<div className="text-2xl font-bold text-primary">
{profile?.park_count || 0}
<div className="space-y-1">
<p className="text-sm text-muted-foreground">Parks Visited</p>
<p className="text-2xl font-bold">{statistics.park_count}</p>
</div>
<div className="space-y-1">
<p className="text-sm text-muted-foreground">Photos</p>
<p className="text-2xl font-bold">{statistics.photo_count}</p>
</div>
<div className="space-y-1">
<p className="text-sm text-muted-foreground">Lists</p>
<p className="text-2xl font-bold">{statistics.list_count}</p>
</div>
<div className="space-y-1">
<p className="text-sm text-muted-foreground">Submissions</p>
<p className="text-2xl font-bold">{statistics.submission_count}</p>
</div>
<div className="space-y-1">
<p className="text-sm text-muted-foreground">Reputation</p>
<p className="text-2xl font-bold">{statistics.reputation_score}</p>
</div>
<div className="text-sm text-muted-foreground">Parks</div>
</div>
</div>
)}
</CardContent>
</Card>
</div>
<Separator />
{/* Data Export */}
{/* Export Your Data */}
<div className="space-y-4">
<div className="flex items-center gap-2">
<Download className="w-5 h-5" />
<h3 className="text-lg font-medium">Export Your Data</h3>
</div>
<Card>
<CardHeader>
<CardTitle>Download Your Data</CardTitle>
<CardDescription>
Export all your personal data in a machine-readable format. This includes your profile,
reviews, ride credits, top lists, and account activity.
Export all your ThrillWiki data in JSON format. This includes your profile, reviews, lists, and activity history.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex flex-wrap gap-2">
<Badge variant="secondary">Profile Information</Badge>
<Badge variant="secondary">Reviews & Ratings</Badge>
<Badge variant="secondary">Ride Credits</Badge>
<Badge variant="secondary">Top Lists</Badge>
<Badge variant="secondary">Account Activity</Badge>
</div>
{exportLoading && <div className="space-y-2">
<div className="flex justify-between text-sm">
<span>Exporting data...</span>
<span>{Math.round(exportProgress)}%</span>
<CardContent className="space-y-6">
{rateLimited && nextAvailableAt && (
<div className="flex items-start gap-3 p-4 border border-yellow-500/20 bg-yellow-500/10 rounded-lg">
<Clock className="w-5 h-5 text-yellow-500 mt-0.5" />
<div className="space-y-1">
<p className="text-sm font-medium">Rate Limited</p>
<p className="text-sm text-muted-foreground">
You can export your data once per hour. Next export available at{' '}
{formatDate(nextAvailableAt)}.
</p>
</div>
<Progress value={exportProgress} />
</div>}
<Button onClick={handleDataExport} disabled={exportLoading} className="w-fit">
</div>
)}
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
Choose what to include in your export:
</p>
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label htmlFor="include_reviews">Include Reviews</Label>
<Switch
id="include_reviews"
checked={exportOptions.include_reviews}
onCheckedChange={(checked) =>
setExportOptions({ ...exportOptions, include_reviews: checked })
}
/>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="include_lists">Include Lists</Label>
<Switch
id="include_lists"
checked={exportOptions.include_lists}
onCheckedChange={(checked) =>
setExportOptions({ ...exportOptions, include_lists: checked })
}
/>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="include_activity_log">Include Activity Log</Label>
<Switch
id="include_activity_log"
checked={exportOptions.include_activity_log}
onCheckedChange={(checked) =>
setExportOptions({ ...exportOptions, include_activity_log: checked })
}
/>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="include_preferences">Include Preferences</Label>
<Switch
id="include_preferences"
checked={exportOptions.include_preferences}
onCheckedChange={(checked) =>
setExportOptions({ ...exportOptions, include_preferences: checked })
}
/>
</div>
</div>
</div>
<div className="flex items-start gap-3 p-4 border rounded-lg">
<AlertCircle className="w-5 h-5 text-blue-500 mt-0.5" />
<div className="space-y-1">
<p className="text-sm font-medium">GDPR Compliance</p>
<p className="text-sm text-muted-foreground">
This export includes all personal data we store about you. You can use this for backup purposes or to migrate to another service.
</p>
</div>
</div>
<Button
onClick={handleDataExport}
disabled={exporting || rateLimited}
className="w-full"
>
<Download className="w-4 h-4 mr-2" />
{exportLoading ? 'Exporting...' : 'Export My Data'}
{exporting ? 'Exporting Data...' : 'Export My Data'}
</Button>
<p className="text-sm text-muted-foreground">
Your data will be provided in JSON format. Processing may take a few moments
for accounts with lots of activity.
</p>
</CardContent>
</Card>
</div>
{/* Data Import */}
<Separator />
{/* Account Activity */}
<div className="space-y-4">
<div className="flex items-center gap-2">
<History className="w-5 h-5" />
<Activity className="w-5 h-5" />
<h3 className="text-lg font-medium">Account Activity</h3>
</div>
<Card>
<CardHeader>
<CardTitle>Recent Activity Log</CardTitle>
<CardDescription>
Your recent account activities and important events.
Recent account activity and changes
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3">
<div className="flex items-center gap-3 p-3 border rounded-lg">
<div className="w-2 h-2 bg-primary rounded-full"></div>
<div className="flex-1">
<p className="text-sm font-medium">Profile updated</p>
<p className="text-xs text-muted-foreground">
{profile?.updated_at ? new Date(profile.updated_at).toLocaleString() : 'Recently'}
</p>
</div>
{recentActivity.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-4">
No recent activity to display
</p>
) : (
<div className="space-y-4">
{recentActivity.map((activity) => (
<div key={activity.id} className="flex items-start gap-3 pb-4 border-b last:border-0 last:pb-0">
<Activity className="w-4 h-4 text-muted-foreground mt-1" />
<div className="flex-1 space-y-1">
<p className="text-sm font-medium">
{formatActionName(activity.action)}
</p>
<p className="text-xs text-muted-foreground">
{formatDate(activity.created_at)}
</p>
</div>
</div>
))}
</div>
<div className="flex items-center gap-3 p-3 border rounded-lg">
<div className="w-2 h-2 bg-muted rounded-full"></div>
<div className="flex-1">
<p className="text-sm font-medium">Account created</p>
<p className="text-xs text-muted-foreground">
{profile?.created_at ? new Date(profile.created_at).toLocaleString() : 'N/A'}
</p>
</div>
</div>
</div>
)}
</CardContent>
</Card>
</div>
</div>;
}
</div>
);
}