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,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
+ };
@@ -0,0 +1,239 @@
1
+ import React, { useState, useEffect, useCallback } from 'react';
2
+ import {
3
+ Box,
4
+ Typography,
5
+ TextField,
6
+ Paper,
7
+ CircularProgress
8
+ } from '@mui/material';
9
+ import {
10
+ DataGrid,
11
+ GridColDef,
12
+ GridActionsCellItem,
13
+ GridToolbar,
14
+ GridRowParams,
15
+ GridPaginationModel
16
+ } from '@mui/x-data-grid';
17
+ import DeleteIcon from '@mui/icons-material/Delete';
18
+ import { ShinyUrlDetailsModal } from './ShinyUrlDetailsModal';
19
+ import { getDefaultApiBaseUrl } from './shortener';
20
+
21
+ export interface ShinyUrlUrlsProps {
22
+ apiKey: string;
23
+ apiBaseUrl?: string;
24
+ className?: string;
25
+ }
26
+
27
+ export const ShinyUrlUrls: React.FC<ShinyUrlUrlsProps> = ({
28
+ apiKey,
29
+ apiBaseUrl,
30
+ className = ''
31
+ }) => {
32
+ const [page, setPage] = useState(0);
33
+ const [pageSize, setPageSize] = useState(10);
34
+ const [search, setSearch] = useState('');
35
+ const [selectedUrlId, setSelectedUrlId] = useState<string | null>(null);
36
+ const [modalOpen, setModalOpen] = useState(false);
37
+ const [loading, setLoading] = useState(false);
38
+ const [rows, setRows] = useState<any[]>([]);
39
+ const [total, setTotal] = useState(0);
40
+
41
+ const baseUrl = apiBaseUrl || getDefaultApiBaseUrl();
42
+
43
+ const fetchUrls = useCallback(async () => {
44
+ if (!apiKey) return;
45
+ setLoading(true);
46
+ try {
47
+ const queryParams = new URLSearchParams({
48
+ page: (page + 1).toString(),
49
+ limit: pageSize.toString(),
50
+ search: search
51
+ });
52
+ const response = await fetch(`${baseUrl}/api/admin/urls?${queryParams}`, {
53
+ headers: {
54
+ 'Authorization': `Bearer ${apiKey}`
55
+ }
56
+ });
57
+ const result = await response.json();
58
+ if (result.success) {
59
+ setRows(result.data || []);
60
+ setTotal(result.pagination?.total || 0);
61
+ }
62
+ } catch (error) {
63
+ console.error('Failed to fetch URLs', error);
64
+ } finally {
65
+ setLoading(false);
66
+ }
67
+ }, [apiKey, baseUrl, page, pageSize, search]);
68
+
69
+ // Debounce search
70
+ useEffect(() => {
71
+ const timer = setTimeout(() => {
72
+ fetchUrls();
73
+ }, 500);
74
+ return () => clearTimeout(timer);
75
+ }, [fetchUrls]);
76
+
77
+ const handleDelete = async (id: string) => {
78
+ if (window.confirm('Are you sure you want to delete this URL?')) {
79
+ try {
80
+ const response = await fetch(`${baseUrl}/api/urls/${id}`, {
81
+ method: 'DELETE',
82
+ headers: {
83
+ 'Authorization': `Bearer ${apiKey}`
84
+ }
85
+ });
86
+ if (response.ok) {
87
+ fetchUrls(); // Refresh
88
+ }
89
+ } catch (error) {
90
+ console.error('Failed to delete URL', error);
91
+ }
92
+ }
93
+ };
94
+
95
+ const handleRowClick = (params: GridRowParams) => {
96
+ setSelectedUrlId(params.id as string);
97
+ setModalOpen(true);
98
+ };
99
+
100
+ const handleCloseModal = () => {
101
+ setModalOpen(false);
102
+ setSelectedUrlId(null);
103
+ };
104
+
105
+ const columns: GridColDef[] = [
106
+ {
107
+ field: 'shortCode',
108
+ headerName: 'Short Code',
109
+ width: 150,
110
+ renderCell: (params: any) => {
111
+ // Add referer as query parameter as fallback if browser doesn't send referer header
112
+ // For component usage, we might not know the exact "admin panel url", but we can use window.location
113
+ const currentUrl = typeof window !== 'undefined' ? window.location.href : '';
114
+ const shortUrl = `${baseUrl}/${params.value}?ref=${encodeURIComponent(currentUrl)}`;
115
+ return (
116
+ <Typography
117
+ component="a"
118
+ href={shortUrl}
119
+ target="_blank"
120
+ rel="noopener"
121
+ variant="body2"
122
+ sx={{
123
+ fontFamily: 'monospace',
124
+ bgcolor: 'action.hover',
125
+ px: 1,
126
+ py: 0.5,
127
+ borderRadius: 1,
128
+ color: 'primary.main',
129
+ textDecoration: 'none',
130
+ cursor: 'pointer',
131
+ '&:hover': {
132
+ textDecoration: 'underline',
133
+ bgcolor: 'action.selected',
134
+ },
135
+ }}
136
+ onClick={(e: React.MouseEvent) => {
137
+ e.stopPropagation(); // Prevent row click when clicking the link
138
+ }}
139
+ >
140
+ {params.value}
141
+ </Typography>
142
+ );
143
+ },
144
+ },
145
+ {
146
+ field: 'originalUrl',
147
+ headerName: 'Original URL',
148
+ flex: 1,
149
+ minWidth: 300,
150
+ },
151
+ {
152
+ field: 'clicks',
153
+ headerName: 'Clicks',
154
+ width: 100,
155
+ type: 'number',
156
+ },
157
+ {
158
+ field: 'createdAt',
159
+ headerName: 'Created At',
160
+ width: 180,
161
+ valueFormatter: (params: any) => {
162
+ if (!params.value) return '';
163
+ return new Date(params.value).toLocaleString();
164
+ },
165
+ },
166
+ {
167
+ field: 'actions',
168
+ type: 'actions',
169
+ headerName: 'Actions',
170
+ width: 100,
171
+ getActions: (params: GridRowParams) => [
172
+ <GridActionsCellItem
173
+ icon={<DeleteIcon />}
174
+ label="Delete"
175
+ onClick={() => handleDelete(params.id as string)}
176
+ color="error"
177
+ />,
178
+ ],
179
+ },
180
+ ];
181
+
182
+ if (!apiKey) {
183
+ return <Typography color="error">Authentication required</Typography>;
184
+ }
185
+
186
+ return (
187
+ <Box className={className}>
188
+ <Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 3 }}>
189
+ <Typography variant="h4">URLs Management</Typography>
190
+ <Box sx={{ display: 'flex', gap: 2 }}>
191
+ <TextField
192
+ size="small"
193
+ placeholder="Search URLs..."
194
+ value={search}
195
+ onChange={(e) => {
196
+ setSearch(e.target.value);
197
+ setPage(0); // Reset page on search
198
+ }}
199
+ sx={{ width: 300 }}
200
+ />
201
+ </Box>
202
+ </Box>
203
+
204
+ <Paper sx={{ height: 600, width: '100%' }}>
205
+ <DataGrid
206
+ rows={rows}
207
+ columns={columns}
208
+ loading={loading}
209
+ pagination
210
+ paginationModel={{ page, pageSize }}
211
+ onPaginationModelChange={(model: GridPaginationModel) => {
212
+ setPage(model.page);
213
+ setPageSize(model.pageSize);
214
+ }}
215
+ paginationMode="server"
216
+ rowCount={total}
217
+ slots={{
218
+ toolbar: GridToolbar,
219
+ }}
220
+ getRowId={(row: any) => row._id}
221
+ onRowClick={handleRowClick}
222
+ sx={{
223
+ '& .MuiDataGrid-row': {
224
+ cursor: 'pointer',
225
+ },
226
+ }}
227
+ />
228
+ </Paper>
229
+
230
+ <ShinyUrlDetailsModal
231
+ open={modalOpen}
232
+ urlId={selectedUrlId}
233
+ onClose={handleCloseModal}
234
+ apiKey={apiKey}
235
+ apiBaseUrl={baseUrl}
236
+ />
237
+ </Box>
238
+ );
239
+ };
package/src/index.ts CHANGED
@@ -1,4 +1,12 @@
1
1
  export { default as ShinyUrlInput } from './ShinyUrlInput';
2
2
  export type { ShinyUrlInputProps } from './ShinyUrlInput';
3
3
  export * from './shortener';
4
+ export { ShinyUrlDashboard } from './ShinyUrlDashboard';
5
+ export type { ShinyUrlDashboardProps } from './ShinyUrlDashboard';
6
+ export { ShinyUrlApiKeys } from './ShinyUrlApiKeys';
7
+ export type { ShinyUrlApiKeysProps } from './ShinyUrlApiKeys';
8
+ export { ShinyUrlUrls } from './ShinyUrlUrls';
9
+ export type { ShinyUrlUrlsProps } from './ShinyUrlUrls';
10
+ export { ShinyUrlDetailsModal } from './ShinyUrlDetailsModal';
11
+ export type { ShinyUrlDetailsModalProps } from './ShinyUrlDetailsModal';
4
12