ui-soxo-bootstrap-core 2.6.29 → 2.6.30
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/core/lib/components/global-header/global-header.scss +6 -6
- package/core/lib/components/sidemenu/sidemenu.js +133 -3
- package/core/lib/components/sidemenu/sidemenu.scss +40 -3
- package/core/lib/pages/login/login.js +63 -42
- package/core/lib/utils/font-awesome.utils.js +168 -0
- package/package.json +1 -1
|
@@ -81,7 +81,7 @@
|
|
|
81
81
|
}
|
|
82
82
|
|
|
83
83
|
&.open {
|
|
84
|
-
width:
|
|
84
|
+
width: var(--sidemenu-width, 211px);
|
|
85
85
|
}
|
|
86
86
|
|
|
87
87
|
&.close {
|
|
@@ -92,7 +92,7 @@
|
|
|
92
92
|
transition: 0.3s;
|
|
93
93
|
|
|
94
94
|
&.open {
|
|
95
|
-
width:
|
|
95
|
+
width: var(--sidemenu-width, 211px);
|
|
96
96
|
transition: 0.1s;
|
|
97
97
|
}
|
|
98
98
|
|
|
@@ -109,7 +109,7 @@
|
|
|
109
109
|
}
|
|
110
110
|
|
|
111
111
|
@media only screen and (min-width: 800px) {
|
|
112
|
-
width:
|
|
112
|
+
width: var(--sidemenu-width, 211px);
|
|
113
113
|
}
|
|
114
114
|
}
|
|
115
115
|
|
|
@@ -126,7 +126,7 @@
|
|
|
126
126
|
overflow-x: scroll;
|
|
127
127
|
position: relative;
|
|
128
128
|
padding: 15px;
|
|
129
|
-
margin-left:
|
|
129
|
+
margin-left: calc(var(--sidemenu-width, 211px) + 1px);
|
|
130
130
|
|
|
131
131
|
&.kioskon {
|
|
132
132
|
overflow-x: hidden;
|
|
@@ -213,9 +213,9 @@
|
|
|
213
213
|
padding: 15px;
|
|
214
214
|
|
|
215
215
|
&.open {
|
|
216
|
-
width: calc(100% -
|
|
216
|
+
width: calc(100% - (var(--sidemenu-width, 211px) + 1px));
|
|
217
217
|
transition: 0.1s;
|
|
218
|
-
left:
|
|
218
|
+
left: calc(var(--sidemenu-width, 211px) + 1px);
|
|
219
219
|
}
|
|
220
220
|
|
|
221
221
|
@media only screen and (max-width: 768px) {
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import React, { useState, useEffect, useContext } from 'react';
|
|
11
|
+
import React, { useState, useEffect, useContext, useCallback, useRef } from 'react';
|
|
12
12
|
|
|
13
13
|
import { animationControls, motion, useAnimation } from 'framer-motion';
|
|
14
14
|
|
|
@@ -24,10 +24,75 @@ import { useTranslation, Trans } from 'react-i18next';
|
|
|
24
24
|
|
|
25
25
|
import { Menus } from '../../models';
|
|
26
26
|
|
|
27
|
+
import { expandFaAliases, ensureFontAwesomeAvailable } from '../../utils/font-awesome.utils';
|
|
28
|
+
|
|
27
29
|
import './sidemenu.scss';
|
|
28
30
|
|
|
29
31
|
const { SubMenu } = Menu;
|
|
30
32
|
|
|
33
|
+
const SIDEMENU_WIDTH_KEY = 'soxo:sidemenu-width';
|
|
34
|
+
const DEFAULT_SIDEMENU_WIDTH = 211;
|
|
35
|
+
const MIN_SIDEMENU_WIDTH = 180;
|
|
36
|
+
const MAX_SIDEMENU_WIDTH = 420;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Renders the menu's image if present, falling back to the Font Awesome icon.
|
|
40
|
+
* The FA icon is rendered immediately so something is always visible during
|
|
41
|
+
* a slow image fetch (e.g. cold reload, route change before the image is
|
|
42
|
+
* cached). The image takes over only after it successfully loads; if it
|
|
43
|
+
* fails, the FA icon stays.
|
|
44
|
+
*/
|
|
45
|
+
function MenuIcon({ menu, icon, size = 25 }) {
|
|
46
|
+
const src = menu && typeof menu.image_path === 'string' ? menu.image_path.trim() : '';
|
|
47
|
+
const [imageStatus, setImageStatus] = useState(src ? 'loading' : 'none');
|
|
48
|
+
const imgRef = useRef(null);
|
|
49
|
+
|
|
50
|
+
// Reset and probe whenever src changes. This covers two cases that were
|
|
51
|
+
// breaking icon rendering on direct URL navigation / reload:
|
|
52
|
+
// 1. Cached images: the browser may fire `load` synchronously before
|
|
53
|
+
// React attaches the onLoad handler, leaving status stuck at
|
|
54
|
+
// 'loading'. We re-check `img.complete` after mount.
|
|
55
|
+
// 2. Broken responses (200 with empty/corrupt body, CSP blocked, etc.)
|
|
56
|
+
// that fire `load` but with naturalWidth=0 — treat as failed so the
|
|
57
|
+
// Font Awesome fallback shows instead of an invisible <img>.
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
if (!src) {
|
|
60
|
+
setImageStatus('none');
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
const img = imgRef.current;
|
|
64
|
+
if (img && img.complete) {
|
|
65
|
+
setImageStatus(img.naturalWidth > 0 ? 'loaded' : 'failed');
|
|
66
|
+
} else {
|
|
67
|
+
setImageStatus('loading');
|
|
68
|
+
}
|
|
69
|
+
}, [src]);
|
|
70
|
+
|
|
71
|
+
const handleLoad = () => {
|
|
72
|
+
const img = imgRef.current;
|
|
73
|
+
setImageStatus(img && img.naturalWidth > 0 ? 'loaded' : 'failed');
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const showImage = src && imageStatus !== 'failed';
|
|
77
|
+
const showIcon = !showImage || imageStatus !== 'loaded';
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
<>
|
|
81
|
+
{showIcon && <i className={`fa-solid fas ${expandFaAliases(icon)}`} />}
|
|
82
|
+
{showImage && (
|
|
83
|
+
<img
|
|
84
|
+
ref={imgRef}
|
|
85
|
+
style={{ width: size, display: imageStatus === 'loaded' ? 'inline-block' : 'none' }}
|
|
86
|
+
src={src}
|
|
87
|
+
alt=""
|
|
88
|
+
onLoad={handleLoad}
|
|
89
|
+
onError={() => setImageStatus('failed')}
|
|
90
|
+
/>
|
|
91
|
+
)}
|
|
92
|
+
</>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
31
96
|
/**
|
|
32
97
|
*
|
|
33
98
|
* @param {*} collapsed
|
|
@@ -59,7 +124,7 @@ function CollapsedIconMenu({ menu, collapsed, icon, caption }) {
|
|
|
59
124
|
{!collapsed ? (
|
|
60
125
|
<div className="menu-collapsed">
|
|
61
126
|
<div>
|
|
62
|
-
|
|
127
|
+
<MenuIcon menu={menu} icon={icon} />
|
|
63
128
|
</div>
|
|
64
129
|
|
|
65
130
|
<div style={{ color: state.theme.colors.leftSectionColor }}>
|
|
@@ -73,7 +138,7 @@ function CollapsedIconMenu({ menu, collapsed, icon, caption }) {
|
|
|
73
138
|
) : (
|
|
74
139
|
<div className="menu-collapsed">
|
|
75
140
|
<span className="anticon">
|
|
76
|
-
|
|
141
|
+
<MenuIcon menu={menu} icon={icon} />
|
|
77
142
|
</span>
|
|
78
143
|
|
|
79
144
|
<span style={{ color: state.theme.colors.colorPrimaryText, paddingLeft: '6px' }}>
|
|
@@ -106,8 +171,62 @@ export default function SideMenu({ loading, modules = [], callback, appSettings,
|
|
|
106
171
|
const [openKeys, setOpenKeys] = useState([]);
|
|
107
172
|
const [menu, setMenu] = useState({});
|
|
108
173
|
|
|
174
|
+
const [sidebarWidth, setSidebarWidth] = useState(() => {
|
|
175
|
+
const saved = parseInt(typeof window !== 'undefined' ? localStorage.getItem(SIDEMENU_WIDTH_KEY) : null, 10);
|
|
176
|
+
return Number.isFinite(saved) && saved >= MIN_SIDEMENU_WIDTH && saved <= MAX_SIDEMENU_WIDTH ? saved : DEFAULT_SIDEMENU_WIDTH;
|
|
177
|
+
});
|
|
178
|
+
const [isDragging, setIsDragging] = useState(false);
|
|
179
|
+
|
|
109
180
|
const { user = { locations: [] }, dispatch, state } = useContext(GlobalContext);
|
|
110
181
|
|
|
182
|
+
// Expose the current expanded sidebar width as a CSS variable so the
|
|
183
|
+
// surrounding layout (right-section, page-wrapper) can follow the drag.
|
|
184
|
+
useEffect(() => {
|
|
185
|
+
if (typeof document === 'undefined') return;
|
|
186
|
+
document.documentElement.style.setProperty('--sidemenu-width', `${sidebarWidth}px`);
|
|
187
|
+
}, [sidebarWidth]);
|
|
188
|
+
|
|
189
|
+
// Make sure Font Awesome is actually rendering glyphs in this app; if the
|
|
190
|
+
// host hasn't loaded it (or shipped a broken build), inject FA6 from a CDN
|
|
191
|
+
// so menu icons appear.
|
|
192
|
+
useEffect(() => {
|
|
193
|
+
ensureFontAwesomeAvailable();
|
|
194
|
+
}, []);
|
|
195
|
+
|
|
196
|
+
useEffect(() => {
|
|
197
|
+
if (typeof window === 'undefined') return;
|
|
198
|
+
localStorage.setItem(SIDEMENU_WIDTH_KEY, String(sidebarWidth));
|
|
199
|
+
}, [sidebarWidth]);
|
|
200
|
+
|
|
201
|
+
useEffect(() => {
|
|
202
|
+
if (!isDragging) return;
|
|
203
|
+
|
|
204
|
+
const handleMouseMove = (e) => {
|
|
205
|
+
const next = Math.min(Math.max(e.clientX, MIN_SIDEMENU_WIDTH), MAX_SIDEMENU_WIDTH);
|
|
206
|
+
setSidebarWidth(next);
|
|
207
|
+
};
|
|
208
|
+
const handleMouseUp = () => {
|
|
209
|
+
setIsDragging(false);
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
document.body.style.cursor = 'col-resize';
|
|
213
|
+
document.body.style.userSelect = 'none';
|
|
214
|
+
document.addEventListener('mousemove', handleMouseMove);
|
|
215
|
+
document.addEventListener('mouseup', handleMouseUp);
|
|
216
|
+
|
|
217
|
+
return () => {
|
|
218
|
+
document.body.style.cursor = '';
|
|
219
|
+
document.body.style.userSelect = '';
|
|
220
|
+
document.removeEventListener('mousemove', handleMouseMove);
|
|
221
|
+
document.removeEventListener('mouseup', handleMouseUp);
|
|
222
|
+
};
|
|
223
|
+
}, [isDragging]);
|
|
224
|
+
|
|
225
|
+
const handleResizeStart = useCallback((e) => {
|
|
226
|
+
e.preventDefault();
|
|
227
|
+
setIsDragging(true);
|
|
228
|
+
}, []);
|
|
229
|
+
|
|
111
230
|
useEffect(() => {
|
|
112
231
|
// Here we have to consider three cases now ,
|
|
113
232
|
// One is firebase for which it is primarly designed for
|
|
@@ -528,6 +647,17 @@ export default function SideMenu({ loading, modules = [], callback, appSettings,
|
|
|
528
647
|
{/* {renderFooter(footerLogo)} */}
|
|
529
648
|
{/* Footer Logo Ends */}
|
|
530
649
|
</div>
|
|
650
|
+
|
|
651
|
+
{!collapsed && (
|
|
652
|
+
<div
|
|
653
|
+
className={`sidemenu-resize-handle${isDragging ? ' dragging' : ''}`}
|
|
654
|
+
onMouseDown={handleResizeStart}
|
|
655
|
+
role="separator"
|
|
656
|
+
aria-orientation="vertical"
|
|
657
|
+
aria-label="Resize sidebar"
|
|
658
|
+
title="Drag to resize"
|
|
659
|
+
/>
|
|
660
|
+
)}
|
|
531
661
|
</div>
|
|
532
662
|
);
|
|
533
663
|
}
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
padding: 5px 16px;
|
|
41
41
|
position: fixed;
|
|
42
42
|
z-index: 1000;
|
|
43
|
-
width:
|
|
43
|
+
width: var(--sidemenu-width, 211px);
|
|
44
44
|
background: #fff;
|
|
45
45
|
// border-bottom: 1.5px solid #24aeb8;
|
|
46
46
|
&.close {
|
|
@@ -106,7 +106,7 @@
|
|
|
106
106
|
margin-top: 0;
|
|
107
107
|
overflow-y: scroll;
|
|
108
108
|
overflow-x: hidden;
|
|
109
|
-
width: 211px;
|
|
109
|
+
width: var(--sidemenu-width, 211px);
|
|
110
110
|
padding-bottom: 50px;
|
|
111
111
|
|
|
112
112
|
/* Custom Scrollbar like macOS */
|
|
@@ -132,7 +132,7 @@
|
|
|
132
132
|
}
|
|
133
133
|
// when toggle open and close
|
|
134
134
|
&.open {
|
|
135
|
-
width: 211px !important;
|
|
135
|
+
width: var(--sidemenu-width, 211px) !important;
|
|
136
136
|
}
|
|
137
137
|
|
|
138
138
|
&.close {
|
|
@@ -202,6 +202,39 @@
|
|
|
202
202
|
}
|
|
203
203
|
}
|
|
204
204
|
|
|
205
|
+
.sidemenu-resize-handle {
|
|
206
|
+
position: fixed;
|
|
207
|
+
top: 0;
|
|
208
|
+
bottom: 0;
|
|
209
|
+
left: var(--sidemenu-width, 211px);
|
|
210
|
+
width: 6px;
|
|
211
|
+
margin-left: -3px;
|
|
212
|
+
cursor: col-resize;
|
|
213
|
+
z-index: 1100;
|
|
214
|
+
background: transparent;
|
|
215
|
+
transition: background 0.15s ease;
|
|
216
|
+
|
|
217
|
+
&::after {
|
|
218
|
+
content: '';
|
|
219
|
+
position: absolute;
|
|
220
|
+
top: 0;
|
|
221
|
+
bottom: 0;
|
|
222
|
+
left: 50%;
|
|
223
|
+
width: 1px;
|
|
224
|
+
background: transparent;
|
|
225
|
+
transition: background 0.15s ease;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
&:hover::after,
|
|
229
|
+
&.dragging::after {
|
|
230
|
+
background: #24aeb8;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
&.dragging {
|
|
234
|
+
background: rgba(36, 174, 184, 0.08);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
205
238
|
.sidebar-footer {
|
|
206
239
|
position: fixed;
|
|
207
240
|
|
|
@@ -258,6 +291,10 @@
|
|
|
258
291
|
.sidebar-footer {
|
|
259
292
|
width: 100% !important;
|
|
260
293
|
}
|
|
294
|
+
|
|
295
|
+
.sidemenu-resize-handle {
|
|
296
|
+
display: none;
|
|
297
|
+
}
|
|
261
298
|
}
|
|
262
299
|
}
|
|
263
300
|
|
|
@@ -1,10 +1,8 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { useContext, useEffect, useState } from 'react';
|
|
2
2
|
|
|
3
|
-
import { Form, Input, message,
|
|
3
|
+
import { Divider, Form, Input, message, Radio, Typography } from 'antd';
|
|
4
4
|
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
import backgroundImage from './../../../assets/images/vector.png';
|
|
5
|
+
import { Link, withRouter } from 'react-router-dom';
|
|
8
6
|
|
|
9
7
|
import OTPInput from 'otp-input-react';
|
|
10
8
|
|
|
@@ -32,7 +30,7 @@ import { Location } from '../../utils';
|
|
|
32
30
|
|
|
33
31
|
import { checkLicenseStatus, formatMobile, safeJSON } from '../../utils/common/common.utils';
|
|
34
32
|
|
|
35
|
-
import { MailOutlined, MessageOutlined
|
|
33
|
+
import { MailOutlined, MessageOutlined } from '@ant-design/icons';
|
|
36
34
|
|
|
37
35
|
import ResetPassword from './reset-password';
|
|
38
36
|
|
|
@@ -50,7 +48,6 @@ const tailLayout = {
|
|
|
50
48
|
|
|
51
49
|
const LICENSE_EXPIRY = '2026-12-12';
|
|
52
50
|
|
|
53
|
-
|
|
54
51
|
const headers = {
|
|
55
52
|
db_ptr: 'nuraho',
|
|
56
53
|
};
|
|
@@ -443,6 +440,20 @@ function LoginPhone({ history, appSettings }) {
|
|
|
443
440
|
setOtpSuccess(false);
|
|
444
441
|
};
|
|
445
442
|
|
|
443
|
+
// Paste handler runs in capture phase so it pre-empts otp-input-react's
|
|
444
|
+
// internal handler, which otherwise drops digits when the focused box is
|
|
445
|
+
// not the first one and does not strip spaces/dashes/surrounding text
|
|
446
|
+
const handleOtpPaste = (e) => {
|
|
447
|
+
const raw = e.clipboardData?.getData('text') || '';
|
|
448
|
+
const digits = raw.replace(/\D/g, '').slice(0, 6);
|
|
449
|
+
if (!digits) return;
|
|
450
|
+
e.preventDefault();
|
|
451
|
+
e.stopPropagation();
|
|
452
|
+
setOtpValue(digits);
|
|
453
|
+
setOtpError(false);
|
|
454
|
+
setOtpSuccess(false);
|
|
455
|
+
};
|
|
456
|
+
|
|
446
457
|
/**
|
|
447
458
|
* Otp resend Logic
|
|
448
459
|
*/
|
|
@@ -495,6 +506,18 @@ function LoginPhone({ history, appSettings }) {
|
|
|
495
506
|
setOtpError(false);
|
|
496
507
|
}
|
|
497
508
|
}, [otpExpired]);
|
|
509
|
+
|
|
510
|
+
// Auto-submit once the user has filled all 6 digits (typed or pasted).
|
|
511
|
+
// verifyOtp clears otpValue on failure (back to length 0) and sets
|
|
512
|
+
// otpSuccess on success, both of which prevent this from re-firing.
|
|
513
|
+
useEffect(() => {
|
|
514
|
+
if (otpValue.length !== 6) return;
|
|
515
|
+
if (loading || otpExpired || otpSuccess || !otpVerification) return;
|
|
516
|
+
verifyOtp();
|
|
517
|
+
// verifyOtp intentionally omitted from deps — it's redefined every render;
|
|
518
|
+
// including it would re-fire on every state change.
|
|
519
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
520
|
+
}, [otpValue, otpVerification]);
|
|
498
521
|
/**
|
|
499
522
|
* Function to get Ldap users
|
|
500
523
|
* @returns
|
|
@@ -611,28 +634,28 @@ function LoginPhone({ history, appSettings }) {
|
|
|
611
634
|
return user.username;
|
|
612
635
|
};
|
|
613
636
|
|
|
614
|
-
const { globalCustomerHeader = () => {
|
|
637
|
+
const { globalCustomerHeader = () => {} } = appSettings;
|
|
615
638
|
|
|
616
639
|
const themeName = process.env.REACT_APP_THEME; // e.g., 'purple'
|
|
617
640
|
const isPurple = themeName === 'purple';
|
|
618
641
|
|
|
619
642
|
const sectionStyle = isPurple
|
|
620
643
|
? {
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
644
|
+
width: '100%',
|
|
645
|
+
height: '100vh',
|
|
646
|
+
backgroundImage: `${state.theme.colors.loginPageBackground}`,
|
|
647
|
+
backgroundPosition: 'center bottom, center',
|
|
648
|
+
backgroundRepeat: 'no-repeat, no-repeat',
|
|
649
|
+
backgroundSize: 'cover, cover',
|
|
650
|
+
}
|
|
628
651
|
: {
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
652
|
+
width: '100%',
|
|
653
|
+
height: '100vh',
|
|
654
|
+
background: 'linear-gradient(to bottom, #F7F6E3 0%, #EEF1DE 20%, #D5E4DA 45%, #9DBFC8 75%, #4F89A6 100%)',
|
|
655
|
+
backgroundPosition: 'center bottom, center',
|
|
656
|
+
backgroundRepeat: 'no-repeat, no-repeat',
|
|
657
|
+
backgroundSize: 'cover, cover',
|
|
658
|
+
};
|
|
636
659
|
|
|
637
660
|
return (
|
|
638
661
|
<section className="full-page" style={sectionStyle}>
|
|
@@ -732,26 +755,24 @@ function LoginPhone({ history, appSettings }) {
|
|
|
732
755
|
<strong>{getOtpDestinationText()}</strong>
|
|
733
756
|
</span>
|
|
734
757
|
</p>
|
|
735
|
-
<
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
? '2px solid #52c41a'
|
|
744
|
-
:
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
}}
|
|
754
|
-
/>
|
|
758
|
+
<div onPasteCapture={handleOtpPaste}>
|
|
759
|
+
<OTPInput
|
|
760
|
+
value={otpValue}
|
|
761
|
+
onChange={handleOtpChange}
|
|
762
|
+
autoFocus
|
|
763
|
+
OTPLength={6}
|
|
764
|
+
disabled={loading || otpExpired}
|
|
765
|
+
inputStyles={{
|
|
766
|
+
border: otpSuccess ? '2px solid #52c41a' : otpError ? '2px solid #FF5C5C' : '1px solid #d9d9d9',
|
|
767
|
+
borderRadius: 4,
|
|
768
|
+
width: isMobile ? 28 : 45,
|
|
769
|
+
height: isMobile ? 36 : 40,
|
|
770
|
+
margin: isMobile ? '2px' : '0 4px',
|
|
771
|
+
fontSize: isMobile ? 16 : 18,
|
|
772
|
+
textAlign: 'center',
|
|
773
|
+
}}
|
|
774
|
+
/>
|
|
775
|
+
</div>
|
|
755
776
|
{/* Timer below OTP */}
|
|
756
777
|
<div className="otp-timer">
|
|
757
778
|
{/* {!otpExpired ? */}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Font Awesome compatibility helpers.
|
|
3
|
+
*
|
|
4
|
+
* Two failure modes are addressed here:
|
|
5
|
+
* 1. The host app ships FA6 Free but menu/data uses FA5 names that were
|
|
6
|
+
* renamed (e.g. fa-search-dollar → fa-magnifying-glass-dollar). Rendering
|
|
7
|
+
* the old class alone produces a blank glyph.
|
|
8
|
+
* 2. The host app doesn't ship Font Awesome at all, or ships a build whose
|
|
9
|
+
* font file fails to load. Every <i> renders blank regardless of class.
|
|
10
|
+
*
|
|
11
|
+
* `expandFaAliases` handles (1) by emitting both old and new class names so
|
|
12
|
+
* whichever the loaded stylesheet recognises will paint the glyph.
|
|
13
|
+
* `ensureFontAwesomeAvailable` handles (2) by probing the page for a working
|
|
14
|
+
* FA install and, if absent, injecting FA6 Free from a CDN.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
// Font Awesome 5 → Font Awesome 6 Free renames. FA Pro keeps the FA5 names
|
|
18
|
+
// as aliases so emitting both is safe there too.
|
|
19
|
+
export const FA5_TO_FA6_FREE_ALIASES = {
|
|
20
|
+
// search / magnifier
|
|
21
|
+
'fa-search': 'fa-magnifying-glass',
|
|
22
|
+
'fa-search-dollar': 'fa-magnifying-glass-dollar',
|
|
23
|
+
'fa-search-plus': 'fa-magnifying-glass-plus',
|
|
24
|
+
'fa-search-minus': 'fa-magnifying-glass-minus',
|
|
25
|
+
'fa-search-location': 'fa-magnifying-glass-location',
|
|
26
|
+
// layout / lists
|
|
27
|
+
'fa-th': 'fa-table-cells',
|
|
28
|
+
'fa-th-large': 'fa-table-cells-large',
|
|
29
|
+
'fa-th-list': 'fa-table-list',
|
|
30
|
+
'fa-list-alt': 'fa-rectangle-list',
|
|
31
|
+
'fa-tasks': 'fa-list-check',
|
|
32
|
+
// auth / users
|
|
33
|
+
'fa-sign-in-alt': 'fa-right-to-bracket',
|
|
34
|
+
'fa-sign-out-alt': 'fa-right-from-bracket',
|
|
35
|
+
'fa-user-circle': 'fa-circle-user',
|
|
36
|
+
'fa-user-cog': 'fa-user-gear',
|
|
37
|
+
'fa-user-md': 'fa-user-doctor',
|
|
38
|
+
'fa-users-cog': 'fa-users-gear',
|
|
39
|
+
// common actions
|
|
40
|
+
'fa-times': 'fa-xmark',
|
|
41
|
+
'fa-times-circle': 'fa-circle-xmark',
|
|
42
|
+
'fa-trash-alt': 'fa-trash-can',
|
|
43
|
+
'fa-edit': 'fa-pen-to-square',
|
|
44
|
+
'fa-pencil-alt': 'fa-pencil',
|
|
45
|
+
'fa-save': 'fa-floppy-disk',
|
|
46
|
+
'fa-share-alt': 'fa-share-nodes',
|
|
47
|
+
'fa-unlink': 'fa-link-slash',
|
|
48
|
+
// settings / dashboards
|
|
49
|
+
'fa-cog': 'fa-gear',
|
|
50
|
+
'fa-cogs': 'fa-gears',
|
|
51
|
+
'fa-tachometer-alt': 'fa-gauge',
|
|
52
|
+
'fa-tachometer': 'fa-gauge',
|
|
53
|
+
// navigation / pages
|
|
54
|
+
'fa-home': 'fa-house',
|
|
55
|
+
'fa-file-alt': 'fa-file-lines',
|
|
56
|
+
// feedback / status
|
|
57
|
+
'fa-info-circle': 'fa-circle-info',
|
|
58
|
+
'fa-question-circle': 'fa-circle-question',
|
|
59
|
+
'fa-check-circle': 'fa-circle-check',
|
|
60
|
+
'fa-check-square': 'fa-square-check',
|
|
61
|
+
'fa-exclamation-triangle': 'fa-triangle-exclamation',
|
|
62
|
+
'fa-exclamation-circle': 'fa-circle-exclamation',
|
|
63
|
+
'fa-plus-circle': 'fa-circle-plus',
|
|
64
|
+
'fa-plus-square': 'fa-square-plus',
|
|
65
|
+
'fa-minus-circle': 'fa-circle-minus',
|
|
66
|
+
'fa-minus-square': 'fa-square-minus',
|
|
67
|
+
// charts
|
|
68
|
+
'fa-chart-bar': 'fa-chart-column',
|
|
69
|
+
// calendar / time
|
|
70
|
+
'fa-calendar-alt': 'fa-calendar-days',
|
|
71
|
+
'fa-calendar-times': 'fa-calendar-xmark',
|
|
72
|
+
'fa-history': 'fa-clock-rotate-left',
|
|
73
|
+
// money / billing
|
|
74
|
+
'fa-money-check-alt': 'fa-money-check-dollar',
|
|
75
|
+
'fa-money-bill-alt': 'fa-money-bill-1',
|
|
76
|
+
// shopping
|
|
77
|
+
'fa-shopping-cart': 'fa-cart-shopping',
|
|
78
|
+
'fa-shopping-bag': 'fa-bag-shopping',
|
|
79
|
+
'fa-shopping-basket': 'fa-basket-shopping',
|
|
80
|
+
// devices / contact
|
|
81
|
+
'fa-mobile-alt': 'fa-mobile-screen-button',
|
|
82
|
+
'fa-tablet-alt': 'fa-tablet-screen-button',
|
|
83
|
+
'fa-phone-alt': 'fa-phone',
|
|
84
|
+
'fa-mail-bulk': 'fa-envelopes-bulk',
|
|
85
|
+
// map / location
|
|
86
|
+
'fa-map-marker': 'fa-location-pin',
|
|
87
|
+
'fa-map-marker-alt': 'fa-location-dot',
|
|
88
|
+
'fa-globe-americas': 'fa-earth-americas',
|
|
89
|
+
// security / id
|
|
90
|
+
'fa-shield-alt': 'fa-shield-halved',
|
|
91
|
+
'fa-id-card-alt': 'fa-id-card-clip',
|
|
92
|
+
// healthcare (this codebase skews this way)
|
|
93
|
+
'fa-prescription-bottle-alt': 'fa-prescription-bottle-medical',
|
|
94
|
+
'fa-procedures': 'fa-bed-pulse',
|
|
95
|
+
'fa-heartbeat': 'fa-heart-pulse',
|
|
96
|
+
'fa-clinic-medical': 'fa-house-chimney-medical',
|
|
97
|
+
'fa-hospital-alt': 'fa-hospital',
|
|
98
|
+
'fa-allergies': 'fa-hand-dots',
|
|
99
|
+
'fa-file-medical-alt': 'fa-file-waveform',
|
|
100
|
+
'fa-running': 'fa-person-running',
|
|
101
|
+
'fa-walking': 'fa-person-walking',
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Expand a Font Awesome icon class string so that both the FA5 name and its
|
|
106
|
+
* FA6-Free equivalent are present. Same-name tokens are emitted once.
|
|
107
|
+
* Non-string input is returned as-is.
|
|
108
|
+
*/
|
|
109
|
+
export function expandFaAliases(icon) {
|
|
110
|
+
if (typeof icon !== 'string' || !icon) return icon || '';
|
|
111
|
+
const seen = new Set();
|
|
112
|
+
const out = [];
|
|
113
|
+
for (const token of icon.split(/\s+/)) {
|
|
114
|
+
if (!token || seen.has(token)) continue;
|
|
115
|
+
seen.add(token);
|
|
116
|
+
out.push(token);
|
|
117
|
+
const alias = FA5_TO_FA6_FREE_ALIASES[token];
|
|
118
|
+
if (alias && !seen.has(alias)) {
|
|
119
|
+
seen.add(alias);
|
|
120
|
+
out.push(alias);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return out.join(' ');
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const FA_FALLBACK_LINK_ID = 'soxo-fa-fallback';
|
|
127
|
+
const FA_FALLBACK_HREF = 'https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css';
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Probe the page for a working Font Awesome install. If the probe glyph
|
|
131
|
+
* doesn't render, inject FA6 Free from a CDN. No-op when FA already works
|
|
132
|
+
* or when the fallback has already been injected.
|
|
133
|
+
*/
|
|
134
|
+
export function ensureFontAwesomeAvailable() {
|
|
135
|
+
if (typeof document === 'undefined' || !document.body) return;
|
|
136
|
+
if (document.getElementById(FA_FALLBACK_LINK_ID)) return;
|
|
137
|
+
|
|
138
|
+
// Render a hidden probe <i> with a class that exists in every recent FA
|
|
139
|
+
// build (`fa-circle`). If the ::before content is empty/none or the
|
|
140
|
+
// font-family doesn't claim to be Font Awesome, FA isn't usable here.
|
|
141
|
+
const probe = document.createElement('i');
|
|
142
|
+
probe.className = 'fa-solid fas fa-circle';
|
|
143
|
+
probe.style.cssText = 'position:absolute;left:-9999px;top:-9999px;visibility:hidden;';
|
|
144
|
+
document.body.appendChild(probe);
|
|
145
|
+
|
|
146
|
+
let working = false;
|
|
147
|
+
try {
|
|
148
|
+
const before = window.getComputedStyle(probe, '::before');
|
|
149
|
+
const content = before && before.content;
|
|
150
|
+
const fontFamily = before && before.fontFamily;
|
|
151
|
+
const hasGlyph = content && content !== 'none' && content !== '""' && content !== "''";
|
|
152
|
+
const isFaFont = fontFamily && /font\s*awesome/i.test(fontFamily);
|
|
153
|
+
working = Boolean(hasGlyph && isFaFont);
|
|
154
|
+
} catch (e) {
|
|
155
|
+
working = false;
|
|
156
|
+
} finally {
|
|
157
|
+
document.body.removeChild(probe);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (working) return;
|
|
161
|
+
|
|
162
|
+
const link = document.createElement('link');
|
|
163
|
+
link.id = FA_FALLBACK_LINK_ID;
|
|
164
|
+
link.rel = 'stylesheet';
|
|
165
|
+
link.href = FA_FALLBACK_HREF;
|
|
166
|
+
link.crossOrigin = 'anonymous';
|
|
167
|
+
document.head.appendChild(link);
|
|
168
|
+
}
|