shiny-url-input-box 1.1.1 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/ShinyUrlApiKeys.d.ts +8 -0
- package/dist/ShinyUrlApiKeys.d.ts.map +1 -0
- package/dist/ShinyUrlApiKeys.js +111 -0
- package/dist/ShinyUrlDetailsModal.d.ts +10 -0
- package/dist/ShinyUrlDetailsModal.d.ts.map +1 -0
- package/dist/ShinyUrlDetailsModal.js +83 -0
- package/dist/ShinyUrlUrls.d.ts +8 -0
- package/dist/ShinyUrlUrls.d.ts.map +1 -0
- package/dist/ShinyUrlUrls.js +155 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/package.json +7 -4
- package/src/ShinyUrlApiKeys.tsx +305 -0
- package/src/ShinyUrlDetailsModal.tsx +397 -0
- package/src/ShinyUrlUrls.tsx +239 -0
- package/src/index.ts +6 -0
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
import React, { useState, useEffect, useCallback } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
Box,
|
|
4
|
+
Paper,
|
|
5
|
+
Typography,
|
|
6
|
+
Button,
|
|
7
|
+
Table,
|
|
8
|
+
TableBody,
|
|
9
|
+
TableCell,
|
|
10
|
+
TableContainer,
|
|
11
|
+
TableHead,
|
|
12
|
+
TableRow,
|
|
13
|
+
IconButton,
|
|
14
|
+
TextField,
|
|
15
|
+
Dialog,
|
|
16
|
+
DialogTitle,
|
|
17
|
+
DialogContent,
|
|
18
|
+
DialogActions,
|
|
19
|
+
Alert,
|
|
20
|
+
Chip,
|
|
21
|
+
Snackbar,
|
|
22
|
+
CircularProgress
|
|
23
|
+
} from '@mui/material';
|
|
24
|
+
import AddIcon from '@mui/icons-material/Add';
|
|
25
|
+
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
|
|
26
|
+
import { getDefaultApiBaseUrl } from './shortener';
|
|
27
|
+
|
|
28
|
+
export interface ShinyUrlApiKeysProps {
|
|
29
|
+
apiKey: string; // The user's/admin's token to authenticate
|
|
30
|
+
apiBaseUrl?: string;
|
|
31
|
+
className?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface ApiKeyData {
|
|
35
|
+
_id: string;
|
|
36
|
+
name: string;
|
|
37
|
+
key: string;
|
|
38
|
+
isActive: boolean;
|
|
39
|
+
createdAt: string;
|
|
40
|
+
lastUsedAt?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export const ShinyUrlApiKeys: React.FC<ShinyUrlApiKeysProps> = ({
|
|
44
|
+
apiKey,
|
|
45
|
+
apiBaseUrl,
|
|
46
|
+
className = ''
|
|
47
|
+
}) => {
|
|
48
|
+
const [loading, setLoading] = useState(true);
|
|
49
|
+
const [keys, setKeys] = useState<ApiKeyData[]>([]);
|
|
50
|
+
const [openDialog, setOpenDialog] = useState(false);
|
|
51
|
+
const [newKeyName, setNewKeyName] = useState('');
|
|
52
|
+
const [createdKey, setCreatedKey] = useState<string | null>(null);
|
|
53
|
+
const [snackbarOpen, setSnackbarOpen] = useState(false);
|
|
54
|
+
const [snackbarMessage, setSnackbarMessage] = useState('');
|
|
55
|
+
const [creating, setCreating] = useState(false);
|
|
56
|
+
|
|
57
|
+
const baseUrl = apiBaseUrl || getDefaultApiBaseUrl();
|
|
58
|
+
|
|
59
|
+
const fetchKeys = useCallback(async () => {
|
|
60
|
+
if (!apiKey) return;
|
|
61
|
+
setLoading(true);
|
|
62
|
+
try {
|
|
63
|
+
const response = await fetch(`${baseUrl}/api/admin/api-keys`, {
|
|
64
|
+
headers: {
|
|
65
|
+
'Authorization': `Bearer ${apiKey}`
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
if (response.ok) {
|
|
69
|
+
const result = await response.json();
|
|
70
|
+
setKeys(result.data || []);
|
|
71
|
+
}
|
|
72
|
+
} catch (error) {
|
|
73
|
+
console.error('Failed to fetch keys', error);
|
|
74
|
+
} finally {
|
|
75
|
+
setLoading(false);
|
|
76
|
+
}
|
|
77
|
+
}, [apiKey, baseUrl]);
|
|
78
|
+
|
|
79
|
+
useEffect(() => {
|
|
80
|
+
fetchKeys();
|
|
81
|
+
}, [fetchKeys]);
|
|
82
|
+
|
|
83
|
+
const handleCreate = async () => {
|
|
84
|
+
setCreating(true);
|
|
85
|
+
try {
|
|
86
|
+
const response = await fetch(`${baseUrl}/api/admin/api-keys`, {
|
|
87
|
+
method: 'POST',
|
|
88
|
+
headers: {
|
|
89
|
+
'Authorization': `Bearer ${apiKey}`,
|
|
90
|
+
'Content-Type': 'application/json'
|
|
91
|
+
},
|
|
92
|
+
body: JSON.stringify({ name: newKeyName || 'Default Key' })
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const result = await response.json();
|
|
96
|
+
|
|
97
|
+
if (result.success && result.data) {
|
|
98
|
+
setCreatedKey(result.data.key);
|
|
99
|
+
setOpenDialog(false);
|
|
100
|
+
setNewKeyName('');
|
|
101
|
+
setSnackbarMessage('API key created successfully! Copy it now - you won\'t be able to see it again.');
|
|
102
|
+
setSnackbarOpen(true);
|
|
103
|
+
fetchKeys(); // Refresh list
|
|
104
|
+
}
|
|
105
|
+
} catch (error) {
|
|
106
|
+
setSnackbarMessage('Failed to create key');
|
|
107
|
+
setSnackbarOpen(true);
|
|
108
|
+
} finally {
|
|
109
|
+
setCreating(false);
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const handleToggle = async (id: string, currentStatus: boolean) => {
|
|
114
|
+
try {
|
|
115
|
+
const response = await fetch(`${baseUrl}/api/admin/api-keys/${id}`, {
|
|
116
|
+
method: 'PATCH',
|
|
117
|
+
headers: {
|
|
118
|
+
'Authorization': `Bearer ${apiKey}`,
|
|
119
|
+
'Content-Type': 'application/json'
|
|
120
|
+
},
|
|
121
|
+
body: JSON.stringify({ isActive: !currentStatus })
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
if (response.ok) {
|
|
125
|
+
setSnackbarMessage('API key updated successfully');
|
|
126
|
+
setSnackbarOpen(true);
|
|
127
|
+
fetchKeys(); // Refresh list
|
|
128
|
+
}
|
|
129
|
+
} catch (error) {
|
|
130
|
+
setSnackbarMessage('Failed to update key');
|
|
131
|
+
setSnackbarOpen(true);
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const handleCopy = (text: string) => {
|
|
136
|
+
navigator.clipboard.writeText(text);
|
|
137
|
+
setSnackbarMessage('Copied to clipboard!');
|
|
138
|
+
setSnackbarOpen(true);
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const formatKey = (key: string) => {
|
|
142
|
+
if (key.length > 20) {
|
|
143
|
+
return key.substring(0, 10) + '...' + key.substring(key.length - 4);
|
|
144
|
+
}
|
|
145
|
+
return key;
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
if (!apiKey) {
|
|
149
|
+
return <Typography color="error">Authentication required</Typography>;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return (
|
|
153
|
+
<Box className={className}>
|
|
154
|
+
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
|
|
155
|
+
<Typography variant="h4">API Keys</Typography>
|
|
156
|
+
<Button
|
|
157
|
+
variant="contained"
|
|
158
|
+
startIcon={<AddIcon />}
|
|
159
|
+
onClick={() => setOpenDialog(true)}
|
|
160
|
+
>
|
|
161
|
+
Create New Key
|
|
162
|
+
</Button>
|
|
163
|
+
</Box>
|
|
164
|
+
|
|
165
|
+
<Paper>
|
|
166
|
+
<TableContainer>
|
|
167
|
+
<Table>
|
|
168
|
+
<TableHead>
|
|
169
|
+
<TableRow>
|
|
170
|
+
<TableCell>Name</TableCell>
|
|
171
|
+
<TableCell>Key</TableCell>
|
|
172
|
+
<TableCell>Status</TableCell>
|
|
173
|
+
<TableCell>Created</TableCell>
|
|
174
|
+
<TableCell>Last Used</TableCell>
|
|
175
|
+
<TableCell>Actions</TableCell>
|
|
176
|
+
</TableRow>
|
|
177
|
+
</TableHead>
|
|
178
|
+
<TableBody>
|
|
179
|
+
{loading ? (
|
|
180
|
+
<TableRow>
|
|
181
|
+
<TableCell colSpan={6} align="center">
|
|
182
|
+
<CircularProgress size={24} />
|
|
183
|
+
</TableCell>
|
|
184
|
+
</TableRow>
|
|
185
|
+
) : keys.length > 0 ? (
|
|
186
|
+
keys.map((key) => (
|
|
187
|
+
<TableRow key={key._id}>
|
|
188
|
+
<TableCell>{key.name || 'Default Key'}</TableCell>
|
|
189
|
+
<TableCell>
|
|
190
|
+
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
|
191
|
+
<Typography variant="body2" sx={{ fontFamily: 'monospace' }}>
|
|
192
|
+
{formatKey(key.key)}
|
|
193
|
+
</Typography>
|
|
194
|
+
<IconButton
|
|
195
|
+
size="small"
|
|
196
|
+
onClick={() => handleCopy(key.key)}
|
|
197
|
+
title="Copy full key"
|
|
198
|
+
>
|
|
199
|
+
<ContentCopyIcon fontSize="small" />
|
|
200
|
+
</IconButton>
|
|
201
|
+
</Box>
|
|
202
|
+
</TableCell>
|
|
203
|
+
<TableCell>
|
|
204
|
+
<Chip
|
|
205
|
+
label={key.isActive ? 'Active' : 'Disabled'}
|
|
206
|
+
color={key.isActive ? 'success' : 'default'}
|
|
207
|
+
size="small"
|
|
208
|
+
/>
|
|
209
|
+
</TableCell>
|
|
210
|
+
<TableCell>
|
|
211
|
+
{new Date(key.createdAt).toLocaleDateString()}
|
|
212
|
+
</TableCell>
|
|
213
|
+
<TableCell>
|
|
214
|
+
{key.lastUsedAt
|
|
215
|
+
? new Date(key.lastUsedAt).toLocaleDateString()
|
|
216
|
+
: 'Never'}
|
|
217
|
+
</TableCell>
|
|
218
|
+
<TableCell>
|
|
219
|
+
<Button
|
|
220
|
+
size="small"
|
|
221
|
+
variant="outlined"
|
|
222
|
+
color={key.isActive ? 'error' : 'success'}
|
|
223
|
+
onClick={() => handleToggle(key._id, key.isActive)}
|
|
224
|
+
>
|
|
225
|
+
{key.isActive ? 'Disable' : 'Enable'}
|
|
226
|
+
</Button>
|
|
227
|
+
</TableCell>
|
|
228
|
+
</TableRow>
|
|
229
|
+
))
|
|
230
|
+
) : (
|
|
231
|
+
<TableRow>
|
|
232
|
+
<TableCell colSpan={6} align="center">
|
|
233
|
+
No API keys found. Create your first key to start using the service.
|
|
234
|
+
</TableCell>
|
|
235
|
+
</TableRow>
|
|
236
|
+
)}
|
|
237
|
+
</TableBody>
|
|
238
|
+
</Table>
|
|
239
|
+
</TableContainer>
|
|
240
|
+
</Paper>
|
|
241
|
+
|
|
242
|
+
<Dialog open={openDialog} onClose={() => setOpenDialog(false)}>
|
|
243
|
+
<DialogTitle>Create New API Key</DialogTitle>
|
|
244
|
+
<DialogContent>
|
|
245
|
+
<TextField
|
|
246
|
+
autoFocus
|
|
247
|
+
margin="dense"
|
|
248
|
+
label="Key Name (optional)"
|
|
249
|
+
fullWidth
|
|
250
|
+
variant="outlined"
|
|
251
|
+
value={newKeyName}
|
|
252
|
+
onChange={(e) => setNewKeyName(e.target.value)}
|
|
253
|
+
placeholder="e.g., Production Key, Development Key"
|
|
254
|
+
sx={{ mt: 2 }}
|
|
255
|
+
/>
|
|
256
|
+
</DialogContent>
|
|
257
|
+
<DialogActions>
|
|
258
|
+
<Button onClick={() => setOpenDialog(false)}>Cancel</Button>
|
|
259
|
+
<Button
|
|
260
|
+
onClick={handleCreate}
|
|
261
|
+
variant="contained"
|
|
262
|
+
disabled={creating}
|
|
263
|
+
>
|
|
264
|
+
{creating ? 'Creating...' : 'Create'}
|
|
265
|
+
</Button>
|
|
266
|
+
</DialogActions>
|
|
267
|
+
</Dialog>
|
|
268
|
+
|
|
269
|
+
<Dialog open={!!createdKey} onClose={() => setCreatedKey(null)}>
|
|
270
|
+
<DialogTitle>API Key Created</DialogTitle>
|
|
271
|
+
<DialogContent>
|
|
272
|
+
<Alert severity="warning" sx={{ mb: 2 }}>
|
|
273
|
+
Copy this key now! You won't be able to see it again.
|
|
274
|
+
</Alert>
|
|
275
|
+
<TextField
|
|
276
|
+
fullWidth
|
|
277
|
+
value={createdKey || ''}
|
|
278
|
+
InputProps={{
|
|
279
|
+
readOnly: true,
|
|
280
|
+
}}
|
|
281
|
+
sx={{ mb: 2 }}
|
|
282
|
+
/>
|
|
283
|
+
<Button
|
|
284
|
+
fullWidth
|
|
285
|
+
variant="contained"
|
|
286
|
+
startIcon={<ContentCopyIcon />}
|
|
287
|
+
onClick={() => createdKey && handleCopy(createdKey)}
|
|
288
|
+
>
|
|
289
|
+
Copy Key
|
|
290
|
+
</Button>
|
|
291
|
+
</DialogContent>
|
|
292
|
+
<DialogActions>
|
|
293
|
+
<Button onClick={() => setCreatedKey(null)}>Close</Button>
|
|
294
|
+
</DialogActions>
|
|
295
|
+
</Dialog>
|
|
296
|
+
|
|
297
|
+
<Snackbar
|
|
298
|
+
open={snackbarOpen}
|
|
299
|
+
autoHideDuration={3000}
|
|
300
|
+
onClose={() => setSnackbarOpen(false)}
|
|
301
|
+
message={snackbarMessage}
|
|
302
|
+
/>
|
|
303
|
+
</Box>
|
|
304
|
+
);
|
|
305
|
+
};
|
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
import React, { useState, useEffect } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
Dialog,
|
|
4
|
+
DialogTitle,
|
|
5
|
+
DialogContent,
|
|
6
|
+
DialogActions,
|
|
7
|
+
Button,
|
|
8
|
+
Box,
|
|
9
|
+
Typography,
|
|
10
|
+
Tabs,
|
|
11
|
+
Tab,
|
|
12
|
+
Paper,
|
|
13
|
+
Table,
|
|
14
|
+
TableBody,
|
|
15
|
+
TableCell,
|
|
16
|
+
TableContainer,
|
|
17
|
+
TableHead,
|
|
18
|
+
TableRow,
|
|
19
|
+
Chip,
|
|
20
|
+
Grid,
|
|
21
|
+
Card,
|
|
22
|
+
CardContent,
|
|
23
|
+
CircularProgress
|
|
24
|
+
} from '@mui/material';
|
|
25
|
+
import {
|
|
26
|
+
LineChart,
|
|
27
|
+
Line,
|
|
28
|
+
BarChart,
|
|
29
|
+
Bar,
|
|
30
|
+
PieChart,
|
|
31
|
+
Pie,
|
|
32
|
+
Cell,
|
|
33
|
+
XAxis,
|
|
34
|
+
YAxis,
|
|
35
|
+
CartesianGrid,
|
|
36
|
+
Tooltip,
|
|
37
|
+
Legend,
|
|
38
|
+
ResponsiveContainer,
|
|
39
|
+
} from 'recharts';
|
|
40
|
+
import { getDefaultApiBaseUrl } from './shortener';
|
|
41
|
+
|
|
42
|
+
export interface ShinyUrlDetailsModalProps {
|
|
43
|
+
open: boolean;
|
|
44
|
+
urlId: string | null;
|
|
45
|
+
onClose: () => void;
|
|
46
|
+
apiKey: string;
|
|
47
|
+
apiBaseUrl?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface TabPanelProps {
|
|
51
|
+
children?: React.ReactNode;
|
|
52
|
+
index: number;
|
|
53
|
+
value: number;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function TabPanel(props: TabPanelProps) {
|
|
57
|
+
const { children, value, index, ...other } = props;
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<div
|
|
61
|
+
role="tabpanel"
|
|
62
|
+
hidden={value !== index}
|
|
63
|
+
id={`url-tabpanel-${index}`}
|
|
64
|
+
aria-labelledby={`url-tab-${index}`}
|
|
65
|
+
{...other}
|
|
66
|
+
>
|
|
67
|
+
{value === index && <Box sx={{ p: 3 }}>{children}</Box>}
|
|
68
|
+
</div>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const COLORS = ['#0088FE', '#00C49F', '#FFBB28', '#FF8042', '#8884d8', '#82ca9d'];
|
|
73
|
+
|
|
74
|
+
export const ShinyUrlDetailsModal: React.FC<ShinyUrlDetailsModalProps> = ({
|
|
75
|
+
open,
|
|
76
|
+
urlId,
|
|
77
|
+
onClose,
|
|
78
|
+
apiKey,
|
|
79
|
+
apiBaseUrl
|
|
80
|
+
}) => {
|
|
81
|
+
const [tabValue, setTabValue] = useState(0);
|
|
82
|
+
const [loading, setLoading] = useState(false);
|
|
83
|
+
const [urlData, setUrlData] = useState<any>(null);
|
|
84
|
+
const [clicksData, setClicksData] = useState<any[]>([]);
|
|
85
|
+
const [analyticsData, setAnalyticsData] = useState<any>(null);
|
|
86
|
+
const baseUrl = apiBaseUrl || getDefaultApiBaseUrl();
|
|
87
|
+
|
|
88
|
+
useEffect(() => {
|
|
89
|
+
if (open && urlId && apiKey) {
|
|
90
|
+
setLoading(true);
|
|
91
|
+
Promise.all([
|
|
92
|
+
fetch(`${baseUrl}/api/urls/${urlId}`, {
|
|
93
|
+
headers: { 'Authorization': `Bearer ${apiKey}` }
|
|
94
|
+
}).then(res => res.json()),
|
|
95
|
+
fetch(`${baseUrl}/api/analytics/url/${urlId}?limit=1000`, {
|
|
96
|
+
headers: { 'Authorization': `Bearer ${apiKey}` }
|
|
97
|
+
}).then(res => res.json()), // Clicks
|
|
98
|
+
fetch(`${baseUrl}/api/analytics/url/${urlId}/stats?days=30`, {
|
|
99
|
+
headers: { 'Authorization': `Bearer ${apiKey}` }
|
|
100
|
+
}).then(res => res.json()) // Analytics
|
|
101
|
+
]).then(([urlRes, clicksRes, analyticsRes]) => {
|
|
102
|
+
if(urlRes.success) setUrlData(urlRes.data);
|
|
103
|
+
if(clicksRes.success) setClicksData(clicksRes.data || []);
|
|
104
|
+
if(analyticsRes.success) setAnalyticsData(analyticsRes.data);
|
|
105
|
+
}).catch(err => {
|
|
106
|
+
console.error("Failed to fetch url details", err);
|
|
107
|
+
}).finally(() => {
|
|
108
|
+
setLoading(false);
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
}, [open, urlId, apiKey, baseUrl]);
|
|
112
|
+
|
|
113
|
+
const handleTabChange = (_event: React.SyntheticEvent, newValue: number) => {
|
|
114
|
+
setTabValue(newValue);
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
// Prepare chart data
|
|
118
|
+
const clicksOverTime = analyticsData?.clicksOverTime?.map((item: any) => ({
|
|
119
|
+
date: item._id,
|
|
120
|
+
clicks: item.count,
|
|
121
|
+
})) || [];
|
|
122
|
+
|
|
123
|
+
const deviceData = analyticsData?.deviceBreakdown?.map((item: any) => ({
|
|
124
|
+
name: item._id || 'Unknown',
|
|
125
|
+
value: item.count,
|
|
126
|
+
})) || [];
|
|
127
|
+
|
|
128
|
+
const browserData = analyticsData?.browserBreakdown?.slice(0, 5).map((item: any) => ({
|
|
129
|
+
name: item._id || 'Unknown',
|
|
130
|
+
clicks: item.count,
|
|
131
|
+
})) || [];
|
|
132
|
+
|
|
133
|
+
const osData = analyticsData?.osBreakdown?.slice(0, 5).map((item: any) => ({
|
|
134
|
+
name: item._id || 'Unknown',
|
|
135
|
+
clicks: item.count,
|
|
136
|
+
})) || [];
|
|
137
|
+
|
|
138
|
+
return (
|
|
139
|
+
<Dialog open={open} onClose={onClose} maxWidth="lg" fullWidth>
|
|
140
|
+
<DialogTitle>
|
|
141
|
+
<Box>
|
|
142
|
+
<Typography variant="h6">URL Details & Analytics</Typography>
|
|
143
|
+
{urlData && (
|
|
144
|
+
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
|
|
145
|
+
<strong>Short Code:</strong>{' '}
|
|
146
|
+
<Typography
|
|
147
|
+
component="a"
|
|
148
|
+
href={`${baseUrl}/${urlData.shortCode}`}
|
|
149
|
+
target="_blank"
|
|
150
|
+
rel="noopener"
|
|
151
|
+
sx={{
|
|
152
|
+
color: 'primary.main',
|
|
153
|
+
textDecoration: 'none',
|
|
154
|
+
fontFamily: 'monospace',
|
|
155
|
+
'&:hover': {
|
|
156
|
+
textDecoration: 'underline',
|
|
157
|
+
},
|
|
158
|
+
}}
|
|
159
|
+
>
|
|
160
|
+
{urlData.shortCode}
|
|
161
|
+
</Typography>{' '}
|
|
162
|
+
| <strong>Total Clicks:</strong> {urlData.clicks || 0}
|
|
163
|
+
</Typography>
|
|
164
|
+
)}
|
|
165
|
+
</Box>
|
|
166
|
+
</DialogTitle>
|
|
167
|
+
<DialogContent>
|
|
168
|
+
{loading ? (
|
|
169
|
+
<Box sx={{ p: 3, textAlign: 'center' }}>
|
|
170
|
+
<CircularProgress />
|
|
171
|
+
<Typography sx={{ mt: 2 }}>Loading...</Typography>
|
|
172
|
+
</Box>
|
|
173
|
+
) : (
|
|
174
|
+
<>
|
|
175
|
+
{urlData && (
|
|
176
|
+
<Box sx={{ mb: 3 }}>
|
|
177
|
+
<Typography variant="body2" sx={{ mb: 1 }}>
|
|
178
|
+
<strong>Original URL:</strong>
|
|
179
|
+
</Typography>
|
|
180
|
+
<Typography
|
|
181
|
+
variant="body2"
|
|
182
|
+
sx={{
|
|
183
|
+
wordBreak: 'break-all',
|
|
184
|
+
bgcolor: 'action.hover',
|
|
185
|
+
p: 1,
|
|
186
|
+
borderRadius: 1,
|
|
187
|
+
}}
|
|
188
|
+
>
|
|
189
|
+
{urlData.originalUrl}
|
|
190
|
+
</Typography>
|
|
191
|
+
</Box>
|
|
192
|
+
)}
|
|
193
|
+
|
|
194
|
+
<Box sx={{ borderBottom: 1, borderColor: 'divider', mb: 2 }}>
|
|
195
|
+
<Tabs value={tabValue} onChange={handleTabChange}>
|
|
196
|
+
<Tab label="Analytics" />
|
|
197
|
+
<Tab label="Click History" />
|
|
198
|
+
</Tabs>
|
|
199
|
+
</Box>
|
|
200
|
+
|
|
201
|
+
<TabPanel value={tabValue} index={0}>
|
|
202
|
+
{analyticsData ? (
|
|
203
|
+
<Grid container spacing={3}>
|
|
204
|
+
<Grid item xs={12} md={6}>
|
|
205
|
+
<Card>
|
|
206
|
+
<CardContent>
|
|
207
|
+
<Typography variant="h6" gutterBottom>
|
|
208
|
+
Total Clicks
|
|
209
|
+
</Typography>
|
|
210
|
+
<Typography variant="h3" color="primary">
|
|
211
|
+
{analyticsData.totalClicks}
|
|
212
|
+
</Typography>
|
|
213
|
+
</CardContent>
|
|
214
|
+
</Card>
|
|
215
|
+
</Grid>
|
|
216
|
+
<Grid item xs={12} md={6}>
|
|
217
|
+
<Card>
|
|
218
|
+
<CardContent>
|
|
219
|
+
<Typography variant="h6" gutterBottom>
|
|
220
|
+
Unique Users
|
|
221
|
+
</Typography>
|
|
222
|
+
<Typography variant="h3" color="secondary">
|
|
223
|
+
{analyticsData.uniqueUsers}
|
|
224
|
+
</Typography>
|
|
225
|
+
</CardContent>
|
|
226
|
+
</Card>
|
|
227
|
+
</Grid>
|
|
228
|
+
|
|
229
|
+
<Grid item xs={12}>
|
|
230
|
+
<Paper sx={{ p: 2 }}>
|
|
231
|
+
<Typography variant="h6" gutterBottom>
|
|
232
|
+
Clicks Over Time (Last 30 Days)
|
|
233
|
+
</Typography>
|
|
234
|
+
<ResponsiveContainer width="100%" height={300}>
|
|
235
|
+
<LineChart data={clicksOverTime}>
|
|
236
|
+
<CartesianGrid strokeDasharray="3 3" />
|
|
237
|
+
<XAxis dataKey="date" />
|
|
238
|
+
<YAxis />
|
|
239
|
+
<Tooltip />
|
|
240
|
+
<Legend />
|
|
241
|
+
<Line
|
|
242
|
+
type="monotone"
|
|
243
|
+
dataKey="clicks"
|
|
244
|
+
stroke="#1976d2"
|
|
245
|
+
strokeWidth={2}
|
|
246
|
+
/>
|
|
247
|
+
</LineChart>
|
|
248
|
+
</ResponsiveContainer>
|
|
249
|
+
</Paper>
|
|
250
|
+
</Grid>
|
|
251
|
+
|
|
252
|
+
<Grid item xs={12} md={6}>
|
|
253
|
+
<Paper sx={{ p: 2 }}>
|
|
254
|
+
<Typography variant="h6" gutterBottom>
|
|
255
|
+
Device Breakdown
|
|
256
|
+
</Typography>
|
|
257
|
+
<ResponsiveContainer width="100%" height={250}>
|
|
258
|
+
<PieChart>
|
|
259
|
+
<Pie
|
|
260
|
+
data={deviceData}
|
|
261
|
+
cx="50%"
|
|
262
|
+
cy="50%"
|
|
263
|
+
labelLine={false}
|
|
264
|
+
label={({ name, percent }: any) =>
|
|
265
|
+
`${name}: ${(percent * 100).toFixed(0)}%`
|
|
266
|
+
}
|
|
267
|
+
outerRadius={80}
|
|
268
|
+
fill="#8884d8"
|
|
269
|
+
dataKey="value"
|
|
270
|
+
>
|
|
271
|
+
{deviceData.map((_entry: any, index: number) => (
|
|
272
|
+
<Cell
|
|
273
|
+
key={`cell-${index}`}
|
|
274
|
+
fill={COLORS[index % COLORS.length]}
|
|
275
|
+
/>
|
|
276
|
+
))}
|
|
277
|
+
</Pie>
|
|
278
|
+
<Tooltip />
|
|
279
|
+
</PieChart>
|
|
280
|
+
</ResponsiveContainer>
|
|
281
|
+
</Paper>
|
|
282
|
+
</Grid>
|
|
283
|
+
|
|
284
|
+
<Grid item xs={12} md={6}>
|
|
285
|
+
<Paper sx={{ p: 2 }}>
|
|
286
|
+
<Typography variant="h6" gutterBottom>
|
|
287
|
+
Top Browsers
|
|
288
|
+
</Typography>
|
|
289
|
+
<ResponsiveContainer width="100%" height={250}>
|
|
290
|
+
<BarChart data={browserData}>
|
|
291
|
+
<CartesianGrid strokeDasharray="3 3" />
|
|
292
|
+
<XAxis dataKey="name" />
|
|
293
|
+
<YAxis />
|
|
294
|
+
<Tooltip />
|
|
295
|
+
<Bar dataKey="clicks" fill="#1976d2" />
|
|
296
|
+
</BarChart>
|
|
297
|
+
</ResponsiveContainer>
|
|
298
|
+
</Paper>
|
|
299
|
+
</Grid>
|
|
300
|
+
|
|
301
|
+
<Grid item xs={12}>
|
|
302
|
+
<Paper sx={{ p: 2 }}>
|
|
303
|
+
<Typography variant="h6" gutterBottom>
|
|
304
|
+
Top Operating Systems
|
|
305
|
+
</Typography>
|
|
306
|
+
<ResponsiveContainer width="100%" height={250}>
|
|
307
|
+
<BarChart data={osData}>
|
|
308
|
+
<CartesianGrid strokeDasharray="3 3" />
|
|
309
|
+
<XAxis dataKey="name" />
|
|
310
|
+
<YAxis />
|
|
311
|
+
<Tooltip />
|
|
312
|
+
<Bar dataKey="clicks" fill="#00C49F" />
|
|
313
|
+
</BarChart>
|
|
314
|
+
</ResponsiveContainer>
|
|
315
|
+
</Paper>
|
|
316
|
+
</Grid>
|
|
317
|
+
</Grid>
|
|
318
|
+
) : (
|
|
319
|
+
<Typography>No analytics data available</Typography>
|
|
320
|
+
)}
|
|
321
|
+
</TabPanel>
|
|
322
|
+
|
|
323
|
+
<TabPanel value={tabValue} index={1}>
|
|
324
|
+
<TableContainer component={Paper}>
|
|
325
|
+
<Table>
|
|
326
|
+
<TableHead>
|
|
327
|
+
<TableRow>
|
|
328
|
+
<TableCell>Date & Time</TableCell>
|
|
329
|
+
<TableCell>IP Address</TableCell>
|
|
330
|
+
<TableCell>Device</TableCell>
|
|
331
|
+
<TableCell>Browser</TableCell>
|
|
332
|
+
<TableCell>OS</TableCell>
|
|
333
|
+
<TableCell>Referer</TableCell>
|
|
334
|
+
</TableRow>
|
|
335
|
+
</TableHead>
|
|
336
|
+
<TableBody>
|
|
337
|
+
{clicksData.length === 0 ? (
|
|
338
|
+
<TableRow>
|
|
339
|
+
<TableCell colSpan={6} align="center">
|
|
340
|
+
No clicks recorded yet
|
|
341
|
+
</TableCell>
|
|
342
|
+
</TableRow>
|
|
343
|
+
) : (
|
|
344
|
+
clicksData.map((click: any, index: number) => (
|
|
345
|
+
<TableRow key={index}>
|
|
346
|
+
<TableCell>
|
|
347
|
+
{new Date(click.clickedAt).toLocaleString()}
|
|
348
|
+
</TableCell>
|
|
349
|
+
<TableCell>
|
|
350
|
+
<Chip label={click.ipAddress} size="small" />
|
|
351
|
+
</TableCell>
|
|
352
|
+
<TableCell>
|
|
353
|
+
<Chip
|
|
354
|
+
label={click.device || 'Unknown'}
|
|
355
|
+
size="small"
|
|
356
|
+
color="primary"
|
|
357
|
+
variant="outlined"
|
|
358
|
+
/>
|
|
359
|
+
</TableCell>
|
|
360
|
+
<TableCell>{click.browser || 'Unknown'}</TableCell>
|
|
361
|
+
<TableCell>{click.os || 'Unknown'}</TableCell>
|
|
362
|
+
<TableCell>
|
|
363
|
+
{click.referer ? (
|
|
364
|
+
<Typography
|
|
365
|
+
variant="body2"
|
|
366
|
+
sx={{
|
|
367
|
+
maxWidth: 200,
|
|
368
|
+
overflow: 'hidden',
|
|
369
|
+
textOverflow: 'ellipsis',
|
|
370
|
+
whiteSpace: 'nowrap',
|
|
371
|
+
}}
|
|
372
|
+
title={click.referer}
|
|
373
|
+
>
|
|
374
|
+
{click.referer}
|
|
375
|
+
</Typography>
|
|
376
|
+
) : (
|
|
377
|
+
<Typography variant="body2" color="text.secondary">
|
|
378
|
+
Direct
|
|
379
|
+
</Typography>
|
|
380
|
+
)}
|
|
381
|
+
</TableCell>
|
|
382
|
+
</TableRow>
|
|
383
|
+
))
|
|
384
|
+
)}
|
|
385
|
+
</TableBody>
|
|
386
|
+
</Table>
|
|
387
|
+
</TableContainer>
|
|
388
|
+
</TabPanel>
|
|
389
|
+
</>
|
|
390
|
+
)}
|
|
391
|
+
</DialogContent>
|
|
392
|
+
<DialogActions>
|
|
393
|
+
<Button onClick={onClose}>Close</Button>
|
|
394
|
+
</DialogActions>
|
|
395
|
+
</Dialog>
|
|
396
|
+
);
|
|
397
|
+
};
|