shiny-url-input-box 1.1.0 → 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.
@@ -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,261 @@
1
+ import React, { useState, useEffect } from 'react';
2
+ import {
3
+ Grid,
4
+ Paper,
5
+ Typography,
6
+ Box,
7
+ Card,
8
+ CardContent,
9
+ CircularProgress
10
+ } from '@mui/material';
11
+ import {
12
+ LineChart,
13
+ Line,
14
+ XAxis,
15
+ YAxis,
16
+ CartesianGrid,
17
+ Tooltip,
18
+ Legend,
19
+ ResponsiveContainer,
20
+ } from 'recharts';
21
+ import { getDefaultApiBaseUrl } from './shortener';
22
+
23
+ export interface ShinyUrlDashboardProps {
24
+ apiKey: string;
25
+ apiBaseUrl?: string;
26
+ className?: string;
27
+ title?: string;
28
+ }
29
+
30
+ interface StatsData {
31
+ stats: {
32
+ totalUrls: number;
33
+ totalClicks: number;
34
+ uniqueIps: number;
35
+ clicksToday: number;
36
+ };
37
+ }
38
+
39
+ interface ClickAnalyticsData {
40
+ data: Array<{
41
+ _id: string; // date
42
+ count: number;
43
+ }>;
44
+ }
45
+
46
+ interface TopUrlData {
47
+ data: Array<{
48
+ _id: string;
49
+ originalUrl: string;
50
+ shortCode: string;
51
+ clickCount: number;
52
+ }>;
53
+ }
54
+
55
+ export const ShinyUrlDashboard: React.FC<ShinyUrlDashboardProps> = ({
56
+ apiKey,
57
+ apiBaseUrl,
58
+ className = '',
59
+ title = 'Analytics Dashboard'
60
+ }) => {
61
+ const [loading, setLoading] = useState(true);
62
+ const [error, setError] = useState<string | null>(null);
63
+ const [stats, setStats] = useState<StatsData | null>(null);
64
+ const [clicks, setClicks] = useState<ClickAnalyticsData | null>(null);
65
+ const [topUrls, setTopUrls] = useState<TopUrlData | null>(null);
66
+
67
+ const baseUrl = apiBaseUrl || getDefaultApiBaseUrl();
68
+
69
+ useEffect(() => {
70
+ const fetchData = async () => {
71
+ if (!apiKey) return;
72
+
73
+ setLoading(true);
74
+ setError(null);
75
+
76
+ try {
77
+ const headers = {
78
+ 'Authorization': `Bearer ${apiKey}`,
79
+ 'Content-Type': 'application/json'
80
+ };
81
+
82
+ // Try standard X-API-Key as well if Bearer fails?
83
+ // Admin panel uses Bearer for JWT.
84
+ // If user passes an API Key (sk_...), the backend might need 'X-API-Key'.
85
+ // For now, let's assume if it looks like a JWT (3 distinct parts), use Bearer, else X-API-Key?
86
+ // Or simpler: try to fetch with headers that match the backend's expectation for the key type.
87
+ // The prompt says "admin panel components... user can import this dashboard creds".
88
+ // If it's an "Admin Dashboard", it likely needs admin 'Bearer' token.
89
+ // If it's a "User Dashboard" (showing user's URLs), it might use 'X-API-Key'.
90
+ // The endpoints used in Admin Dashboard (/admin/...) are protected by JWT.
91
+ // Endpoints for users might be different or same.
92
+ // I will implement a fallback or accept a 'keyType' prop later if needed.
93
+ // For now, I'll follow the admin pattern (Bearer) as requested by "dashboard creds in other admin panels".
94
+
95
+ const fetchOptions = { headers };
96
+
97
+ const [statsRes, clicksRes, topUrlsRes] = await Promise.all([
98
+ fetch(`${baseUrl}/api/admin/dashboard/stats`, fetchOptions),
99
+ fetch(`${baseUrl}/api/admin/analytics/clicks?days=7`, fetchOptions),
100
+ fetch(`${baseUrl}/api/admin/analytics/top-urls?limit=10`, fetchOptions)
101
+ ]);
102
+
103
+ if (!statsRes.ok || !clicksRes.ok || !topUrlsRes.ok) {
104
+ // Check if 401
105
+ if (statsRes.status === 401 || clicksRes.status === 401 || topUrlsRes.status === 401) {
106
+ throw new Error("Unauthorized: Invalid API Key or Token");
107
+ }
108
+ throw new Error("Failed to fetch dashboard data");
109
+ }
110
+
111
+ const statsData = await statsRes.json();
112
+ const clicksData = await clicksRes.json();
113
+ const topUrlsData = await topUrlsRes.json();
114
+
115
+ setStats(statsData);
116
+ setClicks(clicksData);
117
+ setTopUrls(topUrlsData);
118
+ } catch (err) {
119
+ if (err instanceof Error) {
120
+ setError(err.message);
121
+ } else {
122
+ setError("An unknown error occurred");
123
+ }
124
+ } finally {
125
+ setLoading(false);
126
+ }
127
+ };
128
+
129
+ fetchData();
130
+ }, [apiKey, baseUrl]);
131
+
132
+ if (!apiKey) {
133
+ return (
134
+ <Box p={3} className={className}>
135
+ <Typography color="error">Please provide an API Key / Token to view the dashboard.</Typography>
136
+ </Box>
137
+ );
138
+ }
139
+
140
+ if (loading) {
141
+ return (
142
+ <Box display="flex" justifyContent="center" alignItems="center" minHeight="400px" className={className}>
143
+ <CircularProgress />
144
+ </Box>
145
+ );
146
+ }
147
+
148
+ if (error) {
149
+ return (
150
+ <Box p={3} className={className}>
151
+ <Typography color="error">Error: {error}</Typography>
152
+ </Box>
153
+ );
154
+ }
155
+
156
+ const statCards = [
157
+ {
158
+ title: 'Total URLs',
159
+ value: stats?.stats?.totalUrls || 0,
160
+ color: '#1976d2',
161
+ },
162
+ {
163
+ title: 'Total Clicks',
164
+ value: stats?.stats?.totalClicks || 0,
165
+ color: '#2e7d32',
166
+ },
167
+ {
168
+ title: 'Unique IPs',
169
+ value: stats?.stats?.uniqueIps || 0,
170
+ color: '#ed6c02',
171
+ },
172
+ {
173
+ title: 'Clicks Today',
174
+ value: stats?.stats?.clicksToday || 0,
175
+ color: '#d32f2f',
176
+ },
177
+ ];
178
+
179
+ const chartData = clicks?.data?.map((item: any) => ({
180
+ date: item._id,
181
+ clicks: item.count,
182
+ })) || [];
183
+
184
+ return (
185
+ <Box className={className}>
186
+ <Typography variant="h4" gutterBottom>
187
+ {title}
188
+ </Typography>
189
+
190
+ <Grid container spacing={3} sx={{ mb: 3 }}>
191
+ {statCards.map((card) => (
192
+ <Grid item xs={12} sm={6} md={3} key={card.title}>
193
+ <Card>
194
+ <CardContent>
195
+ <Typography color="text.secondary" gutterBottom>
196
+ {card.title}
197
+ </Typography>
198
+ <Typography variant="h4" sx={{ color: card.color }}>
199
+ {card.value.toLocaleString()}
200
+ </Typography>
201
+ </CardContent>
202
+ </Card>
203
+ </Grid>
204
+ ))}
205
+ </Grid>
206
+
207
+ <Grid container spacing={3}>
208
+ <Grid item xs={12} md={8}>
209
+ <Paper sx={{ p: 3 }}>
210
+ <Typography variant="h6" gutterBottom>
211
+ Clicks Over Time (Last 7 Days)
212
+ </Typography>
213
+ <ResponsiveContainer width="100%" height={300}>
214
+ <LineChart data={chartData}>
215
+ <CartesianGrid strokeDasharray="3 3" />
216
+ <XAxis dataKey="date" />
217
+ <YAxis />
218
+ <Tooltip />
219
+ <Legend />
220
+ <Line
221
+ type="monotone"
222
+ dataKey="clicks"
223
+ stroke="#1976d2"
224
+ strokeWidth={2}
225
+ />
226
+ </LineChart>
227
+ </ResponsiveContainer>
228
+ </Paper>
229
+ </Grid>
230
+
231
+ <Grid item xs={12} md={4}>
232
+ <Paper sx={{ p: 3 }}>
233
+ <Typography variant="h6" gutterBottom>
234
+ Top URLs
235
+ </Typography>
236
+ <Box sx={{ maxHeight: 300, overflow: 'auto' }}>
237
+ {topUrls?.data?.map((url: any, index: number) => (
238
+ <Box
239
+ key={url._id}
240
+ sx={{
241
+ p: 1,
242
+ mb: 1,
243
+ bgcolor: 'background.default',
244
+ borderRadius: 1,
245
+ }}
246
+ >
247
+ <Typography variant="body2" noWrap>
248
+ {index + 1}. {url.originalUrl}
249
+ </Typography>
250
+ <Typography variant="caption" color="text.secondary">
251
+ {url.clickCount} clicks
252
+ </Typography>
253
+ </Box>
254
+ ))}
255
+ </Box>
256
+ </Paper>
257
+ </Grid>
258
+ </Grid>
259
+ </Box>
260
+ );
261
+ };