@visns-studio/visns-components 6.6.4 → 6.15.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 +1623 -18
- package/package.json +10 -4
- package/src/components/DataGrid.jsx +171 -37
- package/src/components/Fetch.jsx +80 -3
- package/src/components/Form.jsx +9 -0
- package/src/components/Navigation.jsx +109 -50
- package/src/components/Notification.jsx +279 -9
- package/src/components/TableFilter.jsx +14 -1
- package/src/components/auth/AuthBrandPanel.jsx +43 -0
- package/src/components/auth/AuthLoading.jsx +78 -0
- package/src/components/auth/AuthShell.jsx +59 -0
- package/src/components/auth/ClientAuth.jsx +161 -0
- package/src/components/auth/ClientAuthFrame.jsx +59 -0
- package/src/components/auth/ClientLogin.jsx +266 -55
- package/src/components/auth/ClientOTPVerify.jsx +587 -115
- package/src/components/auth/ImpersonateGate.jsx +254 -0
- package/src/components/auth/Login.jsx +134 -41
- package/src/components/auth/LogoutScreen.jsx +112 -0
- package/src/components/auth/Reset.jsx +134 -77
- package/src/components/auth/TwoFactorAuth.jsx +475 -297
- package/src/components/auth/Verify.jsx +237 -126
- package/src/components/auth/authEndpoints.js +105 -0
- package/src/components/auth/authFont.js +23 -0
- package/src/components/auth/authHelpers.js +465 -0
- package/src/components/auth/clientAuthProtocols.js +240 -0
- package/src/components/auth/useOptionalRouter.js +49 -0
- package/src/components/callQueue/CallQueuePop.jsx +1502 -0
- package/src/components/callQueue/CallQueueSettings.jsx +508 -0
- package/src/components/callQueue/callQueueHelpers.js +280 -0
- package/src/components/callQueue/callQueueSettingsHelpers.js +75 -0
- package/src/components/columns/ColumnRenderers.jsx +53 -7
- package/src/components/generic/GenericAuth.jsx +163 -96
- package/src/components/generic/GenericDetail.jsx +215 -90
- package/src/components/generic/GenericIndex.jsx +20 -47
- package/src/components/generic/GenericMain.jsx +5 -0
- package/src/components/generic/GroupedReportRenderer.jsx +1 -5
- package/src/components/generic/StandardModal.jsx +17 -5
- package/src/components/generic/reportSemanticSteps/SemanticEntityStep.jsx +1 -6
- package/src/components/generic/reportSemanticSteps/SemanticFieldsStep.jsx +4 -11
- package/src/components/generic/reportSemanticSteps/SemanticFiltersStep.jsx +9 -27
- package/src/components/generic/reportSemanticSteps/SemanticGroupingStep.jsx +5 -13
- package/src/components/generic/reportSemanticSteps/SemanticParameterPrompt.jsx +5 -13
- package/src/components/generic/reportSemanticSteps/SemanticPreviewStep.jsx +14 -31
- package/src/components/generic/reportSemanticSteps/SemanticRelationsStep.jsx +3 -9
- package/src/components/generic/reportSemanticSteps/SemanticValueInput.jsx +2 -15
- package/src/components/navActive.js +60 -0
- package/src/components/notify/desktopNotifications.js +256 -0
- package/src/components/sketch/SketchField.jsx +12 -2
- package/src/components/sms/SmsComposeModal.jsx +495 -0
- package/src/components/sms/SmsInbox.jsx +596 -0
- package/src/components/sms/SmsInboxBadge.jsx +468 -0
- package/src/components/sms/SmsLineSettings.jsx +854 -0
- package/src/components/sms/SmsThreadPanel.jsx +1184 -0
- package/src/components/sms/smsEndpoints.js +56 -0
- package/src/components/sms/smsHelpers.js +1075 -0
- package/src/components/sms/smsLiveState.js +132 -0
- package/src/components/sms/useSmsLive.js +307 -0
- package/src/components/styles/CallQueuePop.module.scss +646 -0
- package/src/components/styles/CallQueueSettings.module.scss +460 -0
- package/src/components/styles/ClientAuth.module.scss +229 -0
- package/src/components/styles/GenericDetail.module.scss +153 -26
- package/src/components/styles/GenericIndex.module.scss +13 -0
- package/src/components/styles/GenericMain.module.scss +19 -2
- package/src/components/styles/ImpersonateGate.module.scss +85 -0
- package/src/components/styles/Login.module.scss +13 -122
- package/src/components/styles/LogoutScreen.module.scss +89 -0
- package/src/components/styles/Navigation.module.scss +509 -223
- package/src/components/styles/Notification.module.scss +103 -0
- package/src/components/styles/Reset.module.scss +52 -186
- package/src/components/styles/Sms.module.scss +2382 -0
- package/src/components/styles/TableFilter.module.scss +118 -98
- package/src/components/styles/TwoFactorAuth.module.scss +115 -176
- package/src/components/styles/Vault.module.scss +1983 -0
- package/src/components/styles/Verify.module.scss +57 -180
- package/src/components/styles/_authBrandPanel.scss +163 -0
- package/src/components/styles/_authShell.scss +369 -0
- package/src/components/utils/buildEnv.js +67 -0
- package/src/components/utils/displayValue.js +94 -0
- package/src/components/utils/useDensity.js +345 -9
- package/src/components/vault/OtpChip.jsx +208 -0
- package/src/components/vault/PasswordGenerator.jsx +145 -0
- package/src/components/vault/QrScanner.jsx +326 -0
- package/src/components/vault/VaultAccessLog.jsx +225 -0
- package/src/components/vault/VaultConfirmPanel.jsx +113 -0
- package/src/components/vault/VaultEntryForm.jsx +740 -0
- package/src/components/vault/VaultManager.jsx +1000 -0
- package/src/components/vault/VaultQuickSearch.jsx +681 -0
- package/src/components/vault/qrDecode.js +257 -0
- package/src/components/vault/useDebouncedValue.js +22 -0
- package/src/components/vault/useVaultReveal.js +160 -0
- package/src/components/vault/vaultClipboard.js +33 -0
- package/src/components/vault/vaultEndpoints.js +40 -0
- package/src/components/vault/vaultFit.js +116 -0
- package/src/components/vault/vaultHelpers.js +547 -0
- package/src/components/vault/vaultNavigation.jsx +58 -0
- package/src/components/vault/vaultOtp.js +105 -0
- package/src/index.js +233 -1
|
@@ -21,11 +21,16 @@ import {
|
|
|
21
21
|
Shield,
|
|
22
22
|
Image,
|
|
23
23
|
Phone,
|
|
24
|
+
KeyRound,
|
|
25
|
+
Lock,
|
|
26
|
+
MessageSquare,
|
|
27
|
+
MessagesSquare,
|
|
24
28
|
} from 'lucide-react';
|
|
25
29
|
import CustomFetch from './Fetch';
|
|
26
30
|
import Notification from './Notification';
|
|
27
31
|
import SwitchAccount from './SwitchAccount';
|
|
28
32
|
import BusinessCardOcr from './BusinessCardOcr';
|
|
33
|
+
import { isNavActive } from './navActive';
|
|
29
34
|
import styles from './styles/Navigation.module.scss';
|
|
30
35
|
|
|
31
36
|
// Opt-in icons for the sidebar (simple/cms) layouts: a nav entry names one
|
|
@@ -56,6 +61,10 @@ const NAV_ICONS = {
|
|
|
56
61
|
const SETTING_ICONS = {
|
|
57
62
|
bug: Bug,
|
|
58
63
|
calendar: Calendar,
|
|
64
|
+
key: KeyRound,
|
|
65
|
+
lock: Lock,
|
|
66
|
+
message: MessageSquare,
|
|
67
|
+
messages: MessagesSquare,
|
|
59
68
|
draft: FileText,
|
|
60
69
|
image: Image,
|
|
61
70
|
peopleGroup: Users,
|
|
@@ -76,6 +85,31 @@ const RAIL_ACTION_LABELS = {
|
|
|
76
85
|
notifications: 'Notifications',
|
|
77
86
|
};
|
|
78
87
|
|
|
88
|
+
/**
|
|
89
|
+
* A chip is an icon and nothing else, so its name has to come from somewhere:
|
|
90
|
+
* it is the tooltip, and it is the only thing a screen reader has to go on.
|
|
91
|
+
* A config entry that forgot its `label` used to produce a nameless button —
|
|
92
|
+
* so the id, which is always there, is humanised as the last resort
|
|
93
|
+
* (`callRecordings` -> `Call Recordings`).
|
|
94
|
+
*/
|
|
95
|
+
const actionLabel = (n) => {
|
|
96
|
+
if (n.label) {
|
|
97
|
+
return n.label;
|
|
98
|
+
}
|
|
99
|
+
if (RAIL_ACTION_LABELS[n.id]) {
|
|
100
|
+
return RAIL_ACTION_LABELS[n.id];
|
|
101
|
+
}
|
|
102
|
+
if (!n.id) {
|
|
103
|
+
return '';
|
|
104
|
+
}
|
|
105
|
+
return n.id
|
|
106
|
+
.replace(/[-_]+/g, ' ')
|
|
107
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
|
108
|
+
.replace(/\s+/g, ' ')
|
|
109
|
+
.trim()
|
|
110
|
+
.replace(/^./, (c) => c.toUpperCase());
|
|
111
|
+
};
|
|
112
|
+
|
|
79
113
|
const SearchComponent = ({
|
|
80
114
|
navigate,
|
|
81
115
|
search,
|
|
@@ -242,6 +276,22 @@ function Navigation({
|
|
|
242
276
|
themeType,
|
|
243
277
|
userProfile,
|
|
244
278
|
debugIcons = false, // Add debug flag
|
|
279
|
+
/*
|
|
280
|
+
* Per-id renderers for account actions: `{ [setting.id]: ({ setting,
|
|
281
|
+
* userProfile, setSystemAuth }) => node }`.
|
|
282
|
+
*
|
|
283
|
+
* The chain below is hard-coded because every action it knows about ships
|
|
284
|
+
* with this library. An action that lives in a consuming app — or in a
|
|
285
|
+
* module the library carries but the header should not import eagerly —
|
|
286
|
+
* has nowhere to go. This is that slot: a matching id renders the
|
|
287
|
+
* supplied node inside the same row wrapper the built-in branches use,
|
|
288
|
+
* on both the header chips and the sidebar rail.
|
|
289
|
+
*
|
|
290
|
+
* Checked before the built-in chain, so a project can also override a
|
|
291
|
+
* shipped action. Empty by default, so nothing changes for anyone who
|
|
292
|
+
* does not pass it.
|
|
293
|
+
*/
|
|
294
|
+
renderers = {},
|
|
245
295
|
}) {
|
|
246
296
|
const [search, setSearch] = useState('');
|
|
247
297
|
const [searchResultShow, setSearchResultShow] = useState(false);
|
|
@@ -304,6 +354,19 @@ function Navigation({
|
|
|
304
354
|
// Add active class if the setting is active
|
|
305
355
|
const activeClass = isActive ? styles.active : '';
|
|
306
356
|
|
|
357
|
+
// A consumer-supplied renderer wins over the built-in chain.
|
|
358
|
+
if (typeof renderers[n.id] === 'function') {
|
|
359
|
+
return (
|
|
360
|
+
<li>
|
|
361
|
+
{renderers[n.id]({
|
|
362
|
+
setting: n,
|
|
363
|
+
userProfile,
|
|
364
|
+
setSystemAuth,
|
|
365
|
+
})}
|
|
366
|
+
</li>
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
|
|
307
370
|
if (n.id === 'notifications') {
|
|
308
371
|
return (
|
|
309
372
|
<li>
|
|
@@ -314,8 +377,11 @@ function Navigation({
|
|
|
314
377
|
return (
|
|
315
378
|
<li>
|
|
316
379
|
<button
|
|
380
|
+
type="button"
|
|
317
381
|
data-tooltip-id="system-tooltip"
|
|
382
|
+
data-tooltip-place="bottom"
|
|
318
383
|
data-tooltip-content="Scan Business Card"
|
|
384
|
+
aria-label="Scan Business Card"
|
|
319
385
|
onClick={() => setShowBusinessCardOcr(true)}
|
|
320
386
|
className={activeClass}
|
|
321
387
|
>
|
|
@@ -335,11 +401,15 @@ function Navigation({
|
|
|
335
401
|
);
|
|
336
402
|
} else if (n.id === 'logout') {
|
|
337
403
|
const IconComponent = iconComponents[n.icon];
|
|
404
|
+
const label = actionLabel(n);
|
|
338
405
|
return (
|
|
339
406
|
<li>
|
|
340
407
|
<button
|
|
408
|
+
type="button"
|
|
341
409
|
data-tooltip-id="system-tooltip"
|
|
342
|
-
data-tooltip-
|
|
410
|
+
data-tooltip-place="bottom"
|
|
411
|
+
data-tooltip-content={label}
|
|
412
|
+
aria-label={label}
|
|
343
413
|
onClick={handleLogout}
|
|
344
414
|
className={activeClass} // Add active class to the logout button
|
|
345
415
|
>
|
|
@@ -349,13 +419,16 @@ function Navigation({
|
|
|
349
419
|
);
|
|
350
420
|
} else {
|
|
351
421
|
const IconComponent = iconComponents[n.icon];
|
|
422
|
+
const label = actionLabel(n);
|
|
352
423
|
|
|
353
424
|
return (
|
|
354
425
|
<li>
|
|
355
426
|
<Link
|
|
356
427
|
to={n.url}
|
|
357
428
|
data-tooltip-id="system-tooltip"
|
|
358
|
-
data-tooltip-
|
|
429
|
+
data-tooltip-place="bottom"
|
|
430
|
+
data-tooltip-content={label}
|
|
431
|
+
aria-label={label}
|
|
359
432
|
className={`${activeClass}`}
|
|
360
433
|
target={n.target ? n.target : '_self'}
|
|
361
434
|
>
|
|
@@ -385,11 +458,25 @@ function Navigation({
|
|
|
385
458
|
const renderRailSetting = (n) => {
|
|
386
459
|
const isActive = n.url && n.url === `/${currentPage}`;
|
|
387
460
|
const activeClass = isActive ? styles.active : '';
|
|
388
|
-
const label = n
|
|
461
|
+
const label = actionLabel(n);
|
|
389
462
|
// A CSS string, quoted and escaped, for `content`.
|
|
390
463
|
const labelVar = { '--nav-action-label': JSON.stringify(label) };
|
|
391
464
|
const IconComponent = SETTING_ICONS[n.icon];
|
|
392
465
|
|
|
466
|
+
// Same slot as the header's, publishing the row label the same way a
|
|
467
|
+
// mounted component gets one (see the note above renderRailSetting).
|
|
468
|
+
if (typeof renderers[n.id] === 'function') {
|
|
469
|
+
return (
|
|
470
|
+
<li style={labelVar}>
|
|
471
|
+
{renderers[n.id]({
|
|
472
|
+
setting: n,
|
|
473
|
+
userProfile,
|
|
474
|
+
setSystemAuth,
|
|
475
|
+
})}
|
|
476
|
+
</li>
|
|
477
|
+
);
|
|
478
|
+
}
|
|
479
|
+
|
|
393
480
|
if (n.id === 'notifications') {
|
|
394
481
|
return (
|
|
395
482
|
<li style={labelVar}>
|
|
@@ -510,9 +597,8 @@ function Navigation({
|
|
|
510
597
|
<li key={`nav-child-${childKey}`}>
|
|
511
598
|
<Link
|
|
512
599
|
to={child.url}
|
|
513
|
-
title={child.label}
|
|
514
600
|
className={
|
|
515
|
-
|
|
601
|
+
isNavActive(child.url, [], currentPage)
|
|
516
602
|
? styles.active
|
|
517
603
|
: ''
|
|
518
604
|
}
|
|
@@ -543,7 +629,7 @@ function Navigation({
|
|
|
543
629
|
{n.label}
|
|
544
630
|
</Link>
|
|
545
631
|
) : (
|
|
546
|
-
<span
|
|
632
|
+
<span>{n.label}</span>
|
|
547
633
|
)}
|
|
548
634
|
{renderChildren()}
|
|
549
635
|
</li>
|
|
@@ -685,52 +771,25 @@ function Navigation({
|
|
|
685
771
|
|
|
686
772
|
useEffect(() => {
|
|
687
773
|
setNavData((prevNavData) => {
|
|
774
|
+
/**
|
|
775
|
+
* One rule at every depth — see navActive.js, which holds it and
|
|
776
|
+
* is tested directly. This used to branch on how many segments the
|
|
777
|
+
* URL happened to have, only consulted an item's children in one
|
|
778
|
+
* of those branches, and compared paths with `String.includes`.
|
|
779
|
+
* The visible cost: standing on `/reports`, which Admin owns as a
|
|
780
|
+
* child, nothing in the bar was marked at all.
|
|
781
|
+
*/
|
|
688
782
|
const updateNavItems = (nav) => {
|
|
689
|
-
const
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
return url.startsWith('/') ? url : `/${url}`;
|
|
693
|
-
}
|
|
694
|
-
return '';
|
|
695
|
-
};
|
|
696
|
-
|
|
697
|
-
// Ensure both URLs have the leading slash for consistent comparison
|
|
698
|
-
const currentUrl = cleanUrl(currentPage);
|
|
699
|
-
const navUrl = nav.url ? cleanUrl(nav.url) : 'dashboard';
|
|
700
|
-
|
|
701
|
-
// Split current URL into segments
|
|
702
|
-
const currentUrlSegments = currentUrl
|
|
703
|
-
.split('/')
|
|
704
|
-
.filter(Boolean);
|
|
705
|
-
|
|
706
|
-
// Handle the case where navUrl is just '/'
|
|
707
|
-
if (
|
|
708
|
-
navUrl === '/' &&
|
|
709
|
-
(currentUrl === '/dashboard' || currentUrl === '/')
|
|
710
|
-
) {
|
|
711
|
-
return { ...nav, class: 'nav-item active' }; // Mark as active for dashboard if navUrl is '/'
|
|
712
|
-
}
|
|
713
|
-
|
|
714
|
-
// If current URL has 2 or fewer segments, check if navUrl is contained in currentUrl
|
|
715
|
-
if (currentUrlSegments.length <= 2) {
|
|
716
|
-
if (currentUrl.includes(navUrl) && nav.id !== 'dashboard') {
|
|
717
|
-
return { ...nav, class: 'nav-item active' }; // Mark as active if contained
|
|
718
|
-
}
|
|
719
|
-
} else {
|
|
720
|
-
// If more than 2 segments, use exact match logic
|
|
721
|
-
if (
|
|
722
|
-
navUrl === currentUrl ||
|
|
723
|
-
(nav.children &&
|
|
724
|
-
nav.children.some(
|
|
725
|
-
(child) =>
|
|
726
|
-
cleanUrl(child.url) === `/${currentPage}`
|
|
727
|
-
))
|
|
728
|
-
) {
|
|
729
|
-
return { ...nav, class: 'nav-item active' }; // Mark as active
|
|
730
|
-
}
|
|
731
|
-
}
|
|
783
|
+
const childUrls = (nav.children || []).map(
|
|
784
|
+
(child) => child.url
|
|
785
|
+
);
|
|
732
786
|
|
|
733
|
-
return {
|
|
787
|
+
return {
|
|
788
|
+
...nav,
|
|
789
|
+
class: isNavActive(nav.url, childUrls, currentPage)
|
|
790
|
+
? 'nav-item active'
|
|
791
|
+
: 'nav-item',
|
|
792
|
+
};
|
|
734
793
|
};
|
|
735
794
|
|
|
736
795
|
return {
|
|
@@ -3,10 +3,36 @@ import { useNavigate } from 'react-router-dom';
|
|
|
3
3
|
import moment from 'moment';
|
|
4
4
|
import parse from 'html-react-parser';
|
|
5
5
|
import { toast } from 'react-toastify';
|
|
6
|
-
import { Bell, X, Check } from 'lucide-react';
|
|
6
|
+
import { Bell, X, Check, Monitor } from 'lucide-react';
|
|
7
7
|
import CustomFetch from './Fetch';
|
|
8
|
+
import {
|
|
9
|
+
getPermission,
|
|
10
|
+
isEnabled as desktopAlertsEnabled,
|
|
11
|
+
isSupported as desktopAlertsSupported,
|
|
12
|
+
notify as desktopNotify,
|
|
13
|
+
requestPermission as requestDesktopPermission,
|
|
14
|
+
setEnabled as setDesktopAlertsEnabled,
|
|
15
|
+
} from './notify/desktopNotifications';
|
|
16
|
+
// Pure subscription bookkeeping. It lives under sms/ because that is where it
|
|
17
|
+
// was written, but there is nothing messaging-specific in it — importing it
|
|
18
|
+
// keeps one implementation of "has this channel actually been authorised?".
|
|
19
|
+
import { subscriptionMonitor } from './sms/smsLiveState';
|
|
8
20
|
import styles from './styles/Notification.module.scss';
|
|
9
21
|
|
|
22
|
+
/**
|
|
23
|
+
* The event Laravel broadcasts a notification as, and what Echo's
|
|
24
|
+
* `.notification()` helper binds to. Named here so the fallback path below can
|
|
25
|
+
* unbind it explicitly.
|
|
26
|
+
*/
|
|
27
|
+
const BROADCAST_EVENT = '.Illuminate\\Notifications\\Events\\BroadcastNotificationCreated';
|
|
28
|
+
|
|
29
|
+
/** Polling, with and without a confirmed websocket. */
|
|
30
|
+
const POLL_INTERVAL = 60000;
|
|
31
|
+
const LIVE_POLL_INTERVAL = 300000;
|
|
32
|
+
|
|
33
|
+
/** The bold line on a desktop alert. */
|
|
34
|
+
const ALERT_TITLE = 'Throughlife';
|
|
35
|
+
|
|
10
36
|
// Helper function to sanitize HTML content and remove unwanted styling from anchor tags
|
|
11
37
|
const sanitizeHtml = (html) => {
|
|
12
38
|
if (!html) return '';
|
|
@@ -47,6 +73,26 @@ const sanitizeHtml = (html) => {
|
|
|
47
73
|
return tempDiv.innerHTML;
|
|
48
74
|
};
|
|
49
75
|
|
|
76
|
+
/**
|
|
77
|
+
* A label with its markup taken off, for the desktop toast.
|
|
78
|
+
*
|
|
79
|
+
* Notification labels are stored as HTML (the bell parses them), and the OS
|
|
80
|
+
* toast renders text — so "<b>New task</b>" would otherwise appear verbatim.
|
|
81
|
+
*/
|
|
82
|
+
const plainText = (html) => {
|
|
83
|
+
if (!html) return '';
|
|
84
|
+
|
|
85
|
+
if (typeof document === 'undefined') {
|
|
86
|
+
return String(html).replace(/<[^>]*>/g, '').trim();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const holder = document.createElement('div');
|
|
90
|
+
|
|
91
|
+
holder.innerHTML = String(html);
|
|
92
|
+
|
|
93
|
+
return (holder.textContent || '').replace(/\s+/g, ' ').trim();
|
|
94
|
+
};
|
|
95
|
+
|
|
50
96
|
function Notification(props) {
|
|
51
97
|
const navigate = useNavigate();
|
|
52
98
|
const wrapperRef = useRef(null);
|
|
@@ -54,7 +100,22 @@ function Notification(props) {
|
|
|
54
100
|
const [show, setShow] = useState(false);
|
|
55
101
|
const [loading, setLoading] = useState(false);
|
|
56
102
|
const [processingIds, setProcessingIds] = useState([]);
|
|
57
|
-
const { setSystemAuth } = props;
|
|
103
|
+
const { setSystemAuth, echo = null, channel = null } = props;
|
|
104
|
+
|
|
105
|
+
// Live means *confirmed*: Echo hands back a channel object long before
|
|
106
|
+
// /broadcasting/auth has approved it, and treating that as live is how a
|
|
107
|
+
// bell ends up subscribed to nothing and polling every five minutes.
|
|
108
|
+
const [live, setLive] = useState(false);
|
|
109
|
+
const [alertsOn, setAlertsOn] = useState(() => desktopAlertsEnabled());
|
|
110
|
+
const [permission, setPermission] = useState(() => getPermission());
|
|
111
|
+
|
|
112
|
+
const alertsSupported = desktopAlertsSupported();
|
|
113
|
+
|
|
114
|
+
// Read through refs so a consumer passing inline arrows — `echo={() =>
|
|
115
|
+
// getEcho()}` is the normal way to write it — does not tear the
|
|
116
|
+
// subscription down on every render. Only the channel name does that.
|
|
117
|
+
const echoRef = useRef(echo);
|
|
118
|
+
echoRef.current = echo;
|
|
58
119
|
|
|
59
120
|
const fetchNotification = useCallback(() => {
|
|
60
121
|
setLoading(true);
|
|
@@ -184,26 +245,191 @@ function Notification(props) {
|
|
|
184
245
|
setShow(false);
|
|
185
246
|
}, [navigate]);
|
|
186
247
|
|
|
248
|
+
/**
|
|
249
|
+
* One notification, straight off the websocket.
|
|
250
|
+
*
|
|
251
|
+
* The payload is App\Notifications\SystemAlert::toBroadcast() — the same
|
|
252
|
+
* id the database row was written under, so a live item and a polled one
|
|
253
|
+
* are interchangeable and "mark as read" works on either.
|
|
254
|
+
*/
|
|
255
|
+
const handleBroadcast = useCallback(
|
|
256
|
+
(payload) => {
|
|
257
|
+
const label = payload?.label;
|
|
258
|
+
|
|
259
|
+
if (!label) return;
|
|
260
|
+
|
|
261
|
+
const id = payload.id ?? `live-${Date.now()}`;
|
|
262
|
+
const url = payload.url ?? null;
|
|
263
|
+
|
|
264
|
+
setItems((prev) => {
|
|
265
|
+
// Pusher can redeliver, and a poll may have raced us here.
|
|
266
|
+
if (prev.some((item) => item.id === id)) return prev;
|
|
267
|
+
|
|
268
|
+
return [
|
|
269
|
+
{
|
|
270
|
+
id,
|
|
271
|
+
data: { label, url },
|
|
272
|
+
read_at: null,
|
|
273
|
+
created_at: payload.created_at ?? new Date().toISOString(),
|
|
274
|
+
},
|
|
275
|
+
...prev,
|
|
276
|
+
];
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
desktopNotify({
|
|
280
|
+
title: ALERT_TITLE,
|
|
281
|
+
body: plainText(label),
|
|
282
|
+
tag: `notification-${id}`,
|
|
283
|
+
url,
|
|
284
|
+
navigate,
|
|
285
|
+
});
|
|
286
|
+
},
|
|
287
|
+
[navigate]
|
|
288
|
+
);
|
|
289
|
+
|
|
290
|
+
const broadcastRef = useRef(handleBroadcast);
|
|
291
|
+
broadcastRef.current = handleBroadcast;
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Subscribe to this user's private channel, when the consumer named one.
|
|
295
|
+
*
|
|
296
|
+
* `channel` is deliberately allowed to be null — a build with no
|
|
297
|
+
* VITE_APP_ENV cannot know whether it is dev or production, and dev and
|
|
298
|
+
* production share one Pusher app. Guessing there would put a developer's
|
|
299
|
+
* browser on the production channel; polling is the safe answer.
|
|
300
|
+
*/
|
|
187
301
|
useEffect(() => {
|
|
188
|
-
|
|
302
|
+
if (!channel || typeof window === 'undefined') {
|
|
303
|
+
setLive(false);
|
|
304
|
+
|
|
305
|
+
return undefined;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const source = echoRef.current;
|
|
309
|
+
const instance = typeof source === 'function' ? source() : source;
|
|
310
|
+
|
|
311
|
+
if (!instance || typeof instance.private !== 'function') {
|
|
312
|
+
setLive(false);
|
|
313
|
+
|
|
314
|
+
return undefined;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
let subscription;
|
|
318
|
+
|
|
319
|
+
try {
|
|
320
|
+
subscription = instance.private(channel);
|
|
321
|
+
} catch (error) {
|
|
322
|
+
setLive(false);
|
|
323
|
+
|
|
324
|
+
return undefined;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
if (!subscription) {
|
|
328
|
+
setLive(false);
|
|
189
329
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
330
|
+
return undefined;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const handler = (payload) => broadcastRef.current?.(payload || {});
|
|
334
|
+
|
|
335
|
+
try {
|
|
336
|
+
// Echo's own helper for Laravel's broadcast notifications; the
|
|
337
|
+
// raw `listen` is the fallback for anything hand-rolled.
|
|
338
|
+
if (typeof subscription.notification === 'function') {
|
|
339
|
+
subscription.notification(handler);
|
|
340
|
+
} else {
|
|
341
|
+
subscription.listen(BROADCAST_EVENT, handler);
|
|
193
342
|
}
|
|
194
|
-
}
|
|
343
|
+
} catch (error) {
|
|
344
|
+
setLive(false);
|
|
345
|
+
|
|
346
|
+
return undefined;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const stopMonitoring = subscriptionMonitor(subscription, {
|
|
350
|
+
onSuccess: () => setLive(true),
|
|
351
|
+
onError: () => setLive(false),
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
return () => {
|
|
355
|
+
setLive(false);
|
|
356
|
+
stopMonitoring();
|
|
357
|
+
|
|
358
|
+
try {
|
|
359
|
+
subscription.stopListening?.(BROADCAST_EVENT);
|
|
360
|
+
instance.leave(channel);
|
|
361
|
+
} catch (error) {
|
|
362
|
+
// Nothing to clean up.
|
|
363
|
+
}
|
|
364
|
+
};
|
|
365
|
+
}, [channel]);
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* The fallback, and the safety net.
|
|
369
|
+
*
|
|
370
|
+
* Off the websocket this is the only thing keeping the bell current, so it
|
|
371
|
+
* runs every minute. Once a channel confirms it drops to five — slow
|
|
372
|
+
* enough to be free, quick enough that a dropped event is invisible.
|
|
373
|
+
*/
|
|
374
|
+
useEffect(() => {
|
|
375
|
+
fetchNotification();
|
|
376
|
+
|
|
377
|
+
const interval = setInterval(
|
|
378
|
+
() => {
|
|
379
|
+
if (!document.hidden) {
|
|
380
|
+
fetchNotification();
|
|
381
|
+
}
|
|
382
|
+
},
|
|
383
|
+
live ? LIVE_POLL_INTERVAL : POLL_INTERVAL
|
|
384
|
+
);
|
|
195
385
|
|
|
386
|
+
return () => clearInterval(interval);
|
|
387
|
+
}, [fetchNotification, live]);
|
|
388
|
+
|
|
389
|
+
useEffect(() => {
|
|
196
390
|
document.addEventListener('mousedown', handleClickOutside, false);
|
|
197
391
|
|
|
198
392
|
return function cleanup() {
|
|
199
|
-
clearInterval(interval);
|
|
200
393
|
document.removeEventListener(
|
|
201
394
|
'mousedown',
|
|
202
395
|
handleClickOutside,
|
|
203
396
|
false
|
|
204
397
|
);
|
|
205
398
|
};
|
|
206
|
-
}, [
|
|
399
|
+
}, [handleClickOutside]);
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* The switch at the foot of the dropdown.
|
|
403
|
+
*
|
|
404
|
+
* Turning it on for the first time is what asks the browser for
|
|
405
|
+
* permission — the prompt only appears inside a user gesture, which is why
|
|
406
|
+
* this cannot be done on mount. A refusal leaves the switch off and the
|
|
407
|
+
* row explains itself; the browser will not ask twice.
|
|
408
|
+
*/
|
|
409
|
+
const toggleDesktopAlerts = useCallback(() => {
|
|
410
|
+
if (alertsOn) {
|
|
411
|
+
setDesktopAlertsEnabled(false);
|
|
412
|
+
setAlertsOn(false);
|
|
413
|
+
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
if (getPermission() === 'granted') {
|
|
418
|
+
setDesktopAlertsEnabled(true);
|
|
419
|
+
setAlertsOn(true);
|
|
420
|
+
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
requestDesktopPermission().then((result) => {
|
|
425
|
+
setPermission(result);
|
|
426
|
+
|
|
427
|
+
if (result !== 'granted') return;
|
|
428
|
+
|
|
429
|
+
setDesktopAlertsEnabled(true);
|
|
430
|
+
setAlertsOn(true);
|
|
431
|
+
});
|
|
432
|
+
}, [alertsOn]);
|
|
207
433
|
|
|
208
434
|
const formatTimeAgo = (date) => {
|
|
209
435
|
const now = moment();
|
|
@@ -225,6 +451,7 @@ function Notification(props) {
|
|
|
225
451
|
className={styles.notificationTrigger}
|
|
226
452
|
onClick={toggleNotification}
|
|
227
453
|
data-tooltip-id="system-tooltip"
|
|
454
|
+
data-tooltip-place="bottom"
|
|
228
455
|
data-tooltip-content="Notifications"
|
|
229
456
|
aria-label="Notifications"
|
|
230
457
|
>
|
|
@@ -331,6 +558,49 @@ function Notification(props) {
|
|
|
331
558
|
)}
|
|
332
559
|
</div>
|
|
333
560
|
|
|
561
|
+
{alertsSupported && (
|
|
562
|
+
<div className={styles.alertsRow}>
|
|
563
|
+
<span className={styles.alertsLabel}>
|
|
564
|
+
<Monitor
|
|
565
|
+
size={14}
|
|
566
|
+
strokeWidth={2}
|
|
567
|
+
aria-hidden="true"
|
|
568
|
+
/>
|
|
569
|
+
Desktop alerts
|
|
570
|
+
</span>
|
|
571
|
+
|
|
572
|
+
{permission === 'denied' ? (
|
|
573
|
+
<span className={styles.alertsBlocked}>
|
|
574
|
+
Blocked in browser settings
|
|
575
|
+
</span>
|
|
576
|
+
) : (
|
|
577
|
+
<button
|
|
578
|
+
type="button"
|
|
579
|
+
className={[
|
|
580
|
+
styles.toggle,
|
|
581
|
+
alertsOn ? styles.toggleOn : '',
|
|
582
|
+
]
|
|
583
|
+
.filter(Boolean)
|
|
584
|
+
.join(' ')}
|
|
585
|
+
onClick={toggleDesktopAlerts}
|
|
586
|
+
role="switch"
|
|
587
|
+
aria-checked={alertsOn}
|
|
588
|
+
aria-label="Desktop alerts"
|
|
589
|
+
>
|
|
590
|
+
<span
|
|
591
|
+
className={styles.toggleTrack}
|
|
592
|
+
aria-hidden="true"
|
|
593
|
+
>
|
|
594
|
+
<span
|
|
595
|
+
className={styles.toggleKnob}
|
|
596
|
+
/>
|
|
597
|
+
</span>
|
|
598
|
+
{alertsOn ? 'On' : 'Off'}
|
|
599
|
+
</button>
|
|
600
|
+
)}
|
|
601
|
+
</div>
|
|
602
|
+
)}
|
|
603
|
+
|
|
334
604
|
<div className={styles.notiboxActions}>
|
|
335
605
|
<button
|
|
336
606
|
className={styles.viewAllBtn}
|
|
@@ -9,6 +9,15 @@ function TableFilter({
|
|
|
9
9
|
type,
|
|
10
10
|
onFilterChange,
|
|
11
11
|
tabs,
|
|
12
|
+
// `variant="underline"` lays the rail out as a horizontal tab strip with
|
|
13
|
+
// an accent edge under the selected tab, at every width — the same
|
|
14
|
+
// treatment narrow viewports already get for a flat rail (see the
|
|
15
|
+
// `.flatFilter` block in the stylesheet), promoted to an opt-in.
|
|
16
|
+
//
|
|
17
|
+
// A vertical rail spends a fifth of the page on navigation, which pays for
|
|
18
|
+
// itself at seven tabs and does not at two. Pages say which they are; the
|
|
19
|
+
// default is unchanged.
|
|
20
|
+
variant,
|
|
12
21
|
}) {
|
|
13
22
|
const [visibleChildren, setVisibleChildren] = useState({});
|
|
14
23
|
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
|
@@ -661,11 +670,15 @@ function TableFilter({
|
|
|
661
670
|
}
|
|
662
671
|
}, [isFlatStrip, activeId]);
|
|
663
672
|
|
|
673
|
+
// Gated on `isFlatStrip`: the strip is one row of tabs, and a rail with
|
|
674
|
+
// expandable parents has no single row to lay out.
|
|
675
|
+
const isUnderline = variant === 'underline' && isFlatStrip;
|
|
676
|
+
|
|
664
677
|
return (
|
|
665
678
|
<div
|
|
666
679
|
className={`${styles.tableFilterContainer} ${
|
|
667
680
|
isFlatStrip ? styles.flatFilter : ''
|
|
668
|
-
}`}
|
|
681
|
+
} ${isUnderline ? styles.underlineFilter : ''}`}
|
|
669
682
|
>
|
|
670
683
|
{!isFlatStrip && (
|
|
671
684
|
<div className={styles.mobileToggle} onClick={toggleMobileMenu}>
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The brand side of a two-pane auth screen.
|
|
5
|
+
*
|
|
6
|
+
* Markup counterpart to the `auth-brand-panel` mixin in
|
|
7
|
+
* styles/_authBrandPanel.scss — the staff Login, the shared AuthShell and the
|
|
8
|
+
* client portal's split layout all render exactly this, and each supplies its
|
|
9
|
+
* own CSS module (the class names are identical because they all include that
|
|
10
|
+
* one mixin).
|
|
11
|
+
*
|
|
12
|
+
* `aria-hidden` is deliberate and applies to every consumer: everything this
|
|
13
|
+
* panel says is said again, in words, by the form beside it. A screen reader
|
|
14
|
+
* announcing the brand line twice before reaching the first field is worse
|
|
15
|
+
* than not announcing the photograph at all.
|
|
16
|
+
*
|
|
17
|
+
* Renders the `<aside>` unconditionally, even with nothing to put in it — it is
|
|
18
|
+
* a layout column, and a grid with a missing child is a broken screen rather
|
|
19
|
+
* than a tidier one. Only the ink block is conditional.
|
|
20
|
+
*/
|
|
21
|
+
const AuthBrandPanel = ({ styles, loginBg, branding = {} }) => (
|
|
22
|
+
<aside
|
|
23
|
+
className={styles.brand}
|
|
24
|
+
style={loginBg ? { backgroundImage: `url(${loginBg})` } : undefined}
|
|
25
|
+
aria-hidden="true"
|
|
26
|
+
>
|
|
27
|
+
{(branding.eyebrow || branding.name || branding.tagline) && (
|
|
28
|
+
<div className={styles.brandInk}>
|
|
29
|
+
{branding.eyebrow && (
|
|
30
|
+
<p className={styles.eyebrow}>{branding.eyebrow}</p>
|
|
31
|
+
)}
|
|
32
|
+
{branding.name && (
|
|
33
|
+
<p className={styles.brandLine}>{branding.name}</p>
|
|
34
|
+
)}
|
|
35
|
+
{branding.tagline && (
|
|
36
|
+
<p className={styles.brandSub}>{branding.tagline}</p>
|
|
37
|
+
)}
|
|
38
|
+
</div>
|
|
39
|
+
)}
|
|
40
|
+
</aside>
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
export default AuthBrandPanel;
|