| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444 |
- import { useState, useEffect } from 'react';
- import { useBaaS } from '@picobaas/client/react';
- import type { ImageAnalytics, ViewLog, ShareLink } from '../types';
- import { listShareLinks } from '../api/shareLinks';
- import { getSessionToken } from '../utils/session';
- import LoadingSpinner from './LoadingSpinner';
- interface ImageInfo {
- imageUrl: string;
- shortUrl: string;
- path: string;
- }
- interface AnalyticsDashboardProps {
- shortCode: string;
- onClose?: () => void;
- }
- export default function AnalyticsDashboard({ shortCode, onClose }: AnalyticsDashboardProps) {
- const { client } = useBaaS();
- const [analytics, setAnalytics] = useState<ImageAnalytics | null>(null);
- const [imageInfo, setImageInfo] = useState<ImageInfo | null>(null);
- const [shareLinks, setShareLinks] = useState<ShareLink[]>([]);
- const [isLoading, setIsLoading] = useState(true);
- const [error, setError] = useState<string | null>(null);
- useEffect(() => {
- fetchData();
- }, [shortCode]);
- const fetchData = async () => {
- setIsLoading(true);
- setError(null);
- try {
- const token = client.accessToken;
- const headers = {
- 'Authorization': token ? `Bearer ${token}` : '',
- };
- // Fetch image info, analytics, and share links in parallel
- const sessionToken = getSessionToken();
- const [imageResponse, analyticsResponse, links] = await Promise.all([
- fetch(`/api/images/${shortCode}`, { headers }),
- fetch(`/api/images/${shortCode}/analytics`, { headers }),
- listShareLinks(shortCode, token || undefined, sessionToken),
- ]);
- // Handle image info
- if (imageResponse.ok) {
- const imgData = await imageResponse.json();
- setImageInfo({
- imageUrl: imgData.image_url,
- shortUrl: imgData.short_url || `/i/${shortCode}`,
- path: imgData.path,
- });
- }
- // Handle share links
- setShareLinks(links);
- // Handle analytics
- if (!analyticsResponse.ok) {
- const errorData = await analyticsResponse.json().catch(() => ({}));
- throw new Error(errorData.error || 'Failed to load analytics');
- }
- const data = await analyticsResponse.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 dark:text-red-400 mb-4">{error}</div>
- <button onClick={fetchData} className="btn btn-secondary">
- Try Again
- </button>
- </div>
- );
- }
- if (!analytics) {
- return null;
- }
- return (
- <div className="space-y-6">
- {/* Header with image preview */}
- <div className="flex justify-between items-start gap-4">
- <div className="flex items-center gap-4">
- {imageInfo && (
- <a
- href={imageInfo.shortUrl}
- target="_blank"
- rel="noopener noreferrer"
- className="flex-shrink-0"
- >
- <img
- src={imageInfo.imageUrl}
- alt="Image preview"
- className="w-16 h-16 object-cover rounded-lg hover:opacity-80 transition-opacity"
- />
- </a>
- )}
- <div>
- <h2
- className="text-xl font-semibold"
- style={{ color: 'var(--text-primary)' }}
- >
- Image Analytics
- </h2>
- {imageInfo && (
- <p className="text-sm" style={{ color: 'var(--text-muted)' }}>
- {imageInfo.path.split('/').pop()}
- </p>
- )}
- </div>
- </div>
- {onClose && (
- <button
- onClick={onClose}
- className="transition-colors flex-shrink-0"
- style={{ color: 'var(--text-muted)' }}
- >
- <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="rounded-lg p-4"
- style={{ backgroundColor: 'var(--bg-tertiary)' }}
- >
- <div className="text-sm mb-1" style={{ color: 'var(--text-muted)' }}>
- Total Views
- </div>
- <div
- className="text-2xl font-bold"
- style={{ color: 'var(--text-primary)' }}
- >
- {analytics.totalViews}
- </div>
- </div>
- <div
- className="rounded-lg p-4"
- style={{ backgroundColor: 'var(--bg-tertiary)' }}
- >
- <div className="text-sm mb-1" style={{ color: 'var(--text-muted)' }}>
- Unique Visitors
- </div>
- <div
- className="text-2xl font-bold"
- style={{ color: 'var(--text-primary)' }}
- >
- {analytics.uniqueVisitors}
- </div>
- </div>
- <div
- className="rounded-lg p-4"
- style={{ backgroundColor: 'var(--bg-tertiary)' }}
- >
- <div className="text-sm mb-1" style={{ color: 'var(--text-muted)' }}>
- Proxy/VPN Views
- </div>
- <div
- className="text-2xl font-bold"
- style={{ color: 'var(--text-primary)' }}
- >
- {analytics.proxyViewsCount}
- </div>
- </div>
- </div>
- {/* Referrers */}
- {Object.keys(analytics.viewsByReferrer).length > 0 && (
- <div>
- <h3
- className="text-sm font-medium mb-3"
- style={{ color: 'var(--text-secondary)' }}
- >
- Top Referrers
- </h3>
- <div
- className="rounded-lg divide-y"
- style={{
- backgroundColor: 'var(--bg-tertiary)',
- borderColor: 'var(--border)',
- }}
- >
- {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"
- style={{ borderColor: 'var(--border)' }}
- >
- <span
- className="text-sm truncate max-w-xs"
- style={{ color: 'var(--text-secondary)' }}
- title={referrer}
- >
- {referrer}
- </span>
- <span
- className="text-sm font-medium"
- style={{ color: 'var(--text-primary)' }}
- >
- {count}
- </span>
- </div>
- ))}
- </div>
- </div>
- )}
- {/* Share Links Section */}
- {shareLinks.length > 0 && (
- <div>
- <h3
- className="text-sm font-medium mb-3"
- style={{ color: 'var(--text-secondary)' }}
- >
- Share Links ({shareLinks.length})
- </h3>
- <div
- className="rounded-lg divide-y"
- style={{
- backgroundColor: 'var(--bg-tertiary)',
- borderColor: 'var(--border)',
- }}
- >
- {shareLinks
- .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
- .map((link) => (
- <div
- key={link.id}
- className="px-4 py-3"
- style={{ borderColor: 'var(--border)' }}
- >
- <div className="flex justify-between items-start mb-2">
- <div className="flex items-center gap-2">
- <code
- className="text-sm font-mono px-2 py-0.5 rounded"
- style={{ backgroundColor: 'var(--bg-secondary)' }}
- >
- {link.linkCode}
- </code>
- {link.isRevoked ? (
- <span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400">
- Revoked
- </span>
- ) : !link.isValid ? (
- <span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-400">
- Expired
- </span>
- ) : (
- <span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400">
- Active
- </span>
- )}
- </div>
- <div
- className="text-sm font-medium"
- style={{ color: 'var(--text-primary)' }}
- >
- {link.viewCount} {link.viewCount === 1 ? 'view' : 'views'}
- </div>
- </div>
- <div
- className="text-xs flex flex-wrap gap-x-4 gap-y-1"
- style={{ color: 'var(--text-muted)' }}
- >
- <span>Created: {formatDate(link.createdAt)}</span>
- <span>
- Expires: {formatDate(link.expiresAt)}
- </span>
- {link.lastViewedAt && (
- <span>Last viewed: {formatDate(link.lastViewedAt)}</span>
- )}
- </div>
- </div>
- ))}
- </div>
- </div>
- )}
- {/* Recent Views Table */}
- <div>
- <h3
- className="text-sm font-medium mb-3"
- style={{ color: 'var(--text-secondary)' }}
- >
- Recent Views
- </h3>
- {analytics.recentViews.length === 0 ? (
- <p className="text-sm" style={{ color: 'var(--text-muted)' }}>
- No views recorded yet
- </p>
- ) : (
- <div className="overflow-x-auto">
- <table
- className="min-w-full divide-y"
- style={{ borderColor: 'var(--border)' }}
- >
- <thead style={{ backgroundColor: 'var(--bg-tertiary)' }}>
- <tr>
- <th
- className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider"
- style={{ color: 'var(--text-muted)' }}
- >
- Time
- </th>
- <th
- className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider"
- style={{ color: 'var(--text-muted)' }}
- >
- IP Address
- </th>
- <th
- className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider"
- style={{ color: 'var(--text-muted)' }}
- >
- Referrer
- </th>
- <th
- className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider"
- style={{ color: 'var(--text-muted)' }}
- >
- Proxy
- </th>
- </tr>
- </thead>
- <tbody
- className="divide-y"
- style={{
- backgroundColor: 'var(--bg-secondary)',
- borderColor: 'var(--border)',
- }}
- >
- {analytics.recentViews.slice(0, 20).map((view: ViewLog) => (
- <tr key={view.id}>
- <td
- className="px-4 py-3 whitespace-nowrap text-sm"
- style={{ color: 'var(--text-secondary)' }}
- >
- {formatDate(view.viewedAt)}
- </td>
- <td
- className="px-4 py-3 whitespace-nowrap text-sm"
- style={{ color: 'var(--text-secondary)' }}
- >
- {view.viewerIp}
- </td>
- <td
- className="px-4 py-3 text-sm max-w-xs truncate"
- style={{ color: 'var(--text-secondary)' }}
- 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 dark:bg-yellow-900/30 dark:text-yellow-400">
- Detected
- </span>
- ) : (
- <span style={{ color: 'var(--text-muted)' }}>-</span>
- )}
- </td>
- </tr>
- ))}
- </tbody>
- </table>
- </div>
- )}
- </div>
- {/* Refresh Button */}
- <div className="flex justify-end">
- <button onClick={fetchData} 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',
- });
- }
|