@stevederico/skateboard-ui 1.2.6 → 1.2.12
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/AppSidebar.jsx +41 -39
- package/CHANGELOG.md +18 -0
- package/PaymentView.jsx +35 -22
- package/SignInView.jsx +0 -1
- package/SignUpView.jsx +27 -13
- package/TabBar.jsx +34 -37
- package/ThemeToggle.jsx +8 -1
- package/Utilities.js +21 -11
- package/package.json +1 -7
- package/styles.css +13 -12
- package/ViteConfig.js +0 -279
package/AppSidebar.jsx
CHANGED
|
@@ -32,72 +32,74 @@ export default function AppSidebar() {
|
|
|
32
32
|
className="min-w-[40px]"
|
|
33
33
|
style={{ '--sidebar-width': '12rem' }}
|
|
34
34
|
>
|
|
35
|
-
|
|
36
|
-
<
|
|
37
|
-
<
|
|
38
|
-
<div className=
|
|
39
|
-
<
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
35
|
+
{!constants.hideSidebarHeader && (
|
|
36
|
+
<SidebarHeader className="p-0">
|
|
37
|
+
<SidebarMenu>
|
|
38
|
+
<div className={`flex flex-row m-2 mt-4 mb-4 items-center ${open ? "ml-3" : "justify-center ml-2"}`}>
|
|
39
|
+
<div className="bg-app dark:border rounded-lg flex aspect-square size-10 items-center justify-center">
|
|
40
|
+
<DynamicIconComponent
|
|
41
|
+
name={constants.appIcon}
|
|
42
|
+
size={28}
|
|
43
|
+
color="white"
|
|
44
|
+
strokeWidth={2}
|
|
45
|
+
/>
|
|
46
|
+
</div>
|
|
47
|
+
{open && <div className="font-semibold ml-2 text-xl">{constants.appName}</div>}
|
|
45
48
|
</div>
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
</SidebarHeader>
|
|
49
|
+
</SidebarMenu>
|
|
50
|
+
</SidebarHeader>
|
|
51
|
+
)}
|
|
50
52
|
<SidebarContent>
|
|
51
|
-
<ul
|
|
53
|
+
<ul
|
|
54
|
+
className={`flex flex-col gap-1 p-2 ${open ? "" : "items-center"}`}
|
|
55
|
+
role="navigation"
|
|
56
|
+
aria-label="Main navigation"
|
|
57
|
+
>
|
|
52
58
|
{constants.pages.map((item) => {
|
|
53
59
|
const isActive = currentPage === item.url.toLowerCase();
|
|
54
60
|
return (
|
|
55
61
|
<li key={item.title}>
|
|
56
|
-
<
|
|
57
|
-
|
|
62
|
+
<button
|
|
63
|
+
type="button"
|
|
64
|
+
className={`cursor-pointer items-center flex w-full px-4 py-3 rounded-lg ${open ? "h-14" : "h-12 w-12"} ${isActive ? "bg-accent/80 text-accent-foreground" : "hover:bg-accent/50 hover:text-accent-foreground"}`}
|
|
58
65
|
onClick={() => handleNavigation(`/app/${item.url.toLowerCase()}`)}
|
|
66
|
+
aria-label={item.title}
|
|
67
|
+
aria-current={isActive ? 'page' : undefined}
|
|
59
68
|
>
|
|
60
|
-
<span className="flex
|
|
69
|
+
<span className="flex w-full items-center">
|
|
61
70
|
<DynamicIconComponent
|
|
62
71
|
name={item.icon}
|
|
63
|
-
size={
|
|
72
|
+
size={20}
|
|
64
73
|
strokeWidth={1.5}
|
|
65
|
-
className={"!size-6"}
|
|
66
74
|
/>
|
|
67
|
-
{open && <span className="ml-
|
|
75
|
+
{open && <span className="ml-3">{item.title}</span>}
|
|
68
76
|
</span>
|
|
69
|
-
</
|
|
77
|
+
</button>
|
|
70
78
|
</li>
|
|
71
79
|
);
|
|
72
80
|
})}
|
|
73
81
|
</ul>
|
|
74
82
|
</SidebarContent>
|
|
75
83
|
<SidebarFooter>
|
|
76
|
-
<ul className={`flex flex-col gap-1
|
|
84
|
+
<ul className={`flex flex-col gap-1 ${open ? "" : "items-center"}`}>
|
|
77
85
|
<li>
|
|
78
|
-
<
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
</div>
|
|
84
|
-
</li>
|
|
85
|
-
<li>
|
|
86
|
-
<div
|
|
87
|
-
className={`cursor-pointer items-center rounded-lg flex w-full p-2 ${open ? "h-10" : "h-10 w-8"}
|
|
88
|
-
${location.pathname.toLowerCase().includes("settings") ? "bg-accent text-accent-foreground" : "hover:bg-accent hover:text-accent-foreground"}`}
|
|
86
|
+
<button
|
|
87
|
+
type="button"
|
|
88
|
+
className={`cursor-pointer items-center rounded-lg flex w-full px-4 py-3 ${open ? "h-14" : "h-12 w-12"}
|
|
89
|
+
${location.pathname.toLowerCase().includes("settings") ? "bg-accent/80 text-accent-foreground" : "hover:bg-accent/50 hover:text-accent-foreground"}`}
|
|
89
90
|
onClick={() => handleNavigation("/app/settings")}
|
|
91
|
+
aria-label="Settings"
|
|
92
|
+
aria-current={location.pathname.toLowerCase().includes("settings") ? 'page' : undefined}
|
|
90
93
|
>
|
|
91
|
-
<span className="flex
|
|
94
|
+
<span className="flex w-full items-center">
|
|
92
95
|
<DynamicIconComponent
|
|
93
96
|
name="settings"
|
|
94
|
-
size={
|
|
97
|
+
size={20}
|
|
95
98
|
strokeWidth={1.5}
|
|
96
|
-
className={"!size-5 "}
|
|
97
99
|
/>
|
|
98
|
-
{open && <span className="ml-
|
|
100
|
+
{open && <span className="ml-3">Settings</span>}
|
|
99
101
|
</span>
|
|
100
|
-
</
|
|
102
|
+
</button>
|
|
101
103
|
</li>
|
|
102
104
|
</ul>
|
|
103
105
|
</SidebarFooter>
|
package/CHANGELOG.md
CHANGED
|
@@ -1,4 +1,22 @@
|
|
|
1
1
|
# CHANGELOG
|
|
2
|
+
1.2.12
|
|
3
|
+
|
|
4
|
+
Remove unused embla-carousel-react
|
|
5
|
+
Remove unused sonner
|
|
6
|
+
|
|
7
|
+
1.2.11
|
|
8
|
+
|
|
9
|
+
Add navigation aria labels
|
|
10
|
+
Add sidebar button semantics
|
|
11
|
+
Add TabBar accessibility
|
|
12
|
+
Add password validation
|
|
13
|
+
Add open redirect prevention
|
|
14
|
+
Add API request timeout
|
|
15
|
+
Fix useListData abort handling
|
|
16
|
+
Remove console.log statements
|
|
17
|
+
Optimize ThemeToggle storage
|
|
18
|
+
Remove ViteConfig export
|
|
19
|
+
|
|
2
20
|
1.2.5
|
|
3
21
|
|
|
4
22
|
Add sidebar visibility control
|
package/PaymentView.jsx
CHANGED
|
@@ -1,13 +1,34 @@
|
|
|
1
|
-
import React, { useEffect } from 'react';
|
|
1
|
+
import React, { useEffect, useCallback } from 'react';
|
|
2
2
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
|
3
3
|
import { getState } from './Context.jsx';
|
|
4
4
|
import { getCurrentUser } from './Utilities.js'
|
|
5
5
|
import constants from "@/constants.json";
|
|
6
6
|
|
|
7
|
+
// Whitelist of allowed redirect paths to prevent open redirect vulnerabilities
|
|
8
|
+
const ALLOWED_REDIRECT_PREFIXES = ['/app/', '/'];
|
|
9
|
+
const DEFAULT_REDIRECT = '/app/home';
|
|
10
|
+
|
|
11
|
+
function isAllowedRedirect(path) {
|
|
12
|
+
// Must start with allowed prefix and not contain protocol
|
|
13
|
+
if (path.includes('://') || path.includes('//')) return false;
|
|
14
|
+
return ALLOWED_REDIRECT_PREFIXES.some(prefix => path.startsWith(prefix));
|
|
15
|
+
}
|
|
16
|
+
|
|
7
17
|
export default function PaymentView() {
|
|
8
18
|
const navigate = useNavigate();
|
|
9
|
-
const {
|
|
10
|
-
const [searchParams] = useSearchParams();
|
|
19
|
+
const { dispatch } = getState();
|
|
20
|
+
const [searchParams] = useSearchParams();
|
|
21
|
+
|
|
22
|
+
const fetchUser = useCallback(async () => {
|
|
23
|
+
try {
|
|
24
|
+
const data = await getCurrentUser();
|
|
25
|
+
if (data) {
|
|
26
|
+
dispatch({ type: 'SET_USER', payload: data });
|
|
27
|
+
}
|
|
28
|
+
} catch (error) {
|
|
29
|
+
console.error('Failed to fetch user after payment:', error);
|
|
30
|
+
}
|
|
31
|
+
}, [dispatch]);
|
|
11
32
|
|
|
12
33
|
useEffect(() => {
|
|
13
34
|
// Get app-specific localStorage keys
|
|
@@ -20,30 +41,21 @@ export default function PaymentView() {
|
|
|
20
41
|
const portal = searchParams.get('portal') === 'return';
|
|
21
42
|
|
|
22
43
|
// Default redirect path
|
|
23
|
-
let redirectPath =
|
|
24
|
-
|
|
25
|
-
async function getUser() {
|
|
26
|
-
let data = await getCurrentUser();
|
|
27
|
-
dispatch({ type: 'SET_USER', payload: data });
|
|
28
|
-
}
|
|
44
|
+
let redirectPath = DEFAULT_REDIRECT;
|
|
29
45
|
|
|
30
46
|
// Handle different cases
|
|
31
47
|
switch (true) {
|
|
32
48
|
case success:
|
|
33
|
-
console.log("Checkout was successful!");
|
|
34
49
|
redirectPath = localStorage.getItem(getAppKey('beforeCheckoutURL')) || redirectPath;
|
|
35
|
-
|
|
50
|
+
fetchUser();
|
|
36
51
|
break;
|
|
37
52
|
case canceled:
|
|
38
|
-
console.log("Checkout was canceled!");
|
|
39
53
|
redirectPath = localStorage.getItem(getAppKey('beforeCheckoutURL')) || redirectPath;
|
|
40
54
|
break;
|
|
41
55
|
case portal:
|
|
42
|
-
console.log("Returned from billing portal!");
|
|
43
56
|
redirectPath = localStorage.getItem(getAppKey('beforeManageURL')) || redirectPath;
|
|
44
57
|
break;
|
|
45
58
|
default:
|
|
46
|
-
console.log("No specific query param detected, using default redirect.");
|
|
47
59
|
redirectPath = localStorage.getItem(getAppKey('beforeCheckoutURL')) || redirectPath;
|
|
48
60
|
break;
|
|
49
61
|
}
|
|
@@ -52,10 +64,9 @@ export default function PaymentView() {
|
|
|
52
64
|
if (redirectPath.startsWith('http://') || redirectPath.startsWith('https://')) {
|
|
53
65
|
try {
|
|
54
66
|
const url = new URL(redirectPath);
|
|
55
|
-
redirectPath = url.pathname;
|
|
67
|
+
redirectPath = url.pathname;
|
|
56
68
|
} catch (e) {
|
|
57
|
-
|
|
58
|
-
redirectPath = '/app/home'; // Fallback to default if parsing fails
|
|
69
|
+
redirectPath = DEFAULT_REDIRECT;
|
|
59
70
|
}
|
|
60
71
|
}
|
|
61
72
|
|
|
@@ -64,21 +75,23 @@ export default function PaymentView() {
|
|
|
64
75
|
redirectPath = `/${redirectPath}`;
|
|
65
76
|
}
|
|
66
77
|
|
|
67
|
-
//
|
|
68
|
-
|
|
78
|
+
// Validate redirect path to prevent open redirect
|
|
79
|
+
if (!isAllowedRedirect(redirectPath)) {
|
|
80
|
+
redirectPath = DEFAULT_REDIRECT;
|
|
81
|
+
}
|
|
69
82
|
|
|
70
83
|
// Clear the stored URLs
|
|
71
84
|
localStorage.removeItem(getAppKey('beforeCheckoutURL'));
|
|
72
85
|
localStorage.removeItem(getAppKey('beforeManageURL'));
|
|
73
|
-
|
|
86
|
+
|
|
74
87
|
// Redirect after a delay
|
|
75
88
|
const timeoutId = setTimeout(() => {
|
|
76
89
|
navigate(redirectPath, { replace: true });
|
|
77
90
|
}, 1500);
|
|
78
|
-
|
|
91
|
+
|
|
79
92
|
// Cleanup timeout
|
|
80
93
|
return () => clearTimeout(timeoutId);
|
|
81
|
-
}, [navigate, searchParams]);
|
|
94
|
+
}, [navigate, searchParams, fetchUser]);
|
|
82
95
|
|
|
83
96
|
|
|
84
97
|
|
package/SignInView.jsx
CHANGED
package/SignUpView.jsx
CHANGED
|
@@ -35,9 +35,16 @@ export default function LoginForm({
|
|
|
35
35
|
}, [])
|
|
36
36
|
|
|
37
37
|
async function signUpClicked() {
|
|
38
|
+
// Client-side password validation (matches backend: 6-72 chars)
|
|
39
|
+
if (password.length < 6) {
|
|
40
|
+
setErrorMessage('Password must be at least 6 characters');
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
if (password.length > 72) {
|
|
44
|
+
setErrorMessage('Password must be 72 characters or less');
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
38
47
|
try {
|
|
39
|
-
console.log(`${getBackendURL()}/signup`);
|
|
40
|
-
console.log(`name: ${name}`);
|
|
41
48
|
const response = await fetch(`${getBackendURL()}/signup`, {
|
|
42
49
|
method: 'POST',
|
|
43
50
|
credentials: 'include',
|
|
@@ -52,7 +59,6 @@ export default function LoginForm({
|
|
|
52
59
|
navigate('/app');
|
|
53
60
|
} else {
|
|
54
61
|
setErrorMessage('Invalid Credentials')
|
|
55
|
-
console.log("error with /signup")
|
|
56
62
|
}
|
|
57
63
|
} catch (error) {
|
|
58
64
|
console.error('Signup failed:', error);
|
|
@@ -105,16 +111,24 @@ export default function LoginForm({
|
|
|
105
111
|
}}
|
|
106
112
|
/>
|
|
107
113
|
|
|
108
|
-
<
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
114
|
+
<div className="flex flex-col gap-1">
|
|
115
|
+
<Input
|
|
116
|
+
id="password"
|
|
117
|
+
type="password"
|
|
118
|
+
placeholder="Password"
|
|
119
|
+
className="py-7 px-4 placeholder:text-gray-400 rounded-lg border-2 bg-secondary dark:bg-secondary dark:border-secondary"
|
|
120
|
+
style={{ fontSize: '20px' }}
|
|
121
|
+
required
|
|
122
|
+
minLength={6}
|
|
123
|
+
maxLength={72}
|
|
124
|
+
value={password}
|
|
125
|
+
onChange={(e) => {
|
|
126
|
+
setPassword(e.target.value);
|
|
127
|
+
setErrorMessage('');
|
|
128
|
+
}}
|
|
129
|
+
/>
|
|
130
|
+
<p className="text-xs text-gray-500 dark:text-gray-400 ml-1">Minimum 6 characters</p>
|
|
131
|
+
</div>
|
|
118
132
|
|
|
119
133
|
<button
|
|
120
134
|
onClick={(e) => { e.preventDefault(); signUpClicked() }}
|
package/TabBar.jsx
CHANGED
|
@@ -7,43 +7,40 @@ export default function TabBar() {
|
|
|
7
7
|
const location = useLocation();
|
|
8
8
|
|
|
9
9
|
return (
|
|
10
|
-
<
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
10
|
+
<nav
|
|
11
|
+
className="fixed flex md:hidden pt-2 pb-4 bottom-0 inset-x-0 justify-around text-center border-t shadow-lg bg-background"
|
|
12
|
+
role="navigation"
|
|
13
|
+
aria-label="Main navigation"
|
|
14
|
+
>
|
|
15
|
+
{constants?.pages?.map((item) => {
|
|
16
|
+
const isActive = location.pathname.includes(item.url.toLowerCase());
|
|
17
|
+
return (
|
|
18
|
+
<span className="px-3" key={item.title}>
|
|
19
|
+
<Link
|
|
20
|
+
to={`/app/${item.url.toLowerCase()}`}
|
|
21
|
+
className="cursor-pointer"
|
|
22
|
+
aria-label={item.title}
|
|
23
|
+
aria-current={isActive ? 'page' : undefined}
|
|
24
|
+
>
|
|
25
|
+
<span className={isActive ? "text-base" : "text-gray-500"}>
|
|
26
|
+
<DynamicIcon name={item.icon} size={32} strokeWidth={1.5} />
|
|
27
|
+
</span>
|
|
28
|
+
</Link>
|
|
29
|
+
</span>
|
|
30
|
+
);
|
|
31
|
+
})}
|
|
30
32
|
<span className="px-3">
|
|
31
|
-
<Link
|
|
32
|
-
{
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
</span>
|
|
44
|
-
)
|
|
45
|
-
}
|
|
46
|
-
</Link></span>
|
|
47
|
-
</div>
|
|
33
|
+
<Link
|
|
34
|
+
to={`/app/settings`}
|
|
35
|
+
className="cursor-pointer"
|
|
36
|
+
aria-label="Settings"
|
|
37
|
+
aria-current={location.pathname.includes('settings') ? 'page' : undefined}
|
|
38
|
+
>
|
|
39
|
+
<span className={location.pathname.includes('settings') ? "text-base" : "text-gray-500"}>
|
|
40
|
+
<DynamicIcon name={"settings"} size={32} strokeWidth={1.5} />
|
|
41
|
+
</span>
|
|
42
|
+
</Link>
|
|
43
|
+
</span>
|
|
44
|
+
</nav>
|
|
48
45
|
);
|
|
49
46
|
};
|
package/ThemeToggle.jsx
CHANGED
|
@@ -24,12 +24,19 @@ export default function ThemeToggle({ className = "", iconSize = 24, variant = "
|
|
|
24
24
|
|
|
25
25
|
useEffect(() => {
|
|
26
26
|
const root = document.documentElement;
|
|
27
|
+
const newTheme = isDarkMode ? 'dark' : 'light';
|
|
28
|
+
|
|
27
29
|
if (isDarkMode) {
|
|
28
30
|
root.classList.add('dark');
|
|
29
31
|
} else {
|
|
30
32
|
root.classList.remove('dark');
|
|
31
33
|
}
|
|
32
|
-
|
|
34
|
+
|
|
35
|
+
// Only write to localStorage if value changed
|
|
36
|
+
const currentTheme = localStorage.getItem('theme');
|
|
37
|
+
if (currentTheme !== newTheme) {
|
|
38
|
+
localStorage.setItem('theme', newTheme);
|
|
39
|
+
}
|
|
33
40
|
}, [isDarkMode]);
|
|
34
41
|
|
|
35
42
|
const toggleTheme = () => {
|
package/Utilities.js
CHANGED
|
@@ -67,16 +67,13 @@ function safeRemoveItem(key) {
|
|
|
67
67
|
|
|
68
68
|
export function initializeUtilities(constants) {
|
|
69
69
|
if (!constants) {
|
|
70
|
-
console.error('[Utilities] initializeUtilities called with null/undefined constants!');
|
|
71
70
|
throw new Error('initializeUtilities called with null/undefined constants');
|
|
72
71
|
}
|
|
73
|
-
console.log('[Utilities] Initializing with app:', constants.appName);
|
|
74
72
|
// Store in both module and window to handle module duplication
|
|
75
73
|
_constants = constants;
|
|
76
74
|
if (typeof window !== 'undefined') {
|
|
77
75
|
window.__SKATEBOARD_CONSTANTS__ = constants;
|
|
78
76
|
}
|
|
79
|
-
console.log('[Utilities] Initialization complete. _constants set:', !!_constants);
|
|
80
77
|
}
|
|
81
78
|
|
|
82
79
|
function getConstants() {
|
|
@@ -86,8 +83,6 @@ function getConstants() {
|
|
|
86
83
|
}
|
|
87
84
|
|
|
88
85
|
if (!_constants) {
|
|
89
|
-
console.error('[Utilities] getConstants called but _constants is null!');
|
|
90
|
-
console.trace('[Utilities] Call stack:');
|
|
91
86
|
throw new Error('Utilities not initialized. Call initializeUtilities(constants) first.');
|
|
92
87
|
}
|
|
93
88
|
return _constants;
|
|
@@ -220,7 +215,6 @@ export async function showManage(stripeID) {
|
|
|
220
215
|
|
|
221
216
|
if (response.ok) {
|
|
222
217
|
const data = await response.json();
|
|
223
|
-
console.log("/portal response: ", data);
|
|
224
218
|
if (data.url) {
|
|
225
219
|
safeSetItem(getAppKey("beforeManageURL"), window.location.href);
|
|
226
220
|
window.location.href = data.url; // Redirect to Stripe billing portal
|
|
@@ -497,11 +491,18 @@ export async function apiRequest(endpoint, options = {}) {
|
|
|
497
491
|
(options.method || 'GET').toUpperCase()
|
|
498
492
|
);
|
|
499
493
|
|
|
494
|
+
// Set up timeout (default 30 seconds)
|
|
495
|
+
const timeout = options.timeout || 30000;
|
|
496
|
+
const controller = options.signal ? null : new AbortController();
|
|
497
|
+
const signal = options.signal || controller?.signal;
|
|
498
|
+
const timeoutId = controller ? setTimeout(() => controller.abort(), timeout) : null;
|
|
499
|
+
|
|
500
500
|
let response;
|
|
501
501
|
try {
|
|
502
502
|
response = await fetch(`${getBackendURL()}${endpoint}`, {
|
|
503
503
|
...options,
|
|
504
504
|
credentials: 'include',
|
|
505
|
+
signal,
|
|
505
506
|
headers: {
|
|
506
507
|
'Content-Type': 'application/json',
|
|
507
508
|
...(needsCSRF && csrfToken && { 'X-CSRF-Token': csrfToken }),
|
|
@@ -509,7 +510,12 @@ export async function apiRequest(endpoint, options = {}) {
|
|
|
509
510
|
}
|
|
510
511
|
});
|
|
511
512
|
} catch (error) {
|
|
513
|
+
if (error.name === 'AbortError') {
|
|
514
|
+
throw error; // Re-throw abort errors for useListData to handle
|
|
515
|
+
}
|
|
512
516
|
throw new Error(`Network error: ${error.message}`);
|
|
517
|
+
} finally {
|
|
518
|
+
if (timeoutId) clearTimeout(timeoutId);
|
|
513
519
|
}
|
|
514
520
|
|
|
515
521
|
// Handle 401 (redirect to signout)
|
|
@@ -602,14 +608,16 @@ export function useListData(endpoint, sortFn = null) {
|
|
|
602
608
|
const [loading, setLoading] = useState(true);
|
|
603
609
|
const [error, setError] = useState(null);
|
|
604
610
|
|
|
605
|
-
const fetchData = async () => {
|
|
611
|
+
const fetchData = async (signal) => {
|
|
606
612
|
setLoading(true);
|
|
607
613
|
try {
|
|
608
|
-
const result = await apiRequest(endpoint);
|
|
614
|
+
const result = await apiRequest(endpoint, { signal });
|
|
609
615
|
const sorted = sortFn ? result.sort(sortFn) : result;
|
|
610
616
|
setData(sorted);
|
|
611
617
|
setError(null);
|
|
612
618
|
} catch (err) {
|
|
619
|
+
// Ignore abort errors
|
|
620
|
+
if (err.name === 'AbortError') return;
|
|
613
621
|
setError(err.message);
|
|
614
622
|
} finally {
|
|
615
623
|
setLoading(false);
|
|
@@ -617,10 +625,12 @@ export function useListData(endpoint, sortFn = null) {
|
|
|
617
625
|
};
|
|
618
626
|
|
|
619
627
|
useEffect(() => {
|
|
620
|
-
|
|
621
|
-
|
|
628
|
+
const controller = new AbortController();
|
|
629
|
+
fetchData(controller.signal);
|
|
630
|
+
return () => controller.abort();
|
|
631
|
+
}, [endpoint, sortFn]);
|
|
622
632
|
|
|
623
|
-
return { data, loading, error, refetch: fetchData };
|
|
633
|
+
return { data, loading, error, refetch: () => fetchData() };
|
|
624
634
|
}
|
|
625
635
|
|
|
626
636
|
/**
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stevederico/skateboard-ui",
|
|
3
3
|
"private": false,
|
|
4
|
-
"version": "1.2.
|
|
4
|
+
"version": "1.2.12",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
7
7
|
"./AppSidebar": {
|
|
@@ -80,10 +80,6 @@
|
|
|
80
80
|
"import": "./Utilities.js",
|
|
81
81
|
"default": "./Utilities.js"
|
|
82
82
|
},
|
|
83
|
-
"./ViteConfig": {
|
|
84
|
-
"import": "./ViteConfig.js",
|
|
85
|
-
"default": "./ViteConfig.js"
|
|
86
|
-
},
|
|
87
83
|
"./ProtectedRoute": {
|
|
88
84
|
"import": "./ProtectedRoute.jsx",
|
|
89
85
|
"default": "./ProtectedRoute.jsx"
|
|
@@ -152,12 +148,10 @@
|
|
|
152
148
|
"clsx": "^2.1.1",
|
|
153
149
|
"cmdk": "^1.1.1",
|
|
154
150
|
"date-fns": "^4.1.0",
|
|
155
|
-
"embla-carousel-react": "^8.6.0",
|
|
156
151
|
"lucide-react": "^0.537.0",
|
|
157
152
|
"next-themes": "^0.4.6",
|
|
158
153
|
"react-day-picker": "^9.8.1",
|
|
159
154
|
"react-resizable-panels": "^3.0.4",
|
|
160
|
-
"sonner": "^2.0.7",
|
|
161
155
|
"tailwind-merge": "^3.3.0",
|
|
162
156
|
"tailwindcss-animate": "^1.0.7",
|
|
163
157
|
"vaul": "^1.1.2"
|
package/styles.css
CHANGED
|
@@ -47,37 +47,37 @@
|
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
.dark {
|
|
50
|
-
--background: oklch(0.
|
|
50
|
+
--background: oklch(0.05 0 0);
|
|
51
51
|
--foreground: oklch(0.985 0 0);
|
|
52
|
-
--card: oklch(0.
|
|
52
|
+
--card: oklch(0.12 0 0);
|
|
53
53
|
--card-foreground: oklch(0.985 0 0);
|
|
54
|
-
--popover: oklch(0.
|
|
54
|
+
--popover: oklch(0.12 0 0);
|
|
55
55
|
--popover-foreground: oklch(0.985 0 0);
|
|
56
56
|
--primary: oklch(0.985 0 0);
|
|
57
|
-
--primary-foreground: oklch(0.
|
|
58
|
-
--secondary: oklch(0.
|
|
57
|
+
--primary-foreground: oklch(0.05 0 0);
|
|
58
|
+
--secondary: oklch(0.18 0 0);
|
|
59
59
|
--secondary-foreground: oklch(0.985 0 0);
|
|
60
|
-
--muted: oklch(0.
|
|
60
|
+
--muted: oklch(0.18 0 0);
|
|
61
61
|
--muted-foreground: oklch(0.708 0 0);
|
|
62
|
-
--accent: oklch(0.
|
|
62
|
+
--accent: oklch(0.2 0 0);
|
|
63
63
|
--accent-foreground: oklch(0.985 0 0);
|
|
64
64
|
--destructive: oklch(0.396 0.141 25.723);
|
|
65
65
|
--destructive-foreground: oklch(0.637 0.237 25.331);
|
|
66
|
-
--border: oklch(0.
|
|
67
|
-
--input: oklch(0.
|
|
66
|
+
--border: oklch(0.2 0 0);
|
|
67
|
+
--input: oklch(0.18 0 0);
|
|
68
68
|
--ring: oklch(0.439 0 0);
|
|
69
69
|
--chart-1: oklch(0.488 0.243 264.376);
|
|
70
70
|
--chart-2: oklch(0.696 0.17 162.48);
|
|
71
71
|
--chart-3: oklch(0.769 0.188 70.08);
|
|
72
72
|
--chart-4: oklch(0.627 0.265 303.9);
|
|
73
73
|
--chart-5: oklch(0.645 0.246 16.439);
|
|
74
|
-
--sidebar: oklch(0.
|
|
74
|
+
--sidebar: oklch(0.15 0 0);
|
|
75
75
|
--sidebar-foreground: oklch(0.985 0 0);
|
|
76
76
|
--sidebar-primary: oklch(0.488 0.243 264.376);
|
|
77
77
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
|
78
|
-
--sidebar-accent:
|
|
78
|
+
--sidebar-accent: oklch(0.25 0 0);
|
|
79
79
|
--sidebar-accent-foreground: oklch(0.985 0 0);
|
|
80
|
-
--sidebar-border: oklch(0.
|
|
80
|
+
--sidebar-border: oklch(0.27 0 0);
|
|
81
81
|
--sidebar-ring: oklch(0.439 0 0);
|
|
82
82
|
}
|
|
83
83
|
|
|
@@ -146,6 +146,7 @@
|
|
|
146
146
|
}
|
|
147
147
|
body {
|
|
148
148
|
@apply bg-white dark:bg-black text-foreground;
|
|
149
|
+
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
|
149
150
|
}
|
|
150
151
|
}
|
|
151
152
|
|
package/ViteConfig.js
DELETED
|
@@ -1,279 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Vite configuration utilities for skateboard-ui
|
|
3
|
-
* This file is for build-time use only (in vite.config.js)
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* Custom logger plugin for Vite
|
|
8
|
-
*/
|
|
9
|
-
export const customLoggerPlugin = () => {
|
|
10
|
-
return {
|
|
11
|
-
name: 'custom-logger',
|
|
12
|
-
configureServer(server) {
|
|
13
|
-
server.printUrls = () => {
|
|
14
|
-
console.log(`🖥️ React is running on http://localhost:${server.config.server.port || 5173}`);
|
|
15
|
-
};
|
|
16
|
-
}
|
|
17
|
-
};
|
|
18
|
-
};
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* HTML replacement plugin
|
|
22
|
-
* Replaces {{APP_NAME}}, {{TAGLINE}}, {{COMPANY_WEBSITE}} in index.html
|
|
23
|
-
*/
|
|
24
|
-
export const htmlReplacePlugin = () => {
|
|
25
|
-
return {
|
|
26
|
-
name: 'html-replace',
|
|
27
|
-
async transformIndexHtml(html) {
|
|
28
|
-
const { readFileSync } = await import('node:fs');
|
|
29
|
-
const constants = JSON.parse(readFileSync('src/constants.json', 'utf8'));
|
|
30
|
-
|
|
31
|
-
return html
|
|
32
|
-
.replace(/{{APP_NAME}}/g, constants.appName)
|
|
33
|
-
.replace(/{{TAGLINE}}/g, constants.tagline)
|
|
34
|
-
.replace(/{{COMPANY_WEBSITE}}/g, constants.companyWebsite);
|
|
35
|
-
}
|
|
36
|
-
};
|
|
37
|
-
};
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* Dynamic robots.txt plugin
|
|
41
|
-
*/
|
|
42
|
-
export const dynamicRobotsPlugin = () => {
|
|
43
|
-
return {
|
|
44
|
-
name: 'dynamic-robots',
|
|
45
|
-
async generateBundle() {
|
|
46
|
-
const { readFileSync } = await import('node:fs');
|
|
47
|
-
const constants = JSON.parse(readFileSync('src/constants.json', 'utf8'));
|
|
48
|
-
const website = constants.companyWebsite.startsWith('http')
|
|
49
|
-
? constants.companyWebsite
|
|
50
|
-
: `https://${constants.companyWebsite}`;
|
|
51
|
-
|
|
52
|
-
const robotsContent = `User-agent: Googlebot
|
|
53
|
-
Disallow: /app/
|
|
54
|
-
Disallow: /console/
|
|
55
|
-
Disallow: /signin/
|
|
56
|
-
Disallow: /signup/
|
|
57
|
-
|
|
58
|
-
User-agent: Bingbot
|
|
59
|
-
Disallow: /app/
|
|
60
|
-
Disallow: /console/
|
|
61
|
-
Disallow: /signin/
|
|
62
|
-
Disallow: /signup/
|
|
63
|
-
|
|
64
|
-
User-agent: Applebot
|
|
65
|
-
Disallow: /app/
|
|
66
|
-
Disallow: /console/
|
|
67
|
-
Disallow: /signin/
|
|
68
|
-
Disallow: /signup/
|
|
69
|
-
|
|
70
|
-
User-agent: facebookexternalhit
|
|
71
|
-
Disallow: /app/
|
|
72
|
-
Disallow: /console/
|
|
73
|
-
Disallow: /signin/
|
|
74
|
-
Disallow: /signup/
|
|
75
|
-
|
|
76
|
-
User-agent: Twitterbot
|
|
77
|
-
Allow: /
|
|
78
|
-
|
|
79
|
-
User-agent: LinkedInBot
|
|
80
|
-
Allow: /
|
|
81
|
-
|
|
82
|
-
User-agent: WhatsApp
|
|
83
|
-
Allow: /
|
|
84
|
-
|
|
85
|
-
User-agent: *
|
|
86
|
-
Disallow: /app/
|
|
87
|
-
Disallow: /console/
|
|
88
|
-
Disallow: /signin/
|
|
89
|
-
Disallow: /signup/
|
|
90
|
-
Allow: /
|
|
91
|
-
|
|
92
|
-
Sitemap: ${website}/sitemap.xml`;
|
|
93
|
-
|
|
94
|
-
this.emitFile({
|
|
95
|
-
type: 'asset',
|
|
96
|
-
fileName: 'robots.txt',
|
|
97
|
-
source: robotsContent
|
|
98
|
-
});
|
|
99
|
-
}
|
|
100
|
-
};
|
|
101
|
-
};
|
|
102
|
-
|
|
103
|
-
/**
|
|
104
|
-
* Dynamic sitemap.xml plugin
|
|
105
|
-
*/
|
|
106
|
-
export const dynamicSitemapPlugin = () => {
|
|
107
|
-
return {
|
|
108
|
-
name: 'dynamic-sitemap',
|
|
109
|
-
async generateBundle() {
|
|
110
|
-
const { readFileSync } = await import('node:fs');
|
|
111
|
-
const constants = JSON.parse(readFileSync('src/constants.json', 'utf8'));
|
|
112
|
-
const website = constants.companyWebsite.startsWith('http')
|
|
113
|
-
? constants.companyWebsite
|
|
114
|
-
: `https://${constants.companyWebsite}`;
|
|
115
|
-
|
|
116
|
-
const currentDate = new Date().toISOString().split('T')[0];
|
|
117
|
-
|
|
118
|
-
const sitemapContent = `<?xml version="1.0" encoding="UTF-8"?>
|
|
119
|
-
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
|
120
|
-
<url>
|
|
121
|
-
<loc>${website}/</loc>
|
|
122
|
-
<lastmod>${currentDate}</lastmod>
|
|
123
|
-
<changefreq>weekly</changefreq>
|
|
124
|
-
<priority>1.0</priority>
|
|
125
|
-
</url>
|
|
126
|
-
<url>
|
|
127
|
-
<loc>${website}/terms</loc>
|
|
128
|
-
<lastmod>${currentDate}</lastmod>
|
|
129
|
-
<changefreq>monthly</changefreq>
|
|
130
|
-
<priority>0.8</priority>
|
|
131
|
-
</url>
|
|
132
|
-
<url>
|
|
133
|
-
<loc>${website}/privacy</loc>
|
|
134
|
-
<lastmod>${currentDate}</lastmod>
|
|
135
|
-
<changefreq>monthly</changefreq>
|
|
136
|
-
<priority>0.8</priority>
|
|
137
|
-
</url>
|
|
138
|
-
<url>
|
|
139
|
-
<loc>${website}/signin</loc>
|
|
140
|
-
<lastmod>${currentDate}</lastmod>
|
|
141
|
-
<changefreq>weekly</changefreq>
|
|
142
|
-
<priority>0.6</priority>
|
|
143
|
-
</url>
|
|
144
|
-
<url>
|
|
145
|
-
<loc>${website}/signup</loc>
|
|
146
|
-
<lastmod>${currentDate}</lastmod>
|
|
147
|
-
<changefreq>weekly</changefreq>
|
|
148
|
-
<priority>0.6</priority>
|
|
149
|
-
</url>
|
|
150
|
-
</urlset>`;
|
|
151
|
-
|
|
152
|
-
this.emitFile({
|
|
153
|
-
type: 'asset',
|
|
154
|
-
fileName: 'sitemap.xml',
|
|
155
|
-
source: sitemapContent
|
|
156
|
-
});
|
|
157
|
-
}
|
|
158
|
-
};
|
|
159
|
-
};
|
|
160
|
-
|
|
161
|
-
/**
|
|
162
|
-
* Dynamic manifest.json plugin
|
|
163
|
-
*/
|
|
164
|
-
export const dynamicManifestPlugin = () => {
|
|
165
|
-
return {
|
|
166
|
-
name: 'dynamic-manifest',
|
|
167
|
-
async generateBundle() {
|
|
168
|
-
const { readFileSync } = await import('node:fs');
|
|
169
|
-
const constants = JSON.parse(readFileSync('src/constants.json', 'utf8'));
|
|
170
|
-
|
|
171
|
-
const manifestContent = {
|
|
172
|
-
short_name: constants.appName,
|
|
173
|
-
name: constants.appName,
|
|
174
|
-
description: constants.tagline,
|
|
175
|
-
icons: [
|
|
176
|
-
{
|
|
177
|
-
src: "/icons/icon.svg",
|
|
178
|
-
sizes: "192x192",
|
|
179
|
-
type: "image/svg+xml"
|
|
180
|
-
}
|
|
181
|
-
],
|
|
182
|
-
start_url: "./app",
|
|
183
|
-
display: "standalone",
|
|
184
|
-
theme_color: "#000000",
|
|
185
|
-
background_color: "#ffffff"
|
|
186
|
-
};
|
|
187
|
-
|
|
188
|
-
this.emitFile({
|
|
189
|
-
type: 'asset',
|
|
190
|
-
fileName: 'manifest.json',
|
|
191
|
-
source: JSON.stringify(manifestContent, null, 2)
|
|
192
|
-
});
|
|
193
|
-
}
|
|
194
|
-
};
|
|
195
|
-
};
|
|
196
|
-
|
|
197
|
-
/**
|
|
198
|
-
* Complete Vite config generator
|
|
199
|
-
* Returns standard skateboard config with optional overrides
|
|
200
|
-
*/
|
|
201
|
-
export async function getSkateboardViteConfig(customConfig = {}) {
|
|
202
|
-
const [react, tailwindcss, path] = await Promise.all([
|
|
203
|
-
import('@vitejs/plugin-react-swc').then(m => m.default),
|
|
204
|
-
import('@tailwindcss/vite').then(m => m.default),
|
|
205
|
-
import('node:path')
|
|
206
|
-
]);
|
|
207
|
-
const { resolve } = path;
|
|
208
|
-
|
|
209
|
-
return {
|
|
210
|
-
plugins: [
|
|
211
|
-
react(),
|
|
212
|
-
tailwindcss(),
|
|
213
|
-
customLoggerPlugin(),
|
|
214
|
-
htmlReplacePlugin(),
|
|
215
|
-
dynamicRobotsPlugin(),
|
|
216
|
-
dynamicSitemapPlugin(),
|
|
217
|
-
dynamicManifestPlugin(),
|
|
218
|
-
...(customConfig.plugins || [])
|
|
219
|
-
],
|
|
220
|
-
esbuild: {
|
|
221
|
-
drop: []
|
|
222
|
-
},
|
|
223
|
-
resolve: {
|
|
224
|
-
alias: {
|
|
225
|
-
'@': resolve(process.cwd(), './src'),
|
|
226
|
-
'@package': path.resolve(process.cwd(), 'package.json'),
|
|
227
|
-
'@root': path.resolve(process.cwd()),
|
|
228
|
-
'react/jsx-runtime': path.resolve(process.cwd(), 'node_modules/react/jsx-runtime.js'),
|
|
229
|
-
...(customConfig.resolve?.alias || {})
|
|
230
|
-
}
|
|
231
|
-
},
|
|
232
|
-
optimizeDeps: {
|
|
233
|
-
include: [
|
|
234
|
-
'react',
|
|
235
|
-
'react-dom',
|
|
236
|
-
'react-dom/client',
|
|
237
|
-
'@radix-ui/react-slot',
|
|
238
|
-
'react-router-dom',
|
|
239
|
-
'react-router',
|
|
240
|
-
'cookie',
|
|
241
|
-
'set-cookie-parser',
|
|
242
|
-
...(customConfig.optimizeDeps?.include || [])
|
|
243
|
-
],
|
|
244
|
-
force: true,
|
|
245
|
-
exclude: [
|
|
246
|
-
'@swc/core',
|
|
247
|
-
'@swc/core-darwin-arm64',
|
|
248
|
-
'@swc/wasm',
|
|
249
|
-
'lightningcss',
|
|
250
|
-
'fsevents',
|
|
251
|
-
...(customConfig.optimizeDeps?.exclude || [])
|
|
252
|
-
],
|
|
253
|
-
esbuildOptions: {
|
|
254
|
-
target: 'esnext',
|
|
255
|
-
define: {
|
|
256
|
-
global: 'globalThis'
|
|
257
|
-
},
|
|
258
|
-
...(customConfig.optimizeDeps?.esbuildOptions || {})
|
|
259
|
-
}
|
|
260
|
-
},
|
|
261
|
-
server: {
|
|
262
|
-
host: 'localhost',
|
|
263
|
-
open: false,
|
|
264
|
-
port: 5173,
|
|
265
|
-
strictPort: false,
|
|
266
|
-
hmr: {
|
|
267
|
-
port: 5173,
|
|
268
|
-
overlay: false,
|
|
269
|
-
},
|
|
270
|
-
watch: {
|
|
271
|
-
usePolling: false,
|
|
272
|
-
ignored: ['**/node_modules/**', '**/.git/**']
|
|
273
|
-
},
|
|
274
|
-
...(customConfig.server || {})
|
|
275
|
-
},
|
|
276
|
-
logLevel: 'error',
|
|
277
|
-
...customConfig
|
|
278
|
-
};
|
|
279
|
-
}
|