shiny-url-input-box 1.0.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/README.md +64 -0
- package/dist/ShinyUrlInput.d.ts +15 -0
- package/dist/ShinyUrlInput.d.ts.map +1 -0
- package/dist/ShinyUrlInput.js +166 -0
- package/dist/TinyUrlInput.d.ts +13 -0
- package/dist/TinyUrlInput.d.ts.map +1 -0
- package/dist/TinyUrlInput.js +119 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1 -0
- package/package.json +35 -0
- package/src/ShinyUrlInput.tsx +294 -0
- package/src/TinyUrlInput.css +127 -0
- package/src/TinyUrlInput.jsx +193 -0
- package/src/TinyUrlInput.tsx +218 -0
- package/src/index.js +2 -0
- package/src/index.ts +3 -0
- package/tsconfig.json +23 -0
package/README.md
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# ShinyURL Input Box
|
|
2
|
+
|
|
3
|
+
A reusable React component for generating ShinyURLs.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install shiny-url-input-box
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Or use as a local package:
|
|
12
|
+
|
|
13
|
+
```json
|
|
14
|
+
{
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"shiny-url-input-box": "file:../package"
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Usage
|
|
22
|
+
|
|
23
|
+
```javascript
|
|
24
|
+
import { ShinyUrlInput } from 'shiny-url-input-box';
|
|
25
|
+
|
|
26
|
+
function App() {
|
|
27
|
+
const handleSuccess = (data) => {
|
|
28
|
+
console.log('Short URL created:', data);
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const handleError = (error) => {
|
|
32
|
+
console.error('Error:', error);
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
// With explicit API URL
|
|
36
|
+
return (
|
|
37
|
+
<ShinyUrlInput
|
|
38
|
+
apiBaseUrl="http://localhost:5000"
|
|
39
|
+
onSuccess={handleSuccess}
|
|
40
|
+
onError={handleError}
|
|
41
|
+
label="Enter URL to shorten"
|
|
42
|
+
buttonText="Shorten URL"
|
|
43
|
+
/>
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
// Or without apiBaseUrl (uses same origin automatically)
|
|
47
|
+
return (
|
|
48
|
+
<ShinyUrlInput
|
|
49
|
+
onSuccess={handleSuccess}
|
|
50
|
+
onError={handleError}
|
|
51
|
+
/>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Props
|
|
57
|
+
|
|
58
|
+
- `apiBaseUrl` (string, optional) - Base URL of the backend API. If not provided, defaults to `window.location.origin` (same origin as the frontend)
|
|
59
|
+
- `onSuccess` (function, optional) - Callback called when URL is successfully shortened
|
|
60
|
+
- `onError` (function, optional) - Callback called when an error occurs
|
|
61
|
+
- `label` (string, optional) - Label text above input (default: "Enter URL to shorten")
|
|
62
|
+
- `buttonText` (string, optional) - Submit button text (default: "Shorten URL")
|
|
63
|
+
- `className` (string, optional) - Additional CSS class for the wrapper
|
|
64
|
+
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import './TinyUrlInput.css';
|
|
3
|
+
export interface ShinyUrlInputProps {
|
|
4
|
+
apiKey: string;
|
|
5
|
+
apiBaseUrl?: string;
|
|
6
|
+
onSuccess?: (data: any) => void;
|
|
7
|
+
onError?: (error: string) => void;
|
|
8
|
+
label?: string;
|
|
9
|
+
buttonText?: string;
|
|
10
|
+
className?: string;
|
|
11
|
+
installationPageUrl?: string;
|
|
12
|
+
}
|
|
13
|
+
declare const ShinyUrlInput: React.FC<ShinyUrlInputProps>;
|
|
14
|
+
export default ShinyUrlInput;
|
|
15
|
+
//# sourceMappingURL=ShinyUrlInput.d.ts.map
|
|
@@ -0,0 +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"}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useState, useMemo, useEffect } from 'react';
|
|
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 'http://localhost:5000';
|
|
27
|
+
};
|
|
28
|
+
const ShinyUrlInput = ({ apiKey, apiBaseUrl, onSuccess, onError, label = 'Enter URL to shorten', buttonText = 'Shorten URL', className = '', installationPageUrl }) => {
|
|
29
|
+
const [url, setUrl] = useState('');
|
|
30
|
+
const [loading, setLoading] = useState(false);
|
|
31
|
+
const [error, setError] = useState('');
|
|
32
|
+
const [shortUrl, setShortUrl] = useState('');
|
|
33
|
+
const [copied, setCopied] = useState(false);
|
|
34
|
+
const [keyValid, setKeyValid] = useState(null);
|
|
35
|
+
const [keyError, setKeyError] = useState('');
|
|
36
|
+
const [validatingKey, setValidatingKey] = useState(true);
|
|
37
|
+
// Use provided apiBaseUrl or fallback to default
|
|
38
|
+
const baseUrl = useMemo(() => {
|
|
39
|
+
return apiBaseUrl || getDefaultApiBaseUrl();
|
|
40
|
+
}, [apiBaseUrl]);
|
|
41
|
+
// Validate API key on mount
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
const validateApiKey = async () => {
|
|
44
|
+
if (!apiKey) {
|
|
45
|
+
setKeyValid(false);
|
|
46
|
+
setKeyError('API key is required');
|
|
47
|
+
setValidatingKey(false);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
try {
|
|
51
|
+
const response = await fetch(`${baseUrl}/api/users/api-keys/validate`, {
|
|
52
|
+
method: 'POST',
|
|
53
|
+
headers: {
|
|
54
|
+
'Content-Type': 'application/json',
|
|
55
|
+
},
|
|
56
|
+
body: JSON.stringify({ apiKey }),
|
|
57
|
+
});
|
|
58
|
+
const data = await response.json();
|
|
59
|
+
if (data.success && data.valid) {
|
|
60
|
+
setKeyValid(true);
|
|
61
|
+
setKeyError('');
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
setKeyValid(false);
|
|
65
|
+
setKeyError(data.message || 'Invalid or expired API key');
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
catch (err) {
|
|
69
|
+
setKeyValid(false);
|
|
70
|
+
setKeyError('Failed to validate API key. Please check your connection.');
|
|
71
|
+
}
|
|
72
|
+
finally {
|
|
73
|
+
setValidatingKey(false);
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
validateApiKey();
|
|
77
|
+
}, [apiKey, baseUrl]);
|
|
78
|
+
const handleSubmit = async (e) => {
|
|
79
|
+
e.preventDefault();
|
|
80
|
+
// Sanitize input
|
|
81
|
+
const sanitizedUrl = sanitizeInput(url);
|
|
82
|
+
if (!sanitizedUrl) {
|
|
83
|
+
setError('Please enter a URL');
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
// Security: Validate URL format
|
|
87
|
+
if (!isValidUrl(sanitizedUrl)) {
|
|
88
|
+
setError('Please enter a valid URL (must start with http:// or https://)');
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
setLoading(true);
|
|
92
|
+
setError('');
|
|
93
|
+
setShortUrl('');
|
|
94
|
+
setCopied(false);
|
|
95
|
+
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');
|
|
120
|
+
}
|
|
121
|
+
setShortUrl(data.data.shortUrl);
|
|
122
|
+
if (onSuccess) {
|
|
123
|
+
onSuccess(data);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
catch (err) {
|
|
127
|
+
let errorMessage = 'An error occurred';
|
|
128
|
+
if (err instanceof Error) {
|
|
129
|
+
errorMessage = err.message;
|
|
130
|
+
}
|
|
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
|
+
setError(errorMessage);
|
|
136
|
+
if (onError) {
|
|
137
|
+
onError(errorMessage);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
finally {
|
|
141
|
+
setLoading(false);
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
const handleCopy = async () => {
|
|
145
|
+
try {
|
|
146
|
+
await navigator.clipboard.writeText(shortUrl);
|
|
147
|
+
setCopied(true);
|
|
148
|
+
setTimeout(() => setCopied(false), 2000);
|
|
149
|
+
}
|
|
150
|
+
catch (err) {
|
|
151
|
+
console.error('Failed to copy:', err);
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
// Show loading state while validating key (but still show input)
|
|
155
|
+
// Show error if API key is invalid (but still show input with warning)
|
|
156
|
+
return (_jsxs("div", { className: `tiny-url-input-wrapper ${className}`, children: [validatingKey && (_jsx("div", { className: "tiny-url-loading", style: { marginBottom: '10px', fontSize: '14px' }, children: "Validating API key..." })), keyValid === false && !validatingKey && (_jsxs("div", { className: "tiny-url-error", style: { marginBottom: '15px', padding: '10px', fontSize: '14px' }, children: [_jsx("strong", { children: "\u26A0\uFE0F Warning:" }), " ", keyError || 'API key validation failed', ". The component may not work correctly.", installationPageUrl && (_jsx("div", { style: { marginTop: '5px' }, children: _jsx("a", { href: installationPageUrl, target: "_blank", rel: "noopener noreferrer", style: {
|
|
157
|
+
color: '#0066cc',
|
|
158
|
+
textDecoration: 'underline',
|
|
159
|
+
fontSize: '12px'
|
|
160
|
+
}, children: "Learn how to get your API key \u2192" }) }))] })), _jsxs("form", { onSubmit: handleSubmit, className: "tiny-url-form", children: [_jsx("label", { htmlFor: "url-input", className: "tiny-url-label", children: label }), _jsxs("div", { className: "tiny-url-input-group", children: [_jsx("input", { id: "url-input", type: "text", value: url, onChange: (e) => {
|
|
161
|
+
// Basic input sanitization
|
|
162
|
+
const value = e.target.value;
|
|
163
|
+
setUrl(value);
|
|
164
|
+
}, placeholder: "https://example.com/very/long/url", className: "tiny-url-input", disabled: loading }), _jsx("button", { type: "submit", className: "tiny-url-button", disabled: loading, children: loading ? 'Shortening...' : buttonText })] })] }), error && (_jsx("div", { className: "tiny-url-error", children: error })), shortUrl && (_jsxs("div", { className: "tiny-url-result", children: [_jsx("div", { className: "tiny-url-result-label", children: "Short URL:" }), _jsxs("div", { className: "tiny-url-result-content", children: [_jsx("a", { href: shortUrl, target: "_blank", rel: "noopener noreferrer", className: "tiny-url-link", children: shortUrl }), _jsx("button", { onClick: handleCopy, className: "tiny-url-copy-button", children: copied ? 'Copied!' : 'Copy' })] })] }))] }));
|
|
165
|
+
};
|
|
166
|
+
export default ShinyUrlInput;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import './TinyUrlInput.css';
|
|
3
|
+
export interface TinyUrlInputProps {
|
|
4
|
+
apiBaseUrl?: string;
|
|
5
|
+
onSuccess?: (data: any) => void;
|
|
6
|
+
onError?: (error: string) => void;
|
|
7
|
+
label?: string;
|
|
8
|
+
buttonText?: string;
|
|
9
|
+
className?: string;
|
|
10
|
+
}
|
|
11
|
+
declare const TinyUrlInput: React.FC<TinyUrlInputProps>;
|
|
12
|
+
export default TinyUrlInput;
|
|
13
|
+
//# sourceMappingURL=TinyUrlInput.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"TinyUrlInput.d.ts","sourceRoot":"","sources":["../src/TinyUrlInput.tsx"],"names":[],"mappings":"AAEA,OAAO,KAA4B,MAAM,OAAO,CAAC;AACjD,OAAO,oBAAoB,CAAC;AAE5B,MAAM,WAAW,iBAAiB;IAChC,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;CACpB;AAqCD,QAAA,MAAM,YAAY,EAAE,KAAK,CAAC,EAAE,CAAC,iBAAiB,CAqK7C,CAAC;AAEF,eAAe,YAAY,CAAC"}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useState, useMemo } from 'react';
|
|
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 'http://localhost:5000';
|
|
27
|
+
};
|
|
28
|
+
const TinyUrlInput = ({ apiBaseUrl, onSuccess, onError, label = 'Enter URL to shorten', buttonText = 'Shorten URL', className = '' }) => {
|
|
29
|
+
const [url, setUrl] = useState('');
|
|
30
|
+
const [loading, setLoading] = useState(false);
|
|
31
|
+
const [error, setError] = useState('');
|
|
32
|
+
const [shortUrl, setShortUrl] = useState('');
|
|
33
|
+
const [copied, setCopied] = useState(false);
|
|
34
|
+
// Use provided apiBaseUrl or fallback to default
|
|
35
|
+
const baseUrl = useMemo(() => {
|
|
36
|
+
return apiBaseUrl || getDefaultApiBaseUrl();
|
|
37
|
+
}, [apiBaseUrl]);
|
|
38
|
+
const handleSubmit = async (e) => {
|
|
39
|
+
e.preventDefault();
|
|
40
|
+
// Sanitize input
|
|
41
|
+
const sanitizedUrl = sanitizeInput(url);
|
|
42
|
+
if (!sanitizedUrl) {
|
|
43
|
+
setError('Please enter a URL');
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
// Security: Validate URL format
|
|
47
|
+
if (!isValidUrl(sanitizedUrl)) {
|
|
48
|
+
setError('Please enter a valid URL (must start with http:// or https://)');
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
setLoading(true);
|
|
52
|
+
setError('');
|
|
53
|
+
setShortUrl('');
|
|
54
|
+
setCopied(false);
|
|
55
|
+
try {
|
|
56
|
+
// Security: Use baseUrl which is validated
|
|
57
|
+
const response = await fetch(`${baseUrl}/api/shorten`, {
|
|
58
|
+
method: 'POST',
|
|
59
|
+
headers: {
|
|
60
|
+
'Content-Type': 'application/json',
|
|
61
|
+
},
|
|
62
|
+
body: JSON.stringify({ originalUrl: sanitizedUrl }),
|
|
63
|
+
});
|
|
64
|
+
if (!response.ok) {
|
|
65
|
+
// Try to get error message from response
|
|
66
|
+
let errorMessage = 'Failed to shorten URL';
|
|
67
|
+
try {
|
|
68
|
+
const errorData = await response.json();
|
|
69
|
+
errorMessage = errorData.message || errorMessage;
|
|
70
|
+
}
|
|
71
|
+
catch (e) {
|
|
72
|
+
errorMessage = `Server error: ${response.status} ${response.statusText}`;
|
|
73
|
+
}
|
|
74
|
+
throw new Error(errorMessage);
|
|
75
|
+
}
|
|
76
|
+
const data = await response.json();
|
|
77
|
+
if (!data.success || !data.data) {
|
|
78
|
+
throw new Error(data.message || 'Failed to shorten URL');
|
|
79
|
+
}
|
|
80
|
+
setShortUrl(data.data.shortUrl);
|
|
81
|
+
if (onSuccess) {
|
|
82
|
+
onSuccess(data);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
catch (err) {
|
|
86
|
+
let errorMessage = 'An error occurred';
|
|
87
|
+
if (err instanceof Error) {
|
|
88
|
+
errorMessage = err.message;
|
|
89
|
+
}
|
|
90
|
+
// Handle network errors
|
|
91
|
+
if (err instanceof TypeError && err.message.includes('fetch')) {
|
|
92
|
+
errorMessage = 'Cannot connect to server. Make sure the backend is running on ' + baseUrl;
|
|
93
|
+
}
|
|
94
|
+
setError(errorMessage);
|
|
95
|
+
if (onError) {
|
|
96
|
+
onError(errorMessage);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
finally {
|
|
100
|
+
setLoading(false);
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
const handleCopy = async () => {
|
|
104
|
+
try {
|
|
105
|
+
await navigator.clipboard.writeText(shortUrl);
|
|
106
|
+
setCopied(true);
|
|
107
|
+
setTimeout(() => setCopied(false), 2000);
|
|
108
|
+
}
|
|
109
|
+
catch (err) {
|
|
110
|
+
console.error('Failed to copy:', err);
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
return (_jsxs("div", { className: `tiny-url-input-wrapper ${className}`, children: [_jsxs("form", { onSubmit: handleSubmit, className: "tiny-url-form", children: [_jsx("label", { htmlFor: "url-input", className: "tiny-url-label", children: label }), _jsxs("div", { className: "tiny-url-input-group", children: [_jsx("input", { id: "url-input", type: "text", value: url, onChange: (e) => {
|
|
114
|
+
// Basic input sanitization
|
|
115
|
+
const value = e.target.value;
|
|
116
|
+
setUrl(value);
|
|
117
|
+
}, placeholder: "https://example.com/very/long/url", className: "tiny-url-input", disabled: loading }), _jsx("button", { type: "submit", className: "tiny-url-button", disabled: loading, children: loading ? 'Shortening...' : buttonText })] })] }), error && (_jsx("div", { className: "tiny-url-error", children: error })), shortUrl && (_jsxs("div", { className: "tiny-url-result", children: [_jsx("div", { className: "tiny-url-result-label", children: "Short URL:" }), _jsxs("div", { className: "tiny-url-result-content", children: [_jsx("a", { href: shortUrl, target: "_blank", rel: "noopener noreferrer", className: "tiny-url-link", children: shortUrl }), _jsx("button", { onClick: handleCopy, className: "tiny-url-copy-button", children: copied ? 'Copied!' : 'Copy' })] })] }))] }));
|
|
118
|
+
};
|
|
119
|
+
export default TinyUrlInput;
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +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"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default as ShinyUrlInput } from './ShinyUrlInput';
|
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "shiny-url-input-box",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "A reusable React component for generating ShinyURLs",
|
|
5
|
+
"main": "src/index.ts",
|
|
6
|
+
"types": "src/index.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"import": "./src/index.ts",
|
|
10
|
+
"require": "./src/index.ts",
|
|
11
|
+
"types": "./src/index.ts"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"scripts": {
|
|
15
|
+
"build": "tsc",
|
|
16
|
+
"test": "echo \"Error: no test specified\" && exit 1"
|
|
17
|
+
},
|
|
18
|
+
"keywords": [
|
|
19
|
+
"react",
|
|
20
|
+
"shiny-url",
|
|
21
|
+
"url-shortener"
|
|
22
|
+
],
|
|
23
|
+
"author": "",
|
|
24
|
+
"license": "ISC",
|
|
25
|
+
"peerDependencies": {
|
|
26
|
+
"react": "^18.0.0",
|
|
27
|
+
"react-dom": "^18.0.0"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"@types/react": "^18.2.45",
|
|
31
|
+
"@types/react-dom": "^18.2.18",
|
|
32
|
+
"typescript": "^5.3.3"
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useState, useMemo, useEffect } from 'react';
|
|
4
|
+
import './TinyUrlInput.css';
|
|
5
|
+
|
|
6
|
+
export interface ShinyUrlInputProps {
|
|
7
|
+
apiKey: string;
|
|
8
|
+
apiBaseUrl?: string;
|
|
9
|
+
onSuccess?: (data: any) => void;
|
|
10
|
+
onError?: (error: string) => void;
|
|
11
|
+
label?: string;
|
|
12
|
+
buttonText?: string;
|
|
13
|
+
className?: string;
|
|
14
|
+
installationPageUrl?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
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 'http://localhost:5000';
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const ShinyUrlInput: React.FC<ShinyUrlInputProps> = ({
|
|
53
|
+
apiKey,
|
|
54
|
+
apiBaseUrl,
|
|
55
|
+
onSuccess,
|
|
56
|
+
onError,
|
|
57
|
+
label = 'Enter URL to shorten',
|
|
58
|
+
buttonText = 'Shorten URL',
|
|
59
|
+
className = '',
|
|
60
|
+
installationPageUrl
|
|
61
|
+
}) => {
|
|
62
|
+
const [url, setUrl] = useState<string>('');
|
|
63
|
+
const [loading, setLoading] = useState<boolean>(false);
|
|
64
|
+
const [error, setError] = useState<string>('');
|
|
65
|
+
const [shortUrl, setShortUrl] = useState<string>('');
|
|
66
|
+
const [copied, setCopied] = useState<boolean>(false);
|
|
67
|
+
const [keyValid, setKeyValid] = useState<boolean | null>(null);
|
|
68
|
+
const [keyError, setKeyError] = useState<string>('');
|
|
69
|
+
const [validatingKey, setValidatingKey] = useState<boolean>(true);
|
|
70
|
+
|
|
71
|
+
// Use provided apiBaseUrl or fallback to default
|
|
72
|
+
const baseUrl = useMemo(() => {
|
|
73
|
+
return apiBaseUrl || getDefaultApiBaseUrl();
|
|
74
|
+
}, [apiBaseUrl]);
|
|
75
|
+
|
|
76
|
+
// Validate API key on mount
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
const validateApiKey = async () => {
|
|
79
|
+
if (!apiKey) {
|
|
80
|
+
setKeyValid(false);
|
|
81
|
+
setKeyError('API key is required');
|
|
82
|
+
setValidatingKey(false);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
try {
|
|
87
|
+
const response = await fetch(`${baseUrl}/api/users/api-keys/validate`, {
|
|
88
|
+
method: 'POST',
|
|
89
|
+
headers: {
|
|
90
|
+
'Content-Type': 'application/json',
|
|
91
|
+
},
|
|
92
|
+
body: JSON.stringify({ apiKey }),
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const data = await response.json();
|
|
96
|
+
|
|
97
|
+
if (data.success && data.valid) {
|
|
98
|
+
setKeyValid(true);
|
|
99
|
+
setKeyError('');
|
|
100
|
+
} else {
|
|
101
|
+
setKeyValid(false);
|
|
102
|
+
setKeyError(data.message || 'Invalid or expired API key');
|
|
103
|
+
}
|
|
104
|
+
} catch (err) {
|
|
105
|
+
setKeyValid(false);
|
|
106
|
+
setKeyError('Failed to validate API key. Please check your connection.');
|
|
107
|
+
} finally {
|
|
108
|
+
setValidatingKey(false);
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
validateApiKey();
|
|
113
|
+
}, [apiKey, baseUrl]);
|
|
114
|
+
|
|
115
|
+
const handleSubmit = async (e: React.FormEvent) => {
|
|
116
|
+
e.preventDefault();
|
|
117
|
+
|
|
118
|
+
// Sanitize input
|
|
119
|
+
const sanitizedUrl = sanitizeInput(url);
|
|
120
|
+
|
|
121
|
+
if (!sanitizedUrl) {
|
|
122
|
+
setError('Please enter a URL');
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Security: Validate URL format
|
|
127
|
+
if (!isValidUrl(sanitizedUrl)) {
|
|
128
|
+
setError('Please enter a valid URL (must start with http:// or https://)');
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
setLoading(true);
|
|
133
|
+
setError('');
|
|
134
|
+
setShortUrl('');
|
|
135
|
+
setCopied(false);
|
|
136
|
+
|
|
137
|
+
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
|
+
});
|
|
147
|
+
|
|
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');
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
setShortUrl(data.data.shortUrl);
|
|
167
|
+
|
|
168
|
+
if (onSuccess) {
|
|
169
|
+
onSuccess(data);
|
|
170
|
+
}
|
|
171
|
+
} catch (err) {
|
|
172
|
+
let errorMessage = 'An error occurred';
|
|
173
|
+
|
|
174
|
+
if (err instanceof Error) {
|
|
175
|
+
errorMessage = err.message;
|
|
176
|
+
}
|
|
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
|
+
setError(errorMessage);
|
|
184
|
+
|
|
185
|
+
if (onError) {
|
|
186
|
+
onError(errorMessage);
|
|
187
|
+
}
|
|
188
|
+
} finally {
|
|
189
|
+
setLoading(false);
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
const handleCopy = async () => {
|
|
194
|
+
try {
|
|
195
|
+
await navigator.clipboard.writeText(shortUrl);
|
|
196
|
+
setCopied(true);
|
|
197
|
+
setTimeout(() => setCopied(false), 2000);
|
|
198
|
+
} catch (err) {
|
|
199
|
+
console.error('Failed to copy:', err);
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
// Show loading state while validating key (but still show input)
|
|
204
|
+
// Show error if API key is invalid (but still show input with warning)
|
|
205
|
+
|
|
206
|
+
return (
|
|
207
|
+
<div className={`tiny-url-input-wrapper ${className}`}>
|
|
208
|
+
{validatingKey && (
|
|
209
|
+
<div className="tiny-url-loading" style={{ marginBottom: '10px', fontSize: '14px' }}>
|
|
210
|
+
Validating API key...
|
|
211
|
+
</div>
|
|
212
|
+
)}
|
|
213
|
+
{keyValid === false && !validatingKey && (
|
|
214
|
+
<div className="tiny-url-error" style={{ marginBottom: '15px', padding: '10px', fontSize: '14px' }}>
|
|
215
|
+
<strong>⚠️ Warning:</strong> {keyError || 'API key validation failed'}. The component may not work correctly.
|
|
216
|
+
{installationPageUrl && (
|
|
217
|
+
<div style={{ marginTop: '5px' }}>
|
|
218
|
+
<a
|
|
219
|
+
href={installationPageUrl}
|
|
220
|
+
target="_blank"
|
|
221
|
+
rel="noopener noreferrer"
|
|
222
|
+
style={{
|
|
223
|
+
color: '#0066cc',
|
|
224
|
+
textDecoration: 'underline',
|
|
225
|
+
fontSize: '12px'
|
|
226
|
+
}}
|
|
227
|
+
>
|
|
228
|
+
Learn how to get your API key →
|
|
229
|
+
</a>
|
|
230
|
+
</div>
|
|
231
|
+
)}
|
|
232
|
+
</div>
|
|
233
|
+
)}
|
|
234
|
+
<form onSubmit={handleSubmit} className="tiny-url-form">
|
|
235
|
+
<label htmlFor="url-input" className="tiny-url-label">
|
|
236
|
+
{label}
|
|
237
|
+
</label>
|
|
238
|
+
<div className="tiny-url-input-group">
|
|
239
|
+
<input
|
|
240
|
+
id="url-input"
|
|
241
|
+
type="text"
|
|
242
|
+
value={url}
|
|
243
|
+
onChange={(e) => {
|
|
244
|
+
// Basic input sanitization
|
|
245
|
+
const value = e.target.value;
|
|
246
|
+
setUrl(value);
|
|
247
|
+
}}
|
|
248
|
+
placeholder="https://example.com/very/long/url"
|
|
249
|
+
className="tiny-url-input"
|
|
250
|
+
disabled={loading}
|
|
251
|
+
/>
|
|
252
|
+
<button
|
|
253
|
+
type="submit"
|
|
254
|
+
className="tiny-url-button"
|
|
255
|
+
disabled={loading}
|
|
256
|
+
>
|
|
257
|
+
{loading ? 'Shortening...' : buttonText}
|
|
258
|
+
</button>
|
|
259
|
+
</div>
|
|
260
|
+
</form>
|
|
261
|
+
|
|
262
|
+
{error && (
|
|
263
|
+
<div className="tiny-url-error">
|
|
264
|
+
{error}
|
|
265
|
+
</div>
|
|
266
|
+
)}
|
|
267
|
+
|
|
268
|
+
{shortUrl && (
|
|
269
|
+
<div className="tiny-url-result">
|
|
270
|
+
<div className="tiny-url-result-label">Short URL:</div>
|
|
271
|
+
<div className="tiny-url-result-content">
|
|
272
|
+
<a
|
|
273
|
+
href={shortUrl}
|
|
274
|
+
target="_blank"
|
|
275
|
+
rel="noopener noreferrer"
|
|
276
|
+
className="tiny-url-link"
|
|
277
|
+
>
|
|
278
|
+
{shortUrl}
|
|
279
|
+
</a>
|
|
280
|
+
<button
|
|
281
|
+
onClick={handleCopy}
|
|
282
|
+
className="tiny-url-copy-button"
|
|
283
|
+
>
|
|
284
|
+
{copied ? 'Copied!' : 'Copy'}
|
|
285
|
+
</button>
|
|
286
|
+
</div>
|
|
287
|
+
</div>
|
|
288
|
+
)}
|
|
289
|
+
</div>
|
|
290
|
+
);
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
export default ShinyUrlInput;
|
|
294
|
+
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
.tiny-url-input-wrapper {
|
|
2
|
+
display: flex;
|
|
3
|
+
flex-direction: column;
|
|
4
|
+
gap: 1rem;
|
|
5
|
+
max-width: 600px;
|
|
6
|
+
margin: 0 auto;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
.tiny-url-form {
|
|
10
|
+
display: flex;
|
|
11
|
+
flex-direction: column;
|
|
12
|
+
gap: 0.75rem;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
.tiny-url-label {
|
|
16
|
+
font-size: 1rem;
|
|
17
|
+
font-weight: 500;
|
|
18
|
+
color: #333;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
.tiny-url-input-group {
|
|
22
|
+
display: flex;
|
|
23
|
+
gap: 0.5rem;
|
|
24
|
+
flex-wrap: wrap;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
.tiny-url-input {
|
|
28
|
+
flex: 1;
|
|
29
|
+
min-width: 200px;
|
|
30
|
+
padding: 0.75rem 1rem;
|
|
31
|
+
font-size: 1rem;
|
|
32
|
+
border: 2px solid #ddd;
|
|
33
|
+
border-radius: 6px;
|
|
34
|
+
outline: none;
|
|
35
|
+
transition: border-color 0.2s;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
.tiny-url-input:focus {
|
|
39
|
+
border-color: #4a90e2;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
.tiny-url-input:disabled {
|
|
43
|
+
background-color: #f5f5f5;
|
|
44
|
+
cursor: not-allowed;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
.tiny-url-button {
|
|
48
|
+
padding: 0.75rem 1.5rem;
|
|
49
|
+
font-size: 1rem;
|
|
50
|
+
font-weight: 500;
|
|
51
|
+
color: white;
|
|
52
|
+
background-color: #4a90e2;
|
|
53
|
+
border: none;
|
|
54
|
+
border-radius: 6px;
|
|
55
|
+
cursor: pointer;
|
|
56
|
+
transition: background-color 0.2s;
|
|
57
|
+
white-space: nowrap;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
.tiny-url-button:hover:not(:disabled) {
|
|
61
|
+
background-color: #357abd;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
.tiny-url-button:disabled {
|
|
65
|
+
background-color: #ccc;
|
|
66
|
+
cursor: not-allowed;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
.tiny-url-error {
|
|
70
|
+
padding: 0.75rem 1rem;
|
|
71
|
+
background-color: #fee;
|
|
72
|
+
color: #c33;
|
|
73
|
+
border: 1px solid #fcc;
|
|
74
|
+
border-radius: 6px;
|
|
75
|
+
font-size: 0.9rem;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
.tiny-url-result {
|
|
79
|
+
padding: 1rem;
|
|
80
|
+
background-color: #f0f8ff;
|
|
81
|
+
border: 1px solid #b0d4f1;
|
|
82
|
+
border-radius: 6px;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
.tiny-url-result-label {
|
|
86
|
+
font-size: 0.9rem;
|
|
87
|
+
font-weight: 500;
|
|
88
|
+
color: #555;
|
|
89
|
+
margin-bottom: 0.5rem;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
.tiny-url-result-content {
|
|
93
|
+
display: flex;
|
|
94
|
+
gap: 0.5rem;
|
|
95
|
+
align-items: center;
|
|
96
|
+
flex-wrap: wrap;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
.tiny-url-link {
|
|
100
|
+
flex: 1;
|
|
101
|
+
min-width: 200px;
|
|
102
|
+
color: #4a90e2;
|
|
103
|
+
text-decoration: none;
|
|
104
|
+
word-break: break-all;
|
|
105
|
+
font-size: 0.95rem;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
.tiny-url-link:hover {
|
|
109
|
+
text-decoration: underline;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
.tiny-url-copy-button {
|
|
113
|
+
padding: 0.5rem 1rem;
|
|
114
|
+
font-size: 0.9rem;
|
|
115
|
+
color: white;
|
|
116
|
+
background-color: #28a745;
|
|
117
|
+
border: none;
|
|
118
|
+
border-radius: 4px;
|
|
119
|
+
cursor: pointer;
|
|
120
|
+
transition: background-color 0.2s;
|
|
121
|
+
white-space: nowrap;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
.tiny-url-copy-button:hover {
|
|
125
|
+
background-color: #218838;
|
|
126
|
+
}
|
|
127
|
+
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import React, { useState, useMemo } from 'react';
|
|
2
|
+
import './TinyUrlInput.css';
|
|
3
|
+
|
|
4
|
+
// Security: Validate and sanitize URL
|
|
5
|
+
const isValidUrl = (string) => {
|
|
6
|
+
try {
|
|
7
|
+
const url = new URL(string);
|
|
8
|
+
// Only allow http and https protocols
|
|
9
|
+
return url.protocol === 'http:' || url.protocol === 'https:';
|
|
10
|
+
} catch (_) {
|
|
11
|
+
return false;
|
|
12
|
+
}
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
// Security: Sanitize input to prevent XSS
|
|
16
|
+
const sanitizeInput = (input) => {
|
|
17
|
+
return input.trim().replace(/[<>]/g, '');
|
|
18
|
+
};
|
|
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 'http://localhost:5000';
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const TinyUrlInput = ({
|
|
30
|
+
apiBaseUrl,
|
|
31
|
+
onSuccess,
|
|
32
|
+
onError,
|
|
33
|
+
label = 'Enter URL to shorten',
|
|
34
|
+
buttonText = 'Shorten URL',
|
|
35
|
+
className = ''
|
|
36
|
+
}) => {
|
|
37
|
+
const [url, setUrl] = useState('');
|
|
38
|
+
const [loading, setLoading] = useState(false);
|
|
39
|
+
const [error, setError] = useState('');
|
|
40
|
+
const [shortUrl, setShortUrl] = useState('');
|
|
41
|
+
const [copied, setCopied] = useState(false);
|
|
42
|
+
|
|
43
|
+
// Use provided apiBaseUrl or fallback to default
|
|
44
|
+
const baseUrl = useMemo(() => {
|
|
45
|
+
return apiBaseUrl || getDefaultApiBaseUrl();
|
|
46
|
+
}, [apiBaseUrl]);
|
|
47
|
+
|
|
48
|
+
const handleSubmit = async (e) => {
|
|
49
|
+
e.preventDefault();
|
|
50
|
+
|
|
51
|
+
// Sanitize input
|
|
52
|
+
const sanitizedUrl = sanitizeInput(url);
|
|
53
|
+
|
|
54
|
+
if (!sanitizedUrl) {
|
|
55
|
+
setError('Please enter a URL');
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Security: Validate URL format
|
|
60
|
+
if (!isValidUrl(sanitizedUrl)) {
|
|
61
|
+
setError('Please enter a valid URL (must start with http:// or https://)');
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
setLoading(true);
|
|
66
|
+
setError('');
|
|
67
|
+
setShortUrl('');
|
|
68
|
+
setCopied(false);
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
// Security: Use baseUrl which is validated
|
|
72
|
+
const response = await fetch(`${baseUrl}/api/shorten`, {
|
|
73
|
+
method: 'POST',
|
|
74
|
+
headers: {
|
|
75
|
+
'Content-Type': 'application/json',
|
|
76
|
+
},
|
|
77
|
+
body: JSON.stringify({ originalUrl: url.trim() }),
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
if (!response.ok) {
|
|
81
|
+
// Try to get error message from response
|
|
82
|
+
let errorMessage = 'Failed to shorten URL';
|
|
83
|
+
try {
|
|
84
|
+
const errorData = await response.json();
|
|
85
|
+
errorMessage = errorData.message || errorMessage;
|
|
86
|
+
} catch (e) {
|
|
87
|
+
errorMessage = `Server error: ${response.status} ${response.statusText}`;
|
|
88
|
+
}
|
|
89
|
+
throw new Error(errorMessage);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const data = await response.json();
|
|
93
|
+
|
|
94
|
+
if (!data.success) {
|
|
95
|
+
throw new Error(data.message || 'Failed to shorten URL');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
setShortUrl(data.data.shortUrl);
|
|
99
|
+
|
|
100
|
+
if (onSuccess) {
|
|
101
|
+
onSuccess(data);
|
|
102
|
+
}
|
|
103
|
+
} catch (err) {
|
|
104
|
+
let errorMessage = err.message || 'An error occurred';
|
|
105
|
+
|
|
106
|
+
// Handle network errors
|
|
107
|
+
if (err.name === 'TypeError' && err.message.includes('fetch')) {
|
|
108
|
+
errorMessage = 'Cannot connect to server. Make sure the backend is running on ' + baseUrl;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
setError(errorMessage);
|
|
112
|
+
|
|
113
|
+
if (onError) {
|
|
114
|
+
onError(errorMessage);
|
|
115
|
+
}
|
|
116
|
+
} finally {
|
|
117
|
+
setLoading(false);
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const handleCopy = async () => {
|
|
122
|
+
try {
|
|
123
|
+
await navigator.clipboard.writeText(shortUrl);
|
|
124
|
+
setCopied(true);
|
|
125
|
+
setTimeout(() => setCopied(false), 2000);
|
|
126
|
+
} catch (err) {
|
|
127
|
+
console.error('Failed to copy:', err);
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
return (
|
|
132
|
+
<div className={`tiny-url-input-wrapper ${className}`}>
|
|
133
|
+
<form onSubmit={handleSubmit} className="tiny-url-form">
|
|
134
|
+
<label htmlFor="url-input" className="tiny-url-label">
|
|
135
|
+
{label}
|
|
136
|
+
</label>
|
|
137
|
+
<div className="tiny-url-input-group">
|
|
138
|
+
<input
|
|
139
|
+
id="url-input"
|
|
140
|
+
type="text"
|
|
141
|
+
value={url}
|
|
142
|
+
onChange={(e) => {
|
|
143
|
+
// Basic input sanitization
|
|
144
|
+
const value = e.target.value;
|
|
145
|
+
setUrl(value);
|
|
146
|
+
}}
|
|
147
|
+
placeholder="https://example.com/very/long/url"
|
|
148
|
+
className="tiny-url-input"
|
|
149
|
+
disabled={loading}
|
|
150
|
+
/>
|
|
151
|
+
<button
|
|
152
|
+
type="submit"
|
|
153
|
+
className="tiny-url-button"
|
|
154
|
+
disabled={loading}
|
|
155
|
+
>
|
|
156
|
+
{loading ? 'Shortening...' : buttonText}
|
|
157
|
+
</button>
|
|
158
|
+
</div>
|
|
159
|
+
</form>
|
|
160
|
+
|
|
161
|
+
{error && (
|
|
162
|
+
<div className="tiny-url-error">
|
|
163
|
+
{error}
|
|
164
|
+
</div>
|
|
165
|
+
)}
|
|
166
|
+
|
|
167
|
+
{shortUrl && (
|
|
168
|
+
<div className="tiny-url-result">
|
|
169
|
+
<div className="tiny-url-result-label">Short URL:</div>
|
|
170
|
+
<div className="tiny-url-result-content">
|
|
171
|
+
<a
|
|
172
|
+
href={shortUrl}
|
|
173
|
+
target="_blank"
|
|
174
|
+
rel="noopener noreferrer"
|
|
175
|
+
className="tiny-url-link"
|
|
176
|
+
>
|
|
177
|
+
{shortUrl}
|
|
178
|
+
</a>
|
|
179
|
+
<button
|
|
180
|
+
onClick={handleCopy}
|
|
181
|
+
className="tiny-url-copy-button"
|
|
182
|
+
>
|
|
183
|
+
{copied ? 'Copied!' : 'Copy'}
|
|
184
|
+
</button>
|
|
185
|
+
</div>
|
|
186
|
+
</div>
|
|
187
|
+
)}
|
|
188
|
+
</div>
|
|
189
|
+
);
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
export default TinyUrlInput;
|
|
193
|
+
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useState, useMemo } from 'react';
|
|
4
|
+
import './TinyUrlInput.css';
|
|
5
|
+
|
|
6
|
+
export interface TinyUrlInputProps {
|
|
7
|
+
apiBaseUrl?: string;
|
|
8
|
+
onSuccess?: (data: any) => void;
|
|
9
|
+
onError?: (error: string) => void;
|
|
10
|
+
label?: string;
|
|
11
|
+
buttonText?: string;
|
|
12
|
+
className?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface ShortenResponse {
|
|
16
|
+
success: boolean;
|
|
17
|
+
data?: {
|
|
18
|
+
originalUrl: string;
|
|
19
|
+
shortUrl: string;
|
|
20
|
+
shortCode: string;
|
|
21
|
+
};
|
|
22
|
+
message?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Security: Validate and sanitize URL
|
|
26
|
+
const isValidUrl = (string: string): boolean => {
|
|
27
|
+
try {
|
|
28
|
+
const url = new URL(string);
|
|
29
|
+
// Only allow http and https protocols
|
|
30
|
+
return url.protocol === 'http:' || url.protocol === 'https:';
|
|
31
|
+
} catch (_) {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
// Security: Sanitize input to prevent XSS
|
|
37
|
+
const sanitizeInput = (input: string): string => {
|
|
38
|
+
return input.trim().replace(/[<>]/g, '');
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
// Get default API base URL (same origin or localhost:5000)
|
|
42
|
+
const getDefaultApiBaseUrl = (): string => {
|
|
43
|
+
if (typeof window !== 'undefined') {
|
|
44
|
+
// Use same origin if available
|
|
45
|
+
return window.location.origin;
|
|
46
|
+
}
|
|
47
|
+
return 'http://localhost:5000';
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const TinyUrlInput: React.FC<TinyUrlInputProps> = ({
|
|
51
|
+
apiBaseUrl,
|
|
52
|
+
onSuccess,
|
|
53
|
+
onError,
|
|
54
|
+
label = 'Enter URL to shorten',
|
|
55
|
+
buttonText = 'Shorten URL',
|
|
56
|
+
className = ''
|
|
57
|
+
}) => {
|
|
58
|
+
const [url, setUrl] = useState<string>('');
|
|
59
|
+
const [loading, setLoading] = useState<boolean>(false);
|
|
60
|
+
const [error, setError] = useState<string>('');
|
|
61
|
+
const [shortUrl, setShortUrl] = useState<string>('');
|
|
62
|
+
const [copied, setCopied] = useState<boolean>(false);
|
|
63
|
+
|
|
64
|
+
// Use provided apiBaseUrl or fallback to default
|
|
65
|
+
const baseUrl = useMemo(() => {
|
|
66
|
+
return apiBaseUrl || getDefaultApiBaseUrl();
|
|
67
|
+
}, [apiBaseUrl]);
|
|
68
|
+
|
|
69
|
+
const handleSubmit = async (e: React.FormEvent) => {
|
|
70
|
+
e.preventDefault();
|
|
71
|
+
|
|
72
|
+
// Sanitize input
|
|
73
|
+
const sanitizedUrl = sanitizeInput(url);
|
|
74
|
+
|
|
75
|
+
if (!sanitizedUrl) {
|
|
76
|
+
setError('Please enter a URL');
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Security: Validate URL format
|
|
81
|
+
if (!isValidUrl(sanitizedUrl)) {
|
|
82
|
+
setError('Please enter a valid URL (must start with http:// or https://)');
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
setLoading(true);
|
|
87
|
+
setError('');
|
|
88
|
+
setShortUrl('');
|
|
89
|
+
setCopied(false);
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
// Security: Use baseUrl which is validated
|
|
93
|
+
const response = await fetch(`${baseUrl}/api/shorten`, {
|
|
94
|
+
method: 'POST',
|
|
95
|
+
headers: {
|
|
96
|
+
'Content-Type': 'application/json',
|
|
97
|
+
},
|
|
98
|
+
body: JSON.stringify({ originalUrl: sanitizedUrl }),
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
if (!response.ok) {
|
|
102
|
+
// Try to get error message from response
|
|
103
|
+
let errorMessage = 'Failed to shorten URL';
|
|
104
|
+
try {
|
|
105
|
+
const errorData: ShortenResponse = await response.json();
|
|
106
|
+
errorMessage = errorData.message || errorMessage;
|
|
107
|
+
} catch (e) {
|
|
108
|
+
errorMessage = `Server error: ${response.status} ${response.statusText}`;
|
|
109
|
+
}
|
|
110
|
+
throw new Error(errorMessage);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const data: ShortenResponse = await response.json();
|
|
114
|
+
|
|
115
|
+
if (!data.success || !data.data) {
|
|
116
|
+
throw new Error(data.message || 'Failed to shorten URL');
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
setShortUrl(data.data.shortUrl);
|
|
120
|
+
|
|
121
|
+
if (onSuccess) {
|
|
122
|
+
onSuccess(data);
|
|
123
|
+
}
|
|
124
|
+
} catch (err) {
|
|
125
|
+
let errorMessage = 'An error occurred';
|
|
126
|
+
|
|
127
|
+
if (err instanceof Error) {
|
|
128
|
+
errorMessage = err.message;
|
|
129
|
+
}
|
|
130
|
+
|
|
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
|
+
|
|
136
|
+
setError(errorMessage);
|
|
137
|
+
|
|
138
|
+
if (onError) {
|
|
139
|
+
onError(errorMessage);
|
|
140
|
+
}
|
|
141
|
+
} finally {
|
|
142
|
+
setLoading(false);
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const handleCopy = async () => {
|
|
147
|
+
try {
|
|
148
|
+
await navigator.clipboard.writeText(shortUrl);
|
|
149
|
+
setCopied(true);
|
|
150
|
+
setTimeout(() => setCopied(false), 2000);
|
|
151
|
+
} catch (err) {
|
|
152
|
+
console.error('Failed to copy:', err);
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
return (
|
|
157
|
+
<div className={`tiny-url-input-wrapper ${className}`}>
|
|
158
|
+
<form onSubmit={handleSubmit} className="tiny-url-form">
|
|
159
|
+
<label htmlFor="url-input" className="tiny-url-label">
|
|
160
|
+
{label}
|
|
161
|
+
</label>
|
|
162
|
+
<div className="tiny-url-input-group">
|
|
163
|
+
<input
|
|
164
|
+
id="url-input"
|
|
165
|
+
type="text"
|
|
166
|
+
value={url}
|
|
167
|
+
onChange={(e) => {
|
|
168
|
+
// Basic input sanitization
|
|
169
|
+
const value = e.target.value;
|
|
170
|
+
setUrl(value);
|
|
171
|
+
}}
|
|
172
|
+
placeholder="https://example.com/very/long/url"
|
|
173
|
+
className="tiny-url-input"
|
|
174
|
+
disabled={loading}
|
|
175
|
+
/>
|
|
176
|
+
<button
|
|
177
|
+
type="submit"
|
|
178
|
+
className="tiny-url-button"
|
|
179
|
+
disabled={loading}
|
|
180
|
+
>
|
|
181
|
+
{loading ? 'Shortening...' : buttonText}
|
|
182
|
+
</button>
|
|
183
|
+
</div>
|
|
184
|
+
</form>
|
|
185
|
+
|
|
186
|
+
{error && (
|
|
187
|
+
<div className="tiny-url-error">
|
|
188
|
+
{error}
|
|
189
|
+
</div>
|
|
190
|
+
)}
|
|
191
|
+
|
|
192
|
+
{shortUrl && (
|
|
193
|
+
<div className="tiny-url-result">
|
|
194
|
+
<div className="tiny-url-result-label">Short URL:</div>
|
|
195
|
+
<div className="tiny-url-result-content">
|
|
196
|
+
<a
|
|
197
|
+
href={shortUrl}
|
|
198
|
+
target="_blank"
|
|
199
|
+
rel="noopener noreferrer"
|
|
200
|
+
className="tiny-url-link"
|
|
201
|
+
>
|
|
202
|
+
{shortUrl}
|
|
203
|
+
</a>
|
|
204
|
+
<button
|
|
205
|
+
onClick={handleCopy}
|
|
206
|
+
className="tiny-url-copy-button"
|
|
207
|
+
>
|
|
208
|
+
{copied ? 'Copied!' : 'Copy'}
|
|
209
|
+
</button>
|
|
210
|
+
</div>
|
|
211
|
+
</div>
|
|
212
|
+
)}
|
|
213
|
+
</div>
|
|
214
|
+
);
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
export default TinyUrlInput;
|
|
218
|
+
|
package/src/index.js
ADDED
package/src/index.ts
ADDED
package/tsconfig.json
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2020",
|
|
4
|
+
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
|
5
|
+
"module": "ESNext",
|
|
6
|
+
"jsx": "react-jsx",
|
|
7
|
+
"declaration": true,
|
|
8
|
+
"declarationMap": true,
|
|
9
|
+
"outDir": "./dist",
|
|
10
|
+
"rootDir": "./src",
|
|
11
|
+
"strict": true,
|
|
12
|
+
"esModuleInterop": true,
|
|
13
|
+
"skipLibCheck": true,
|
|
14
|
+
"forceConsistentCasingInFileNames": true,
|
|
15
|
+
"moduleResolution": "bundler",
|
|
16
|
+
"resolveJsonModule": true,
|
|
17
|
+
"isolatedModules": true,
|
|
18
|
+
"allowSyntheticDefaultImports": true
|
|
19
|
+
},
|
|
20
|
+
"include": ["src/**/*"],
|
|
21
|
+
"exclude": ["node_modules", "dist"]
|
|
22
|
+
}
|
|
23
|
+
|