|
|
@@ -0,0 +1,224 @@
|
|
|
+import { useState, useEffect } from 'react';
|
|
|
+import { useBaaS } from '@picobaas/client/react';
|
|
|
+import type { ImageAnalytics, ViewLog } from '../types';
|
|
|
+import LoadingSpinner from './LoadingSpinner';
|
|
|
+
|
|
|
+interface AnalyticsDashboardProps {
|
|
|
+ shortCode: string;
|
|
|
+ onClose?: () => void;
|
|
|
+}
|
|
|
+
|
|
|
+export default function AnalyticsDashboard({ shortCode, onClose }: AnalyticsDashboardProps) {
|
|
|
+ const { client } = useBaaS();
|
|
|
+ const [analytics, setAnalytics] = useState<ImageAnalytics | null>(null);
|
|
|
+ const [isLoading, setIsLoading] = useState(true);
|
|
|
+ const [error, setError] = useState<string | null>(null);
|
|
|
+
|
|
|
+ useEffect(() => {
|
|
|
+ fetchAnalytics();
|
|
|
+ }, [shortCode]);
|
|
|
+
|
|
|
+ const fetchAnalytics = async () => {
|
|
|
+ setIsLoading(true);
|
|
|
+ setError(null);
|
|
|
+
|
|
|
+ try {
|
|
|
+ // Get auth token from client
|
|
|
+ const token = await client.auth.getAccessToken();
|
|
|
+
|
|
|
+ const response = await fetch(`/api/images/${shortCode}/analytics`, {
|
|
|
+ headers: {
|
|
|
+ 'Authorization': token ? `Bearer ${token}` : '',
|
|
|
+ },
|
|
|
+ });
|
|
|
+
|
|
|
+ if (!response.ok) {
|
|
|
+ const errorData = await response.json().catch(() => ({}));
|
|
|
+ throw new Error(errorData.error || 'Failed to load analytics');
|
|
|
+ }
|
|
|
+
|
|
|
+ const data = await response.json();
|
|
|
+ setAnalytics({
|
|
|
+ totalViews: data.total_views,
|
|
|
+ uniqueVisitors: data.unique_visitors,
|
|
|
+ proxyViewsCount: data.proxy_views_count,
|
|
|
+ viewsByReferrer: data.views_by_referrer || {},
|
|
|
+ recentViews: (data.recent_views || []).map((v: Record<string, unknown>) => ({
|
|
|
+ id: v.id as string,
|
|
|
+ viewerIp: v.viewer_ip as string,
|
|
|
+ userAgent: v.user_agent as string,
|
|
|
+ referrer: v.referrer as string | null,
|
|
|
+ country: v.country as string | null,
|
|
|
+ isProxyDetected: v.is_proxy_detected as boolean,
|
|
|
+ viewedAt: new Date(v.viewed_at as string),
|
|
|
+ })),
|
|
|
+ });
|
|
|
+ } catch (err) {
|
|
|
+ setError(err instanceof Error ? err.message : 'Failed to load analytics');
|
|
|
+ } finally {
|
|
|
+ setIsLoading(false);
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ if (isLoading) {
|
|
|
+ return (
|
|
|
+ <div className="flex items-center justify-center p-8">
|
|
|
+ <LoadingSpinner size="lg" />
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ if (error) {
|
|
|
+ return (
|
|
|
+ <div className="p-6 text-center">
|
|
|
+ <div className="text-red-600 mb-4">{error}</div>
|
|
|
+ <button onClick={fetchAnalytics} className="btn btn-secondary">
|
|
|
+ Try Again
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!analytics) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ return (
|
|
|
+ <div className="space-y-6">
|
|
|
+ {/* Header */}
|
|
|
+ {onClose && (
|
|
|
+ <div className="flex justify-between items-center">
|
|
|
+ <h2 className="text-xl font-semibold text-gray-900">Image Analytics</h2>
|
|
|
+ <button
|
|
|
+ onClick={onClose}
|
|
|
+ className="text-gray-400 hover:text-gray-600"
|
|
|
+ >
|
|
|
+ <svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
|
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
|
+ </svg>
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+
|
|
|
+ {/* Stats Grid */}
|
|
|
+ <div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
|
|
+ <div className="bg-gray-50 rounded-lg p-4">
|
|
|
+ <div className="text-sm text-gray-500 mb-1">Total Views</div>
|
|
|
+ <div className="text-2xl font-bold text-gray-900">{analytics.totalViews}</div>
|
|
|
+ </div>
|
|
|
+ <div className="bg-gray-50 rounded-lg p-4">
|
|
|
+ <div className="text-sm text-gray-500 mb-1">Unique Visitors</div>
|
|
|
+ <div className="text-2xl font-bold text-gray-900">{analytics.uniqueVisitors}</div>
|
|
|
+ </div>
|
|
|
+ <div className="bg-gray-50 rounded-lg p-4">
|
|
|
+ <div className="text-sm text-gray-500 mb-1">Proxy/VPN Views</div>
|
|
|
+ <div className="text-2xl font-bold text-gray-900">{analytics.proxyViewsCount}</div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* Referrers */}
|
|
|
+ {Object.keys(analytics.viewsByReferrer).length > 0 && (
|
|
|
+ <div>
|
|
|
+ <h3 className="text-sm font-medium text-gray-700 mb-3">Top Referrers</h3>
|
|
|
+ <div className="bg-gray-50 rounded-lg divide-y divide-gray-200">
|
|
|
+ {Object.entries(analytics.viewsByReferrer)
|
|
|
+ .sort(([, a], [, b]) => b - a)
|
|
|
+ .slice(0, 5)
|
|
|
+ .map(([referrer, count]) => (
|
|
|
+ <div key={referrer} className="px-4 py-3 flex justify-between items-center">
|
|
|
+ <span className="text-sm text-gray-600 truncate max-w-xs" title={referrer}>
|
|
|
+ {referrer}
|
|
|
+ </span>
|
|
|
+ <span className="text-sm font-medium text-gray-900">{count}</span>
|
|
|
+ </div>
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+
|
|
|
+ {/* Recent Views Table */}
|
|
|
+ <div>
|
|
|
+ <h3 className="text-sm font-medium text-gray-700 mb-3">Recent Views</h3>
|
|
|
+ {analytics.recentViews.length === 0 ? (
|
|
|
+ <p className="text-gray-500 text-sm">No views recorded yet</p>
|
|
|
+ ) : (
|
|
|
+ <div className="overflow-x-auto">
|
|
|
+ <table className="min-w-full divide-y divide-gray-200">
|
|
|
+ <thead className="bg-gray-50">
|
|
|
+ <tr>
|
|
|
+ <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
|
+ Time
|
|
|
+ </th>
|
|
|
+ <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
|
+ IP Address
|
|
|
+ </th>
|
|
|
+ <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
|
+ Referrer
|
|
|
+ </th>
|
|
|
+ <th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
|
+ Proxy
|
|
|
+ </th>
|
|
|
+ </tr>
|
|
|
+ </thead>
|
|
|
+ <tbody className="bg-white divide-y divide-gray-200">
|
|
|
+ {analytics.recentViews.slice(0, 20).map((view: ViewLog) => (
|
|
|
+ <tr key={view.id}>
|
|
|
+ <td className="px-4 py-3 whitespace-nowrap text-sm text-gray-600">
|
|
|
+ {formatDate(view.viewedAt)}
|
|
|
+ </td>
|
|
|
+ <td className="px-4 py-3 whitespace-nowrap text-sm text-gray-600">
|
|
|
+ {view.viewerIp}
|
|
|
+ </td>
|
|
|
+ <td className="px-4 py-3 text-sm text-gray-600 max-w-xs truncate" title={view.referrer || undefined}>
|
|
|
+ {view.referrer || '-'}
|
|
|
+ </td>
|
|
|
+ <td className="px-4 py-3 whitespace-nowrap text-sm">
|
|
|
+ {view.isProxyDetected ? (
|
|
|
+ <span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-yellow-100 text-yellow-800">
|
|
|
+ Detected
|
|
|
+ </span>
|
|
|
+ ) : (
|
|
|
+ <span className="text-gray-400">-</span>
|
|
|
+ )}
|
|
|
+ </td>
|
|
|
+ </tr>
|
|
|
+ ))}
|
|
|
+ </tbody>
|
|
|
+ </table>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* Refresh Button */}
|
|
|
+ <div className="flex justify-end">
|
|
|
+ <button onClick={fetchAnalytics} className="btn btn-secondary text-sm">
|
|
|
+ Refresh
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+function formatDate(date: Date): string {
|
|
|
+ const now = new Date();
|
|
|
+ const diff = now.getTime() - date.getTime();
|
|
|
+
|
|
|
+ if (diff < 60000) {
|
|
|
+ return 'Just now';
|
|
|
+ }
|
|
|
+ if (diff < 3600000) {
|
|
|
+ const mins = Math.floor(diff / 60000);
|
|
|
+ return `${mins}m ago`;
|
|
|
+ }
|
|
|
+ if (diff < 86400000) {
|
|
|
+ const hours = Math.floor(diff / 3600000);
|
|
|
+ return `${hours}h ago`;
|
|
|
+ }
|
|
|
+
|
|
|
+ return date.toLocaleDateString(undefined, {
|
|
|
+ month: 'short',
|
|
|
+ day: 'numeric',
|
|
|
+ hour: '2-digit',
|
|
|
+ minute: '2-digit',
|
|
|
+ });
|
|
|
+}
|