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 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;
@@ -0,0 +1,3 @@
1
+ export { default as ShinyUrlInput } from './ShinyUrlInput';
2
+ export type { ShinyUrlInputProps } from './ShinyUrlInput';
3
+ //# sourceMappingURL=index.d.ts.map
@@ -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
@@ -0,0 +1,2 @@
1
+ export { default as TinyUrlInput } from './TinyUrlInput.jsx';
2
+
package/src/index.ts ADDED
@@ -0,0 +1,3 @@
1
+ export { default as ShinyUrlInput } from './ShinyUrlInput';
2
+ export type { ShinyUrlInputProps } from './ShinyUrlInput';
3
+
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
+