A very performant and light (2mb in memory) link shortener and tracker. Written in Rust and React and uses Postgres/SQLite.
at master 5.6 kB view raw
1import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; 2import { 3 LineChart, 4 Line, 5 XAxis, 6 YAxis, 7 CartesianGrid, 8 Tooltip, 9 ResponsiveContainer, 10} from "recharts"; 11import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; 12import { toast } from "@/hooks/use-toast"; 13import { useState, useEffect, useMemo } from "react"; 14 15import { getLinkClickStats, getLinkSourceStats } from "../api/client"; 16import { ClickStats, SourceStats } from "../types/api"; 17 18interface StatisticsModalProps { 19 isOpen: boolean; 20 onClose: () => void; 21 linkId: number; 22} 23 24interface EnhancedClickStats extends ClickStats { 25 sources?: { source: string; count: number }[]; 26} 27 28const CustomTooltip = ({ 29 active, 30 payload, 31 label, 32}: { 33 active?: boolean; 34 payload?: { value: number; payload: EnhancedClickStats }[]; 35 label?: string; 36}) => { 37 if (active && payload && payload.length > 0) { 38 const data = payload[0].payload; 39 return ( 40 <div className="bg-background text-foreground p-4 rounded-lg shadow-lg border"> 41 <p className="font-medium">{label}</p> 42 <p className="text-sm">Clicks: {data.clicks}</p> 43 {data.sources && data.sources.length > 0 && ( 44 <div className="mt-2"> 45 <p className="font-medium text-sm">Sources:</p> 46 <ul className="text-sm"> 47 {data.sources.map((source: { source: string; count: number }) => ( 48 <li key={source.source}> 49 {source.source}: {source.count} 50 </li> 51 ))} 52 </ul> 53 </div> 54 )} 55 </div> 56 ); 57 } 58 return null; 59}; 60 61export function StatisticsModal({ isOpen, onClose, linkId }: StatisticsModalProps) { 62 const [clicksOverTime, setClicksOverTime] = useState<EnhancedClickStats[]>([]); 63 const [sourcesData, setSourcesData] = useState<SourceStats[]>([]); 64 const [loading, setLoading] = useState(true); 65 66 useEffect(() => { 67 if (isOpen && linkId) { 68 const fetchData = async () => { 69 try { 70 setLoading(true); 71 const [clicksData, sourcesData] = await Promise.all([ 72 getLinkClickStats(linkId), 73 getLinkSourceStats(linkId), 74 ]); 75 76 // Enhance clicks data with source information 77 const enhancedClicksData = clicksData.map((clickData) => ({ 78 ...clickData, 79 sources: sourcesData.filter((source) => source.date === clickData.date), 80 })); 81 82 setClicksOverTime(enhancedClicksData); 83 setSourcesData(sourcesData); 84 } catch (error: unknown) { 85 console.error("Failed to fetch statistics:", error); 86 toast({ 87 variant: "destructive", 88 title: "Error", 89 description: error instanceof Error ? error.message : "Failed to load statistics", 90 }); 91 } finally { 92 setLoading(false); 93 } 94 }; 95 96 fetchData(); 97 } 98 }, [isOpen, linkId]); 99 100 const aggregatedSources = useMemo(() => { 101 const sourceMap = sourcesData.reduce<Record<string, number>>( 102 (acc, { source, count }) => ({ 103 ...acc, 104 [source]: (acc[source] || 0) + count 105 }), 106 {} 107 ); 108 109 return Object.entries(sourceMap) 110 .map(([source, count]) => ({ source, count })) 111 .sort((a, b) => b.count - a.count); 112 }, [sourcesData]); 113 114 return ( 115 <Dialog open={isOpen} onOpenChange={onClose}> 116 <DialogContent className="max-w-3xl"> 117 <DialogHeader> 118 <DialogTitle>Link Statistics</DialogTitle> 119 </DialogHeader> 120 121 {loading ? ( 122 <div className="flex items-center justify-center h-64">Loading...</div> 123 ) : ( 124 <div className="grid gap-4"> 125 <Card> 126 <CardHeader> 127 <CardTitle>Clicks Over Time</CardTitle> 128 </CardHeader> 129 <CardContent> 130 <div className="h-[300px]"> 131 <ResponsiveContainer width="100%" height="100%"> 132 <LineChart data={clicksOverTime}> 133 <CartesianGrid strokeDasharray="3 3" /> 134 <XAxis dataKey="date" /> 135 <YAxis /> 136 <Tooltip content={<CustomTooltip />} /> 137 <Line 138 type="monotone" 139 dataKey="clicks" 140 stroke="#8884d8" 141 strokeWidth={2} 142 /> 143 </LineChart> 144 </ResponsiveContainer> 145 </div> 146 </CardContent> 147 </Card> 148 149 <Card> 150 <CardHeader> 151 <CardTitle>Top Sources</CardTitle> 152 </CardHeader> 153 <CardContent> 154 <ul className="space-y-2"> 155 {aggregatedSources.map((source, index) => ( 156 <li 157 key={source.source} 158 className="flex items-center justify-between py-2 border-b last:border-0" 159 > 160 <span className="text-sm"> 161 <span className="font-medium text-muted-foreground mr-2"> 162 {index + 1}. 163 </span> 164 {source.source} 165 </span> 166 <span className="text-sm font-medium">{source.count} clicks</span> 167 </li> 168 ))} 169 </ul> 170 </CardContent> 171 </Card> 172 </div> 173 )} 174 </DialogContent> 175 </Dialog> 176 ); 177}