shiny-url-input-box 1.0.1 → 1.1.1

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/README.md CHANGED
@@ -62,3 +62,51 @@ function App() {
62
62
  - `buttonText` (string, optional) - Submit button text (default: "Shorten URL")
63
63
  - `className` (string, optional) - Additional CSS class for the wrapper
64
64
 
65
+
66
+ ## Programmatic Usage
67
+
68
+ You can also use the exported `shortenUrl` function to shorten URLs programmatically without using the UI component.
69
+
70
+ ```javascript
71
+ import { shortenUrl } from 'shiny-url-input-box';
72
+
73
+ // Inside an async function
74
+ try {
75
+ const result = await shortenUrl(
76
+ 'https://example.com/long/url',
77
+ 'YOUR_API_KEY',
78
+ { apiBaseUrl: 'https://shinyurl-backend.onrender.com' } // Optional
79
+ );
80
+
81
+ if (result.success) {
82
+ console.log('Short URL:', result.data.shortUrl);
83
+ } else {
84
+ console.error('Error:', result.message);
85
+ }
86
+ } catch (error) {
87
+ console.error('Failed to shorten URL:', error);
88
+ }
89
+ ```
90
+
91
+ ### `shortenUrl` Arguments
92
+
93
+ 1. `url` (string): The URL to shorten.
94
+ 2. `apiKey` (string): Your API key.
95
+ 3. `options` (object, optional):
96
+ - `apiBaseUrl` (string): Base URL of the backend API. Defaults to window origin or shinyurl-backend.onrender.com.
97
+
98
+ ### Return Value
99
+
100
+ Returns a Promise that resolves to:
101
+
102
+ ```typescript
103
+ interface ShortenResponse {
104
+ success: boolean;
105
+ data?: {
106
+ originalUrl: string;
107
+ shortUrl: string;
108
+ shortCode: string;
109
+ };
110
+ message?: string;
111
+ }
112
+ ```
@@ -0,0 +1,9 @@
1
+ import React from 'react';
2
+ export interface ShinyUrlDashboardProps {
3
+ apiKey: string;
4
+ apiBaseUrl?: string;
5
+ className?: string;
6
+ title?: string;
7
+ }
8
+ export declare const ShinyUrlDashboard: React.FC<ShinyUrlDashboardProps>;
9
+ //# sourceMappingURL=ShinyUrlDashboard.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ShinyUrlDashboard.d.ts","sourceRoot":"","sources":["../src/ShinyUrlDashboard.tsx"],"names":[],"mappings":"AAAA,OAAO,KAA8B,MAAM,OAAO,CAAC;AAsBnD,MAAM,WAAW,sBAAsB;IACrC,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AA2BD,eAAO,MAAM,iBAAiB,EAAE,KAAK,CAAC,EAAE,CAAC,sBAAsB,CA8M9D,CAAC"}
@@ -0,0 +1,111 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState, useEffect } from 'react';
3
+ import { Grid, Paper, Typography, Box, Card, CardContent, CircularProgress } from '@mui/material';
4
+ import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, } from 'recharts';
5
+ import { getDefaultApiBaseUrl } from './shortener';
6
+ export const ShinyUrlDashboard = ({ apiKey, apiBaseUrl, className = '', title = 'Analytics Dashboard' }) => {
7
+ const [loading, setLoading] = useState(true);
8
+ const [error, setError] = useState(null);
9
+ const [stats, setStats] = useState(null);
10
+ const [clicks, setClicks] = useState(null);
11
+ const [topUrls, setTopUrls] = useState(null);
12
+ const baseUrl = apiBaseUrl || getDefaultApiBaseUrl();
13
+ useEffect(() => {
14
+ const fetchData = async () => {
15
+ if (!apiKey)
16
+ return;
17
+ setLoading(true);
18
+ setError(null);
19
+ try {
20
+ const headers = {
21
+ 'Authorization': `Bearer ${apiKey}`,
22
+ 'Content-Type': 'application/json'
23
+ };
24
+ // Try standard X-API-Key as well if Bearer fails?
25
+ // Admin panel uses Bearer for JWT.
26
+ // If user passes an API Key (sk_...), the backend might need 'X-API-Key'.
27
+ // For now, let's assume if it looks like a JWT (3 distinct parts), use Bearer, else X-API-Key?
28
+ // Or simpler: try to fetch with headers that match the backend's expectation for the key type.
29
+ // The prompt says "admin panel components... user can import this dashboard creds".
30
+ // If it's an "Admin Dashboard", it likely needs admin 'Bearer' token.
31
+ // If it's a "User Dashboard" (showing user's URLs), it might use 'X-API-Key'.
32
+ // The endpoints used in Admin Dashboard (/admin/...) are protected by JWT.
33
+ // Endpoints for users might be different or same.
34
+ // I will implement a fallback or accept a 'keyType' prop later if needed.
35
+ // For now, I'll follow the admin pattern (Bearer) as requested by "dashboard creds in other admin panels".
36
+ const fetchOptions = { headers };
37
+ const [statsRes, clicksRes, topUrlsRes] = await Promise.all([
38
+ fetch(`${baseUrl}/api/admin/dashboard/stats`, fetchOptions),
39
+ fetch(`${baseUrl}/api/admin/analytics/clicks?days=7`, fetchOptions),
40
+ fetch(`${baseUrl}/api/admin/analytics/top-urls?limit=10`, fetchOptions)
41
+ ]);
42
+ if (!statsRes.ok || !clicksRes.ok || !topUrlsRes.ok) {
43
+ // Check if 401
44
+ if (statsRes.status === 401 || clicksRes.status === 401 || topUrlsRes.status === 401) {
45
+ throw new Error("Unauthorized: Invalid API Key or Token");
46
+ }
47
+ throw new Error("Failed to fetch dashboard data");
48
+ }
49
+ const statsData = await statsRes.json();
50
+ const clicksData = await clicksRes.json();
51
+ const topUrlsData = await topUrlsRes.json();
52
+ setStats(statsData);
53
+ setClicks(clicksData);
54
+ setTopUrls(topUrlsData);
55
+ }
56
+ catch (err) {
57
+ if (err instanceof Error) {
58
+ setError(err.message);
59
+ }
60
+ else {
61
+ setError("An unknown error occurred");
62
+ }
63
+ }
64
+ finally {
65
+ setLoading(false);
66
+ }
67
+ };
68
+ fetchData();
69
+ }, [apiKey, baseUrl]);
70
+ if (!apiKey) {
71
+ return (_jsx(Box, { p: 3, className: className, children: _jsx(Typography, { color: "error", children: "Please provide an API Key / Token to view the dashboard." }) }));
72
+ }
73
+ if (loading) {
74
+ return (_jsx(Box, { display: "flex", justifyContent: "center", alignItems: "center", minHeight: "400px", className: className, children: _jsx(CircularProgress, {}) }));
75
+ }
76
+ if (error) {
77
+ return (_jsx(Box, { p: 3, className: className, children: _jsxs(Typography, { color: "error", children: ["Error: ", error] }) }));
78
+ }
79
+ const statCards = [
80
+ {
81
+ title: 'Total URLs',
82
+ value: stats?.stats?.totalUrls || 0,
83
+ color: '#1976d2',
84
+ },
85
+ {
86
+ title: 'Total Clicks',
87
+ value: stats?.stats?.totalClicks || 0,
88
+ color: '#2e7d32',
89
+ },
90
+ {
91
+ title: 'Unique IPs',
92
+ value: stats?.stats?.uniqueIps || 0,
93
+ color: '#ed6c02',
94
+ },
95
+ {
96
+ title: 'Clicks Today',
97
+ value: stats?.stats?.clicksToday || 0,
98
+ color: '#d32f2f',
99
+ },
100
+ ];
101
+ const chartData = clicks?.data?.map((item) => ({
102
+ date: item._id,
103
+ clicks: item.count,
104
+ })) || [];
105
+ return (_jsxs(Box, { className: className, children: [_jsx(Typography, { variant: "h4", gutterBottom: true, children: title }), _jsx(Grid, { container: true, spacing: 3, sx: { mb: 3 }, children: statCards.map((card) => (_jsx(Grid, { item: true, xs: 12, sm: 6, md: 3, children: _jsx(Card, { children: _jsxs(CardContent, { children: [_jsx(Typography, { color: "text.secondary", gutterBottom: true, children: card.title }), _jsx(Typography, { variant: "h4", sx: { color: card.color }, children: card.value.toLocaleString() })] }) }) }, card.title))) }), _jsxs(Grid, { container: true, spacing: 3, children: [_jsx(Grid, { item: true, xs: 12, md: 8, children: _jsxs(Paper, { sx: { p: 3 }, children: [_jsx(Typography, { variant: "h6", gutterBottom: true, children: "Clicks Over Time (Last 7 Days)" }), _jsx(ResponsiveContainer, { width: "100%", height: 300, children: _jsxs(LineChart, { data: chartData, children: [_jsx(CartesianGrid, { strokeDasharray: "3 3" }), _jsx(XAxis, { dataKey: "date" }), _jsx(YAxis, {}), _jsx(Tooltip, {}), _jsx(Legend, {}), _jsx(Line, { type: "monotone", dataKey: "clicks", stroke: "#1976d2", strokeWidth: 2 })] }) })] }) }), _jsx(Grid, { item: true, xs: 12, md: 4, children: _jsxs(Paper, { sx: { p: 3 }, children: [_jsx(Typography, { variant: "h6", gutterBottom: true, children: "Top URLs" }), _jsx(Box, { sx: { maxHeight: 300, overflow: 'auto' }, children: topUrls?.data?.map((url, index) => (_jsxs(Box, { sx: {
106
+ p: 1,
107
+ mb: 1,
108
+ bgcolor: 'background.default',
109
+ borderRadius: 1,
110
+ }, children: [_jsxs(Typography, { variant: "body2", noWrap: true, children: [index + 1, ". ", url.originalUrl] }), _jsxs(Typography, { variant: "caption", color: "text.secondary", children: [url.clickCount, " clicks"] })] }, url._id))) })] }) })] })] }));
111
+ };
@@ -1 +1 @@
1
- {"version":3,"file":"ShinyUrlInput.d.ts","sourceRoot":"","sources":["../src/ShinyUrlInput.tsx"],"names":[],"mappings":"AAEA,OAAO,KAAuC,MAAM,OAAO,CAAC;AAC5D,OAAO,oBAAoB,CAAC;AAE5B,MAAM,WAAW,kBAAkB;IACjC,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,SAAS,CAAC,EAAE,CAAC,IAAI,EAAE,GAAG,KAAK,IAAI,CAAC;IAChC,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IAClC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,mBAAmB,CAAC,EAAE,MAAM,CAAC;CAC9B;AAqCD,QAAA,MAAM,aAAa,EAAE,KAAK,CAAC,EAAE,CAAC,kBAAkB,CA+O/C,CAAC;AAEF,eAAe,aAAa,CAAC"}
1
+ {"version":3,"file":"ShinyUrlInput.d.ts","sourceRoot":"","sources":["../src/ShinyUrlInput.tsx"],"names":[],"mappings":"AAEA,OAAO,KAAuC,MAAM,OAAO,CAAC;AAC5D,OAAO,oBAAoB,CAAC;AAG5B,MAAM,WAAW,kBAAkB;IACjC,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,SAAS,CAAC,EAAE,CAAC,IAAI,EAAE,GAAG,KAAK,IAAI,CAAC;IAChC,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IAClC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,mBAAmB,CAAC,EAAE,MAAM,CAAC;CAC9B;AAED,QAAA,MAAM,aAAa,EAAE,KAAK,CAAC,EAAE,CAAC,kBAAkB,CAkN/C,CAAC;AAEF,eAAe,aAAa,CAAC"}
@@ -2,29 +2,7 @@
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { useState, useMemo, useEffect } from 'react';
4
4
  import './TinyUrlInput.css';
5
- // Security: Validate and sanitize URL
6
- const isValidUrl = (string) => {
7
- try {
8
- const url = new URL(string);
9
- // Only allow http and https protocols
10
- return url.protocol === 'http:' || url.protocol === 'https:';
11
- }
12
- catch (_) {
13
- return false;
14
- }
15
- };
16
- // Security: Sanitize input to prevent XSS
17
- const sanitizeInput = (input) => {
18
- return input.trim().replace(/[<>]/g, '');
19
- };
20
- // Get default API base URL (same origin or localhost:5000)
21
- const getDefaultApiBaseUrl = () => {
22
- if (typeof window !== 'undefined') {
23
- // Use same origin if available
24
- return window.location.origin;
25
- }
26
- return 'https://shinyurl-backend.onrender.com';
27
- };
5
+ import { shortenUrl, getDefaultApiBaseUrl, sanitizeInput, isValidUrl } from './shortener';
28
6
  const ShinyUrlInput = ({ apiKey, apiBaseUrl, onSuccess, onError, label = 'Enter URL to shorten', buttonText = 'Shorten URL', className = '', installationPageUrl }) => {
29
7
  const [url, setUrl] = useState('');
30
8
  const [loading, setLoading] = useState(false);
@@ -93,34 +71,13 @@ const ShinyUrlInput = ({ apiKey, apiBaseUrl, onSuccess, onError, label = 'Enter
93
71
  setShortUrl('');
94
72
  setCopied(false);
95
73
  try {
96
- // Security: Use baseUrl which is validated
97
- const response = await fetch(`${baseUrl}/api/shorten`, {
98
- method: 'POST',
99
- headers: {
100
- 'Content-Type': 'application/json',
101
- 'X-API-Key': apiKey,
102
- },
103
- body: JSON.stringify({ originalUrl: sanitizedUrl }),
104
- });
105
- if (!response.ok) {
106
- // Try to get error message from response
107
- let errorMessage = 'Failed to shorten URL';
108
- try {
109
- const errorData = await response.json();
110
- errorMessage = errorData.message || errorMessage;
111
- }
112
- catch (e) {
113
- errorMessage = `Server error: ${response.status} ${response.statusText}`;
114
- }
115
- throw new Error(errorMessage);
116
- }
117
- const data = await response.json();
118
- if (!data.success || !data.data) {
119
- throw new Error(data.message || 'Failed to shorten URL');
74
+ const result = await shortenUrl(sanitizedUrl, apiKey, { apiBaseUrl: baseUrl });
75
+ if (!result.success || !result.data) {
76
+ throw new Error(result.message || 'Failed to shorten URL');
120
77
  }
121
- setShortUrl(data.data.shortUrl);
78
+ setShortUrl(result.data.shortUrl);
122
79
  if (onSuccess) {
123
- onSuccess(data);
80
+ onSuccess(result);
124
81
  }
125
82
  }
126
83
  catch (err) {
@@ -128,10 +85,6 @@ const ShinyUrlInput = ({ apiKey, apiBaseUrl, onSuccess, onError, label = 'Enter
128
85
  if (err instanceof Error) {
129
86
  errorMessage = err.message;
130
87
  }
131
- // Handle network errors
132
- if (err instanceof TypeError && err.message.includes('fetch')) {
133
- errorMessage = 'Cannot connect to server. Make sure the backend is running on ' + baseUrl;
134
- }
135
88
  setError(errorMessage);
136
89
  if (onError) {
137
90
  onError(errorMessage);
package/dist/index.d.ts CHANGED
@@ -1,3 +1,6 @@
1
1
  export { default as ShinyUrlInput } from './ShinyUrlInput';
2
2
  export type { ShinyUrlInputProps } from './ShinyUrlInput';
3
+ export * from './shortener';
4
+ export { ShinyUrlDashboard } from './ShinyUrlDashboard';
5
+ export type { ShinyUrlDashboardProps } from './ShinyUrlDashboard';
3
6
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,IAAI,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAC3D,YAAY,EAAE,kBAAkB,EAAE,MAAM,iBAAiB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,IAAI,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAC3D,YAAY,EAAE,kBAAkB,EAAE,MAAM,iBAAiB,CAAC;AAC1D,cAAc,aAAa,CAAC;AAC5B,OAAO,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAC;AACxD,YAAY,EAAE,sBAAsB,EAAE,MAAM,qBAAqB,CAAC"}
package/dist/index.js CHANGED
@@ -1 +1,3 @@
1
1
  export { default as ShinyUrlInput } from './ShinyUrlInput';
2
+ export * from './shortener';
3
+ export { ShinyUrlDashboard } from './ShinyUrlDashboard';
@@ -0,0 +1,17 @@
1
+ export interface ShortenResponse {
2
+ success: boolean;
3
+ data?: {
4
+ originalUrl: string;
5
+ shortUrl: string;
6
+ shortCode: string;
7
+ };
8
+ message?: string;
9
+ }
10
+ export interface ShortenOptions {
11
+ apiBaseUrl?: string;
12
+ }
13
+ export declare const isValidUrl: (string: string) => boolean;
14
+ export declare const sanitizeInput: (input: string) => string;
15
+ export declare const getDefaultApiBaseUrl: () => string;
16
+ export declare const shortenUrl: (originalUrl: string, apiKey: string, options?: ShortenOptions) => Promise<ShortenResponse>;
17
+ //# sourceMappingURL=shortener.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"shortener.d.ts","sourceRoot":"","sources":["../src/shortener.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,eAAe;IAC9B,OAAO,EAAE,OAAO,CAAC;IACjB,IAAI,CAAC,EAAE;QACL,WAAW,EAAE,MAAM,CAAC;QACpB,QAAQ,EAAE,MAAM,CAAC;QACjB,SAAS,EAAE,MAAM,CAAC;KACnB,CAAC;IACF,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,cAAc;IAC7B,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAGD,eAAO,MAAM,UAAU,GAAI,QAAQ,MAAM,KAAG,OAQ3C,CAAC;AAGF,eAAO,MAAM,aAAa,GAAI,OAAO,MAAM,KAAG,MAE7C,CAAC;AAGF,eAAO,MAAM,oBAAoB,QAAO,MAMvC,CAAC;AAEF,eAAO,MAAM,UAAU,GACrB,aAAa,MAAM,EACnB,QAAQ,MAAM,EACd,UAAS,cAAmB,KAC3B,OAAO,CAAC,eAAe,CAiDzB,CAAC"}
@@ -0,0 +1,66 @@
1
+ // Security: Validate and sanitize URL
2
+ export const isValidUrl = (string) => {
3
+ try {
4
+ const url = new URL(string);
5
+ // Only allow http and https protocols
6
+ return url.protocol === 'http:' || url.protocol === 'https:';
7
+ }
8
+ catch (_) {
9
+ return false;
10
+ }
11
+ };
12
+ // Security: Sanitize input to prevent XSS
13
+ export const sanitizeInput = (input) => {
14
+ return input.trim().replace(/[<>]/g, '');
15
+ };
16
+ // Get default API base URL (same origin or localhost:5000)
17
+ export const getDefaultApiBaseUrl = () => {
18
+ if (typeof window !== 'undefined') {
19
+ // Use same origin if available
20
+ return window.location.origin;
21
+ }
22
+ return 'https://shinyurl-backend.onrender.com';
23
+ };
24
+ export const shortenUrl = async (originalUrl, apiKey, options = {}) => {
25
+ const sanitizedUrl = sanitizeInput(originalUrl);
26
+ if (!sanitizedUrl) {
27
+ return { success: false, message: 'Please enter a URL' };
28
+ }
29
+ if (!isValidUrl(sanitizedUrl)) {
30
+ return { success: false, message: 'Please enter a valid URL (must start with http:// or https://)' };
31
+ }
32
+ const baseUrl = options.apiBaseUrl || getDefaultApiBaseUrl();
33
+ try {
34
+ const response = await fetch(`${baseUrl}/api/shorten`, {
35
+ method: 'POST',
36
+ headers: {
37
+ 'Content-Type': 'application/json',
38
+ 'X-API-Key': apiKey,
39
+ },
40
+ body: JSON.stringify({ originalUrl: sanitizedUrl }),
41
+ });
42
+ if (!response.ok) {
43
+ let errorMessage = 'Failed to shorten URL';
44
+ try {
45
+ const errorData = await response.json();
46
+ errorMessage = errorData.message || errorMessage;
47
+ }
48
+ catch (e) {
49
+ errorMessage = `Server error: ${response.status} ${response.statusText}`;
50
+ }
51
+ return { success: false, message: errorMessage };
52
+ }
53
+ const data = await response.json();
54
+ return data;
55
+ }
56
+ catch (err) {
57
+ let errorMessage = 'An error occurred';
58
+ if (err instanceof Error) {
59
+ errorMessage = err.message;
60
+ }
61
+ if (err instanceof TypeError && typeof err.message === 'string' && err.message.includes('fetch')) {
62
+ errorMessage = 'Cannot connect to server. Make sure the backend is running on ' + baseUrl;
63
+ }
64
+ return { success: false, message: errorMessage };
65
+ }
66
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shiny-url-input-box",
3
- "version": "1.0.1",
3
+ "version": "1.1.1",
4
4
  "description": "A reusable React component for generating ShinyURLs",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -22,14 +22,23 @@
22
22
  ],
23
23
  "author": "",
24
24
  "license": "ISC",
25
- "peerDependencies": {
26
- "react": "^18.0.0",
27
- "react-dom": "^18.0.0"
28
- },
29
25
  "devDependencies": {
30
26
  "@types/react": "^18.2.45",
31
27
  "@types/react-dom": "^18.2.18",
32
- "typescript": "^5.3.3"
28
+ "typescript": "^5.3.3",
29
+ "@types/recharts": "^1.8.29",
30
+ "@mui/material": "^5.15.2",
31
+ "@emotion/react": "^11.11.3",
32
+ "@emotion/styled": "^11.11.0",
33
+ "@mui/icons-material": "^5.15.2",
34
+ "recharts": "^2.10.3"
35
+ },
36
+ "peerDependencies": {
37
+ "react": "^18.0.0",
38
+ "react-dom": "^18.0.0",
39
+ "@mui/material": "^5.0.0",
40
+ "@emotion/react": "^11.0.0",
41
+ "@emotion/styled": "^11.0.0",
42
+ "recharts": "^2.0.0"
33
43
  }
34
- }
35
-
44
+ }
@@ -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
+ };
@@ -2,6 +2,7 @@
2
2
 
3
3
  import React, { useState, useMemo, useEffect } from 'react';
4
4
  import './TinyUrlInput.css';
5
+ import { shortenUrl, ShortenResponse, getDefaultApiBaseUrl, sanitizeInput, isValidUrl } from './shortener';
5
6
 
6
7
  export interface ShinyUrlInputProps {
7
8
  apiKey: string;
@@ -14,41 +15,6 @@ export interface ShinyUrlInputProps {
14
15
  installationPageUrl?: string;
15
16
  }
16
17
 
17
- interface ShortenResponse {
18
- success: boolean;
19
- data?: {
20
- originalUrl: string;
21
- shortUrl: string;
22
- shortCode: string;
23
- };
24
- message?: string;
25
- }
26
-
27
- // Security: Validate and sanitize URL
28
- const isValidUrl = (string: string): boolean => {
29
- try {
30
- const url = new URL(string);
31
- // Only allow http and https protocols
32
- return url.protocol === 'http:' || url.protocol === 'https:';
33
- } catch (_) {
34
- return false;
35
- }
36
- };
37
-
38
- // Security: Sanitize input to prevent XSS
39
- const sanitizeInput = (input: string): string => {
40
- return input.trim().replace(/[<>]/g, '');
41
- };
42
-
43
- // Get default API base URL (same origin or localhost:5000)
44
- const getDefaultApiBaseUrl = (): string => {
45
- if (typeof window !== 'undefined') {
46
- // Use same origin if available
47
- return window.location.origin;
48
- }
49
- return 'https://shinyurl-backend.onrender.com';
50
- };
51
-
52
18
  const ShinyUrlInput: React.FC<ShinyUrlInputProps> = ({
53
19
  apiKey,
54
20
  apiBaseUrl,
@@ -135,51 +101,22 @@ const ShinyUrlInput: React.FC<ShinyUrlInputProps> = ({
135
101
  setCopied(false);
136
102
 
137
103
  try {
138
- // Security: Use baseUrl which is validated
139
- const response = await fetch(`${baseUrl}/api/shorten`, {
140
- method: 'POST',
141
- headers: {
142
- 'Content-Type': 'application/json',
143
- 'X-API-Key': apiKey,
144
- },
145
- body: JSON.stringify({ originalUrl: sanitizedUrl }),
146
- });
104
+ const result = await shortenUrl(sanitizedUrl, apiKey, { apiBaseUrl: baseUrl });
147
105
 
148
- if (!response.ok) {
149
- // Try to get error message from response
150
- let errorMessage = 'Failed to shorten URL';
151
- try {
152
- const errorData: ShortenResponse = await response.json();
153
- errorMessage = errorData.message || errorMessage;
154
- } catch (e) {
155
- errorMessage = `Server error: ${response.status} ${response.statusText}`;
156
- }
157
- throw new Error(errorMessage);
158
- }
159
-
160
- const data: ShortenResponse = await response.json();
161
-
162
- if (!data.success || !data.data) {
163
- throw new Error(data.message || 'Failed to shorten URL');
106
+ if (!result.success || !result.data) {
107
+ throw new Error(result.message || 'Failed to shorten URL');
164
108
  }
165
109
 
166
- setShortUrl(data.data.shortUrl);
110
+ setShortUrl(result.data.shortUrl);
167
111
 
168
112
  if (onSuccess) {
169
- onSuccess(data);
113
+ onSuccess(result);
170
114
  }
171
115
  } catch (err) {
172
116
  let errorMessage = 'An error occurred';
173
-
174
117
  if (err instanceof Error) {
175
118
  errorMessage = err.message;
176
119
  }
177
-
178
- // Handle network errors
179
- if (err instanceof TypeError && err.message.includes('fetch')) {
180
- errorMessage = 'Cannot connect to server. Make sure the backend is running on ' + baseUrl;
181
- }
182
-
183
120
  setError(errorMessage);
184
121
 
185
122
  if (onError) {
package/src/index.ts CHANGED
@@ -1,3 +1,6 @@
1
1
  export { default as ShinyUrlInput } from './ShinyUrlInput';
2
2
  export type { ShinyUrlInputProps } from './ShinyUrlInput';
3
+ export * from './shortener';
4
+ export { ShinyUrlDashboard } from './ShinyUrlDashboard';
5
+ export type { ShinyUrlDashboardProps } from './ShinyUrlDashboard';
3
6
 
@@ -0,0 +1,93 @@
1
+ export interface ShortenResponse {
2
+ success: boolean;
3
+ data?: {
4
+ originalUrl: string;
5
+ shortUrl: string;
6
+ shortCode: string;
7
+ };
8
+ message?: string;
9
+ }
10
+
11
+ export interface ShortenOptions {
12
+ apiBaseUrl?: string;
13
+ }
14
+
15
+ // Security: Validate and sanitize URL
16
+ export const isValidUrl = (string: string): boolean => {
17
+ try {
18
+ const url = new URL(string);
19
+ // Only allow http and https protocols
20
+ return url.protocol === 'http:' || url.protocol === 'https:';
21
+ } catch (_) {
22
+ return false;
23
+ }
24
+ };
25
+
26
+ // Security: Sanitize input to prevent XSS
27
+ export const sanitizeInput = (input: string): string => {
28
+ return input.trim().replace(/[<>]/g, '');
29
+ };
30
+
31
+ // Get default API base URL (same origin or localhost:5000)
32
+ export const getDefaultApiBaseUrl = (): string => {
33
+ if (typeof window !== 'undefined') {
34
+ // Use same origin if available
35
+ return window.location.origin;
36
+ }
37
+ return 'https://shinyurl-backend.onrender.com';
38
+ };
39
+
40
+ export const shortenUrl = async (
41
+ originalUrl: string,
42
+ apiKey: string,
43
+ options: ShortenOptions = {}
44
+ ): Promise<ShortenResponse> => {
45
+ const sanitizedUrl = sanitizeInput(originalUrl);
46
+
47
+ if (!sanitizedUrl) {
48
+ return { success: false, message: 'Please enter a URL' };
49
+ }
50
+
51
+ if (!isValidUrl(sanitizedUrl)) {
52
+ return { success: false, message: 'Please enter a valid URL (must start with http:// or https://)' };
53
+ }
54
+
55
+ const baseUrl = options.apiBaseUrl || getDefaultApiBaseUrl();
56
+
57
+ try {
58
+ const response = await fetch(`${baseUrl}/api/shorten`, {
59
+ method: 'POST',
60
+ headers: {
61
+ 'Content-Type': 'application/json',
62
+ 'X-API-Key': apiKey,
63
+ },
64
+ body: JSON.stringify({ originalUrl: sanitizedUrl }),
65
+ });
66
+
67
+ if (!response.ok) {
68
+ let errorMessage = 'Failed to shorten URL';
69
+ try {
70
+ const errorData = await response.json();
71
+ errorMessage = errorData.message || errorMessage;
72
+ } catch (e) {
73
+ errorMessage = `Server error: ${response.status} ${response.statusText}`;
74
+ }
75
+ return { success: false, message: errorMessage };
76
+ }
77
+
78
+ const data: ShortenResponse = await response.json();
79
+ return data;
80
+ } catch (err: unknown) {
81
+ let errorMessage = 'An error occurred';
82
+
83
+ if (err instanceof Error) {
84
+ errorMessage = err.message;
85
+ }
86
+
87
+ if (err instanceof TypeError && typeof err.message === 'string' && err.message.includes('fetch')) {
88
+ errorMessage = 'Cannot connect to server. Make sure the backend is running on ' + baseUrl;
89
+ }
90
+
91
+ return { success: false, message: errorMessage };
92
+ }
93
+ };