@trackany-device/components 1.1.0 → 1.2.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 +9 -9
- package/package.json +133 -4
- package/src/assets/index.ts +120 -0
- package/src/assets/media/avatars/300-1.png +0 -0
- package/src/assets/media/avatars/300-10.png +0 -0
- package/src/assets/media/avatars/300-11.png +0 -0
- package/src/assets/media/avatars/300-12.png +0 -0
- package/src/assets/media/avatars/300-13.png +0 -0
- package/src/assets/media/avatars/300-14.png +0 -0
- package/src/assets/media/avatars/300-15.png +0 -0
- package/src/assets/media/avatars/300-16.png +0 -0
- package/src/assets/media/avatars/300-17.png +0 -0
- package/src/assets/media/avatars/300-18.png +0 -0
- package/src/assets/media/avatars/300-19.png +0 -0
- package/src/assets/media/avatars/300-2.png +0 -0
- package/src/assets/media/avatars/300-20.png +0 -0
- package/src/assets/media/avatars/300-21.png +0 -0
- package/src/assets/media/avatars/300-22.png +0 -0
- package/src/assets/media/avatars/300-23.png +0 -0
- package/src/assets/media/avatars/300-24.png +0 -0
- package/src/assets/media/avatars/300-25.png +0 -0
- package/src/assets/media/avatars/300-26.png +0 -0
- package/src/assets/media/avatars/300-27.png +0 -0
- package/src/assets/media/avatars/300-28.png +0 -0
- package/src/assets/media/avatars/300-29.png +0 -0
- package/src/assets/media/avatars/300-3.png +0 -0
- package/src/assets/media/avatars/300-30.png +0 -0
- package/src/assets/media/avatars/300-31.png +0 -0
- package/src/assets/media/avatars/300-32.png +0 -0
- package/src/assets/media/avatars/300-33.png +0 -0
- package/src/assets/media/avatars/300-34.png +0 -0
- package/src/assets/media/avatars/300-4.png +0 -0
- package/src/assets/media/avatars/300-5.png +0 -0
- package/src/assets/media/avatars/300-6.png +0 -0
- package/src/assets/media/avatars/300-7.png +0 -0
- package/src/assets/media/avatars/300-8.png +0 -0
- package/src/assets/media/avatars/300-9.png +0 -0
- package/src/assets/media/avatars/blank.png +0 -0
- package/src/assets/media/avatars/gray/1.png +0 -0
- package/src/assets/media/avatars/gray/2.png +0 -0
- package/src/assets/media/avatars/gray/3.png +0 -0
- package/src/assets/media/avatars/gray/4.png +0 -0
- package/src/assets/media/avatars/gray/5.png +0 -0
- package/src/assets/media/illustrations/1-dark.svg +78 -0
- package/src/assets/media/illustrations/1.svg +78 -0
- package/src/assets/media/illustrations/10-dark.svg +148 -0
- package/src/assets/media/illustrations/10.svg +148 -0
- package/src/assets/media/illustrations/11-dark.svg +234 -0
- package/src/assets/media/illustrations/11.svg +234 -0
- package/src/assets/media/illustrations/12.svg +138 -0
- package/src/assets/media/illustrations/13.svg +205 -0
- package/src/assets/media/illustrations/14.svg +259 -0
- package/src/assets/media/illustrations/15.svg +242 -0
- package/src/assets/media/illustrations/16.svg +128 -0
- package/src/assets/media/illustrations/17.svg +180 -0
- package/src/assets/media/illustrations/18-dark.svg +6 -0
- package/src/assets/media/illustrations/18.svg +6 -0
- package/src/assets/media/illustrations/19-dark.svg +8 -0
- package/src/assets/media/illustrations/19.svg +8 -0
- package/src/assets/media/illustrations/2-dark.svg +78 -0
- package/src/assets/media/illustrations/2.svg +78 -0
- package/src/assets/media/illustrations/20-dark.svg +13 -0
- package/src/assets/media/illustrations/20.svg +13 -0
- package/src/assets/media/illustrations/21-dark.svg +9 -0
- package/src/assets/media/illustrations/21.svg +9 -0
- package/src/assets/media/illustrations/22-dark.svg +17 -0
- package/src/assets/media/illustrations/22.svg +17 -0
- package/src/assets/media/illustrations/23-dark.svg +13 -0
- package/src/assets/media/illustrations/23.svg +13 -0
- package/src/assets/media/illustrations/24.svg +6 -0
- package/src/assets/media/illustrations/25.svg +8 -0
- package/src/assets/media/illustrations/26.svg +8 -0
- package/src/assets/media/illustrations/27.svg +6 -0
- package/src/assets/media/illustrations/28-dark.svg +28 -0
- package/src/assets/media/illustrations/28.svg +14 -0
- package/src/assets/media/illustrations/29-dark.svg +6 -0
- package/src/assets/media/illustrations/29.svg +6 -0
- package/src/assets/media/illustrations/3-dark.svg +70 -0
- package/src/assets/media/illustrations/3.svg +70 -0
- package/src/assets/media/illustrations/30-dark.svg +8 -0
- package/src/assets/media/illustrations/30.svg +8 -0
- package/src/assets/media/illustrations/31-dark.svg +9 -0
- package/src/assets/media/illustrations/31.svg +9 -0
- package/src/assets/media/illustrations/32-dark.svg +10 -0
- package/src/assets/media/illustrations/32.svg +10 -0
- package/src/assets/media/illustrations/33-dark.svg +15 -0
- package/src/assets/media/illustrations/33.svg +15 -0
- package/src/assets/media/illustrations/34-dark.svg +5 -0
- package/src/assets/media/illustrations/34.svg +5 -0
- package/src/assets/media/illustrations/35-dark.svg +11 -0
- package/src/assets/media/illustrations/35.svg +4 -0
- package/src/assets/media/illustrations/4-dark.svg +51 -0
- package/src/assets/media/illustrations/4.svg +51 -0
- package/src/assets/media/illustrations/5-dark.svg +78 -0
- package/src/assets/media/illustrations/5.svg +78 -0
- package/src/assets/media/illustrations/6.svg +58 -0
- package/src/assets/media/illustrations/7.svg +49 -0
- package/src/assets/media/illustrations/8.svg +61 -0
- package/src/assets/media/illustrations/9.svg +57 -0
- package/src/assets/media/misc/placeholder.svg +15 -0
- package/src/components/devices/devices-mini-map.tsx +32 -26
- package/src/components/devices/map-marker.tsx +98 -0
- package/src/components/ui/checklist-item.tsx +55 -0
- package/src/components/ui/plan-card.tsx +68 -0
- package/src/components/ui/settings-row.tsx +32 -0
- package/src/components/ui/settings-section.tsx +22 -0
- package/src/components/ui/usage-meter.tsx +35 -0
- package/src/index.ts +12 -1
- package/src/layouts/LayoutSwitcher.tsx +220 -0
- package/src/layouts/app/MegaMenuLayout.tsx +69 -34
- package/src/layouts/app/MegaMenuNavbarLayout.tsx +73 -37
- package/src/layouts/app/NavbarCollapsibleLayout.tsx +53 -4
- package/src/layouts/app/NavbarSidebarLayout.tsx +74 -29
- package/src/layouts/app/SidebarDualMenuLayout.tsx +48 -5
- package/src/layouts/app/SidebarFixedLayout.tsx +15 -10
- package/src/layouts/app/SidebarMinimalLayout.tsx +51 -3
- package/src/layouts/app/SidebarTabsLayout.tsx +48 -2
- package/src/layouts/app/SplitSidebarLayout.tsx +91 -43
- package/src/layouts/app/TopNavLayout.tsx +7 -12
- package/src/layouts/app/WorkspaceSidebarLayout.tsx +103 -46
- package/src/layouts/app/partials/Navbar.tsx +61 -10
- package/src/layouts/app/partials/Toolbar.tsx +1 -1
- package/src/layouts/auth/AuthCenteredLayout.tsx +10 -4
- package/src/lib/map-markers.ts +21 -3
- package/src/pages/login/ConfirmPasswordPage.tsx +35 -0
- package/src/pages/login/ForgotPasswordPage.tsx +41 -0
- package/src/pages/login/LoginPage.tsx +50 -0
- package/src/pages/login/RegisterPage.tsx +41 -0
- package/src/pages/login/ResetPasswordPage.tsx +35 -0
- package/src/pages/login/TwoFactorChallengePage.tsx +41 -0
- package/src/pages/login/VerifyEmailPage.tsx +31 -0
- package/src/pages/my/ActivityPage.tsx +160 -0
- package/src/pages/my/GetStartedPage.tsx +221 -0
- package/src/pages/my/NotificationsPage.tsx +133 -0
- package/src/pages/my/ProfilePage.tsx +650 -0
- package/src/pages/my/TenantsPage.tsx +37 -0
- package/src/pages/tenant/AssigneesPage.tsx +155 -0
- package/src/pages/tenant/BeatsPage.tsx +403 -0
- package/src/pages/tenant/DashboardPage.tsx +195 -0
- package/src/pages/tenant/GeofencePage.tsx +422 -0
- package/src/pages/tenant/IncidentsPage.tsx +214 -0
- package/src/pages/tenant/IntegrationsPage.tsx +352 -0
- package/src/pages/tenant/InvitePage.tsx +153 -0
- package/src/pages/tenant/LiveStreamPage.tsx +141 -0
- package/src/pages/tenant/MembersPage.tsx +414 -0
- package/src/pages/tenant/TenantProfilePage.tsx +701 -0
- package/src/platform/adapters/default.tsx +1 -1
- package/src/platform/types.ts +2 -0
- package/src/styles/components/apexcharts.css +101 -0
- package/src/styles/components/image-input.css +51 -0
- package/src/styles/components/leaflet.css +25 -0
- package/src/styles/components/rating.css +89 -0
- package/src/styles/components/scrollable.css +119 -0
- package/src/styles/layout.css +24 -0
- package/src/styles/layouts/sidebar-fixed.css +93 -138
- package/src/styles/themes.css +5 -5
- package/src/vite-env.d.ts +21 -0
- package/src/layouts/SettingsLayout.tsx +0 -21
- package/src/layouts/app-layout.tsx +0 -29
|
@@ -8,6 +8,8 @@ import { Toolbar } from './partials/Toolbar';
|
|
|
8
8
|
import { Footer } from './partials/Footer';
|
|
9
9
|
import { HeaderTopbar } from './partials/HeaderTopbar';
|
|
10
10
|
|
|
11
|
+
const HEADER_HEIGHT = 70;
|
|
12
|
+
|
|
11
13
|
interface TopNavLayoutProps extends BaseAppLayoutProps {
|
|
12
14
|
headerRightSlot?: ReactNode;
|
|
13
15
|
navRightSlot?: ReactNode;
|
|
@@ -26,7 +28,7 @@ export function TopNavLayout({
|
|
|
26
28
|
breadcrumbs = [],
|
|
27
29
|
toolbarActions,
|
|
28
30
|
stickyHeader = true,
|
|
29
|
-
stickyOffset =
|
|
31
|
+
stickyOffset = HEADER_HEIGHT,
|
|
30
32
|
onLogout,
|
|
31
33
|
settingsUrl,
|
|
32
34
|
logoutUrl,
|
|
@@ -46,21 +48,14 @@ export function TopNavLayout({
|
|
|
46
48
|
return () => window.removeEventListener('scroll', handleScroll);
|
|
47
49
|
}, [stickyHeader, stickyOffset]);
|
|
48
50
|
|
|
49
|
-
const headerHeight = isSticky ? '60px' : '100px';
|
|
50
|
-
|
|
51
51
|
return (
|
|
52
|
-
<div
|
|
53
|
-
className="flex grow flex-col min-h-screen"
|
|
54
|
-
style={{ '--header-height': headerHeight } as React.CSSProperties}
|
|
55
|
-
>
|
|
52
|
+
<div className="flex grow flex-col min-h-screen">
|
|
56
53
|
{/* Header */}
|
|
57
54
|
<header
|
|
58
55
|
className={cn(
|
|
59
|
-
'flex items-center shrink-0
|
|
60
|
-
'
|
|
61
|
-
stickyHeader && isSticky && 'fixed z-10 top-0 inset-x-0 shadow-xs backdrop-blur-md bg-background/80',
|
|
56
|
+
'flex items-center shrink-0 h-[70px] border-b border-border bg-background transition-shadow',
|
|
57
|
+
stickyHeader && isSticky && 'fixed z-10 top-0 inset-x-0 shadow-sm backdrop-blur-md bg-background/90',
|
|
62
58
|
)}
|
|
63
|
-
style={{ height: headerHeight }}
|
|
64
59
|
>
|
|
65
60
|
<div className="container mx-auto px-4 flex justify-between items-center gap-4">
|
|
66
61
|
{/* Logo */}
|
|
@@ -81,7 +76,7 @@ export function TopNavLayout({
|
|
|
81
76
|
</header>
|
|
82
77
|
|
|
83
78
|
{/* Spacer when header is sticky */}
|
|
84
|
-
{stickyHeader && isSticky && <div style={{ height:
|
|
79
|
+
{stickyHeader && isSticky && <div style={{ height: `${HEADER_HEIGHT}px` }} aria-hidden="true" />}
|
|
85
80
|
|
|
86
81
|
{/* Horizontal navbar */}
|
|
87
82
|
<Navbar navItems={navItems} currentUrl={currentUrl} rightSlot={navRightSlot} />
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
'use client';
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
import { type ReactNode } from 'react';
|
|
2
|
+
|
|
3
|
+
import { useState, type ReactNode } from 'react';
|
|
5
4
|
import { cn } from '../../lib/utils';
|
|
6
5
|
import type { BaseAppLayoutProps } from './layout-types';
|
|
7
6
|
import { Toolbar } from './partials/Toolbar';
|
|
@@ -9,6 +8,8 @@ import { Footer } from './partials/Footer';
|
|
|
9
8
|
import { HeaderTopbar } from './partials/HeaderTopbar';
|
|
10
9
|
import { AccordionMenu, AccordionMenuGroup, AccordionMenuItem, AccordionMenuSub, AccordionMenuSubContent, AccordionMenuSubTrigger } from '../../components/ui/accordion-menu';
|
|
11
10
|
import { ScrollArea } from '../../components/ui/scroll-area';
|
|
11
|
+
import { Menu, X } from 'lucide-react';
|
|
12
|
+
import { Button } from '../../controls/Button';
|
|
12
13
|
|
|
13
14
|
interface WorkspaceSidebarLayoutProps extends BaseAppLayoutProps {
|
|
14
15
|
workspaces?: Array<{ id: string; name: string; icon?: string; href: string }>;
|
|
@@ -28,60 +29,116 @@ export function WorkspaceSidebarLayout({
|
|
|
28
29
|
onLogout, settingsUrl, logoutUrl, unreadCount = 0,
|
|
29
30
|
footerLinks = [], copyright, showToolbar = true,
|
|
30
31
|
}: WorkspaceSidebarLayoutProps) {
|
|
32
|
+
const [mobileOpen, setMobileOpen] = useState(false);
|
|
31
33
|
const allNavItems = [...navItems, ...secondaryItems];
|
|
34
|
+
|
|
32
35
|
return (
|
|
33
36
|
<div className="flex min-h-screen">
|
|
34
|
-
{/*
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
<a
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
{/* Main sidebar */}
|
|
47
|
-
<aside className="w-56 shrink-0 flex flex-col border-e border-sidebar-border bg-sidebar">
|
|
48
|
-
{!workspaces.length && logo && (
|
|
49
|
-
<div className="flex items-center gap-2 px-4 h-[70px] border-b border-sidebar-border shrink-0">
|
|
50
|
-
<a href={logoHref}>{logo}</a>
|
|
51
|
-
{appName && <span className="text-sm font-semibold">{appName}</span>}
|
|
37
|
+
{/* Desktop sidebar — always in-flow, hidden on mobile */}
|
|
38
|
+
<div className="hidden lg:flex shrink-0">
|
|
39
|
+
{/* Workspace strip */}
|
|
40
|
+
{workspaces.length > 0 && (
|
|
41
|
+
<div className="w-[52px] flex flex-col items-center py-3 gap-1.5 border-e border-sidebar-border bg-sidebar shrink-0">
|
|
42
|
+
{logo && <a href={logoHref} className="mb-2">{logo}</a>}
|
|
43
|
+
{workspaces.map((ws, i) => (
|
|
44
|
+
<a key={i} href={ws.href} className={cn('w-8 h-8 rounded-lg flex items-center justify-center text-xs font-bold transition-colors', activeWorkspace === ws.id ? 'bg-sidebar-primary text-sidebar-primary-foreground' : 'bg-sidebar-accent text-sidebar-foreground hover:bg-sidebar-accent/80')}>
|
|
45
|
+
{ws.icon ?? ws.name.slice(0, 2)}
|
|
46
|
+
</a>
|
|
47
|
+
))}
|
|
52
48
|
</div>
|
|
53
49
|
)}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
) : (
|
|
66
|
-
<AccordionMenuItem key={i} value={item.href ?? item.title} asChild><a href={item.href}>{item.title}</a></AccordionMenuItem>
|
|
67
|
-
)
|
|
68
|
-
))}
|
|
69
|
-
</AccordionMenuGroup>
|
|
70
|
-
{communities.length > 0 && (
|
|
50
|
+
|
|
51
|
+
{/* Main sidebar */}
|
|
52
|
+
<aside className="w-56 shrink-0 flex flex-col border-e border-sidebar-border bg-sidebar">
|
|
53
|
+
{!workspaces.length && logo && (
|
|
54
|
+
<div className="flex items-center gap-2 px-4 h-[70px] border-b border-sidebar-border shrink-0">
|
|
55
|
+
<a href={logoHref}>{logo}</a>
|
|
56
|
+
{appName && <span className="text-sm font-semibold">{appName}</span>}
|
|
57
|
+
</div>
|
|
58
|
+
)}
|
|
59
|
+
<ScrollArea className="flex-1 py-2 px-2">
|
|
60
|
+
<AccordionMenu type="single" collapsible matchPath={(href) => !!href && currentUrl.startsWith(href)} selectedValue={currentUrl}>
|
|
71
61
|
<AccordionMenuGroup>
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
62
|
+
{allNavItems.map((item, i) => (
|
|
63
|
+
item.items ? (
|
|
64
|
+
<AccordionMenuSub key={i} value={item.title}>
|
|
65
|
+
<AccordionMenuSubTrigger>{item.title}</AccordionMenuSubTrigger>
|
|
66
|
+
<AccordionMenuSubContent type="single" collapsible parentValue={item.title}>
|
|
67
|
+
{item.items.map((c, ci) => <AccordionMenuItem key={ci} value={c.href ?? c.title} asChild><a href={c.href}>{c.title}</a></AccordionMenuItem>)}
|
|
68
|
+
</AccordionMenuSubContent>
|
|
69
|
+
</AccordionMenuSub>
|
|
70
|
+
) : (
|
|
71
|
+
<AccordionMenuItem key={i} value={item.href ?? item.title} asChild><a href={item.href}>{item.title}</a></AccordionMenuItem>
|
|
72
|
+
)
|
|
75
73
|
))}
|
|
76
74
|
</AccordionMenuGroup>
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
75
|
+
{communities.length > 0 && (
|
|
76
|
+
<AccordionMenuGroup>
|
|
77
|
+
<div className="px-2 py-1.5 text-xs font-medium text-muted-foreground">Communities</div>
|
|
78
|
+
{communities.map((c, i) => (
|
|
79
|
+
<AccordionMenuItem key={i} value={c.href} asChild><a href={c.href}>{c.name}</a></AccordionMenuItem>
|
|
80
|
+
))}
|
|
81
|
+
</AccordionMenuGroup>
|
|
82
|
+
)}
|
|
83
|
+
</AccordionMenu>
|
|
84
|
+
</ScrollArea>
|
|
85
|
+
{sidebarFooter && <div className="p-3 border-t border-sidebar-border">{sidebarFooter}</div>}
|
|
86
|
+
</aside>
|
|
87
|
+
</div>
|
|
88
|
+
|
|
89
|
+
{/* Mobile sidebar overlay — only rendered when open */}
|
|
90
|
+
{mobileOpen && (
|
|
91
|
+
<>
|
|
92
|
+
<div className="fixed inset-0 z-30 bg-black/50 lg:hidden" onClick={() => setMobileOpen(false)} />
|
|
93
|
+
<aside className="fixed inset-y-0 start-0 z-40 w-[280px] flex flex-col border-e border-sidebar-border bg-sidebar lg:hidden">
|
|
94
|
+
<div className="flex items-center gap-2 px-4 h-[70px] border-b border-sidebar-border shrink-0">
|
|
95
|
+
{logo && <a href={logoHref}>{logo}</a>}
|
|
96
|
+
{appName && <span className="text-sm font-semibold">{appName}</span>}
|
|
97
|
+
<Button variant="ghost" size="sm" className="size-8 p-0 ml-auto" onClick={() => setMobileOpen(false)}>
|
|
98
|
+
<X className="size-4" />
|
|
99
|
+
</Button>
|
|
100
|
+
</div>
|
|
101
|
+
<ScrollArea className="flex-1 py-2 px-2">
|
|
102
|
+
<AccordionMenu type="single" collapsible matchPath={(href) => !!href && currentUrl.startsWith(href)} selectedValue={currentUrl}>
|
|
103
|
+
<AccordionMenuGroup>
|
|
104
|
+
{allNavItems.map((item, i) => (
|
|
105
|
+
item.items ? (
|
|
106
|
+
<AccordionMenuSub key={i} value={item.title}>
|
|
107
|
+
<AccordionMenuSubTrigger>{item.title}</AccordionMenuSubTrigger>
|
|
108
|
+
<AccordionMenuSubContent type="single" collapsible parentValue={item.title}>
|
|
109
|
+
{item.items.map((c, ci) => <AccordionMenuItem key={ci} value={c.href ?? c.title} asChild><a href={c.href}>{c.title}</a></AccordionMenuItem>)}
|
|
110
|
+
</AccordionMenuSubContent>
|
|
111
|
+
</AccordionMenuSub>
|
|
112
|
+
) : (
|
|
113
|
+
<AccordionMenuItem key={i} value={item.href ?? item.title} asChild><a href={item.href}>{item.title}</a></AccordionMenuItem>
|
|
114
|
+
)
|
|
115
|
+
))}
|
|
116
|
+
</AccordionMenuGroup>
|
|
117
|
+
{communities.length > 0 && (
|
|
118
|
+
<AccordionMenuGroup>
|
|
119
|
+
<div className="px-2 py-1.5 text-xs font-medium text-muted-foreground">Communities</div>
|
|
120
|
+
{communities.map((c, i) => (
|
|
121
|
+
<AccordionMenuItem key={i} value={c.href} asChild><a href={c.href}>{c.name}</a></AccordionMenuItem>
|
|
122
|
+
))}
|
|
123
|
+
</AccordionMenuGroup>
|
|
124
|
+
)}
|
|
125
|
+
</AccordionMenu>
|
|
126
|
+
</ScrollArea>
|
|
127
|
+
{sidebarFooter && <div className="p-3 border-t border-sidebar-border">{sidebarFooter}</div>}
|
|
128
|
+
</aside>
|
|
129
|
+
</>
|
|
130
|
+
)}
|
|
82
131
|
|
|
83
132
|
<div className="flex flex-col flex-1 min-w-0">
|
|
84
133
|
<header className="flex items-center h-[70px] border-b border-border bg-background px-4 shrink-0">
|
|
134
|
+
<Button
|
|
135
|
+
variant="ghost" size="sm"
|
|
136
|
+
className="size-9 p-0 rounded-full lg:hidden mr-2"
|
|
137
|
+
onClick={() => setMobileOpen((o) => !o)}
|
|
138
|
+
aria-label="Toggle menu"
|
|
139
|
+
>
|
|
140
|
+
{mobileOpen ? <X className="size-4" /> : <Menu className="size-4" />}
|
|
141
|
+
</Button>
|
|
85
142
|
<div className="flex-1" />
|
|
86
143
|
<HeaderTopbar user={user} unreadCount={unreadCount} settingsUrl={settingsUrl} logoutUrl={logoutUrl} onLogout={onLogout} />
|
|
87
144
|
</header>
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import type
|
|
3
|
+
import { useState, type ReactNode } from 'react';
|
|
4
4
|
import type { NavItem } from '../../../types/navigation';
|
|
5
5
|
import { cn } from '../../../lib/utils';
|
|
6
|
-
import { ChevronDown } from 'lucide-react';
|
|
6
|
+
import { ChevronDown, Menu, X } from 'lucide-react';
|
|
7
7
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '../../../components/ui/dropdown-menu';
|
|
8
8
|
|
|
9
9
|
interface NavbarProps {
|
|
@@ -23,10 +23,14 @@ function hasActiveChild(items: NavItem[], currentUrl: string): boolean {
|
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
export function Navbar({ navItems = [], currentUrl = '', rightSlot, className }: NavbarProps) {
|
|
26
|
+
const [mobileOpen, setMobileOpen] = useState(false);
|
|
27
|
+
|
|
26
28
|
return (
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
29
|
+
// relative so the mobile dropdown can be absolute-positioned
|
|
30
|
+
<div className={cn('border-b border-border relative', className)}>
|
|
31
|
+
<div className="container mx-auto px-4 flex justify-between items-center gap-2">
|
|
32
|
+
{/* Desktop nav */}
|
|
33
|
+
<nav className="hidden lg:flex items-stretch overflow-x-auto">
|
|
30
34
|
{navItems.map((item, index) => {
|
|
31
35
|
const active = isActive(item, currentUrl);
|
|
32
36
|
const childActive = item.items ? hasActiveChild(item.items, currentUrl) : false;
|
|
@@ -50,10 +54,7 @@ export function Navbar({ navItems = [], currentUrl = '', rightSlot, className }:
|
|
|
50
54
|
<DropdownMenuContent sideOffset={0} className="min-w-[175px]">
|
|
51
55
|
{item.items.map((child, ci) => (
|
|
52
56
|
<DropdownMenuItem key={ci} asChild>
|
|
53
|
-
<a
|
|
54
|
-
href={child.href}
|
|
55
|
-
className={cn(isActive(child, currentUrl) && 'bg-accent')}
|
|
56
|
-
>
|
|
57
|
+
<a href={child.href} className={cn(isActive(child, currentUrl) && 'bg-accent')}>
|
|
57
58
|
{child.title}
|
|
58
59
|
</a>
|
|
59
60
|
</DropdownMenuItem>
|
|
@@ -78,8 +79,58 @@ export function Navbar({ navItems = [], currentUrl = '', rightSlot, className }:
|
|
|
78
79
|
);
|
|
79
80
|
})}
|
|
80
81
|
</nav>
|
|
81
|
-
|
|
82
|
+
|
|
83
|
+
{/* Mobile: hamburger on the left, right slot hidden */}
|
|
84
|
+
<button
|
|
85
|
+
className="lg:hidden flex items-center justify-center size-9 rounded-md hover:bg-accent transition-colors"
|
|
86
|
+
onClick={() => setMobileOpen((o) => !o)}
|
|
87
|
+
aria-label="Toggle navigation"
|
|
88
|
+
>
|
|
89
|
+
{mobileOpen ? <X className="size-4" /> : <Menu className="size-4" />}
|
|
90
|
+
</button>
|
|
91
|
+
|
|
92
|
+
{rightSlot && <div className="hidden lg:flex items-center gap-4">{rightSlot}</div>}
|
|
82
93
|
</div>
|
|
94
|
+
|
|
95
|
+
{/* Mobile dropdown — absolute so it overlays content without shifting layout */}
|
|
96
|
+
{mobileOpen && (
|
|
97
|
+
<div className="lg:hidden absolute top-full left-0 right-0 z-50 border-b border-border bg-background shadow-md pb-2">
|
|
98
|
+
{navItems.map((item, i) =>
|
|
99
|
+
item.items && item.items.length > 0 ? (
|
|
100
|
+
<div key={i}>
|
|
101
|
+
<div className="px-4 pt-3 pb-1 text-xs font-semibold text-muted-foreground uppercase tracking-wide">
|
|
102
|
+
{item.title}
|
|
103
|
+
</div>
|
|
104
|
+
{item.items.map((child, ci) => (
|
|
105
|
+
<a
|
|
106
|
+
key={ci}
|
|
107
|
+
href={child.href}
|
|
108
|
+
className={cn(
|
|
109
|
+
'block px-6 py-2 text-sm hover:bg-accent transition-colors',
|
|
110
|
+
isActive(child, currentUrl) && 'text-primary font-medium',
|
|
111
|
+
)}
|
|
112
|
+
onClick={() => setMobileOpen(false)}
|
|
113
|
+
>
|
|
114
|
+
{child.title}
|
|
115
|
+
</a>
|
|
116
|
+
))}
|
|
117
|
+
</div>
|
|
118
|
+
) : (
|
|
119
|
+
<a
|
|
120
|
+
key={i}
|
|
121
|
+
href={item.href}
|
|
122
|
+
className={cn(
|
|
123
|
+
'block px-4 py-2.5 text-sm hover:bg-accent transition-colors',
|
|
124
|
+
isActive(item, currentUrl) && 'text-primary font-medium',
|
|
125
|
+
)}
|
|
126
|
+
onClick={() => setMobileOpen(false)}
|
|
127
|
+
>
|
|
128
|
+
{item.title}
|
|
129
|
+
</a>
|
|
130
|
+
),
|
|
131
|
+
)}
|
|
132
|
+
</div>
|
|
133
|
+
)}
|
|
83
134
|
</div>
|
|
84
135
|
);
|
|
85
136
|
}
|
|
@@ -13,7 +13,7 @@ interface ToolbarProps {
|
|
|
13
13
|
|
|
14
14
|
export function Toolbar({ title, breadcrumbs = [], actions, className, currentUrl }: ToolbarProps) {
|
|
15
15
|
return (
|
|
16
|
-
<div className={cn('mb-5 lg:mb-10', className)}>
|
|
16
|
+
<div className={cn('pt-5 mb-5 lg:pt-7 lg:mb-10', className)}>
|
|
17
17
|
<div className="container mx-auto px-4 flex items-center justify-between flex-wrap gap-5">
|
|
18
18
|
<div className="flex items-center flex-wrap gap-1 lg:gap-5">
|
|
19
19
|
{title && <h1 className="font-medium text-lg text-mono">{title}</h1>}
|
|
@@ -5,13 +5,19 @@ interface Props extends AuthLayoutProps {
|
|
|
5
5
|
logo?: ReactNode;
|
|
6
6
|
appName?: string;
|
|
7
7
|
homeUrl?: string;
|
|
8
|
-
|
|
8
|
+
backgroundImage?: string;
|
|
9
|
+
backgroundImageDark?: string;
|
|
9
10
|
}
|
|
10
11
|
|
|
11
|
-
export default function AuthCenteredLayout({ children, title, description, logo, appName, homeUrl = '/',
|
|
12
|
+
export default function AuthCenteredLayout({ children, title, description, logo, appName, homeUrl = '/', backgroundImage, backgroundImageDark }: Props) {
|
|
12
13
|
return (
|
|
13
|
-
<div className="relative flex min-h-svh flex-col items-center justify-center bg-background bg-cover bg-center bg-no-repeat p-6"
|
|
14
|
-
|
|
14
|
+
<div className="auth-centered-bg relative flex min-h-svh flex-col items-center justify-center bg-background bg-cover bg-center bg-no-repeat p-6">
|
|
15
|
+
{(backgroundImage || backgroundImageDark) && (
|
|
16
|
+
<style>{`
|
|
17
|
+
.auth-centered-bg { ${backgroundImage ? `background-image: url('${backgroundImage}');` : ''} }
|
|
18
|
+
.dark .auth-centered-bg { ${backgroundImageDark ? `background-image: url('${backgroundImageDark}');` : ''} }
|
|
19
|
+
`}</style>
|
|
20
|
+
)}
|
|
15
21
|
<div aria-hidden className="absolute inset-0 bg-gradient-to-br from-background/95 via-background/85 to-primary-subtle/60" />
|
|
16
22
|
|
|
17
23
|
<div className="relative z-10 w-full max-w-md">
|
package/src/lib/map-markers.ts
CHANGED
|
@@ -3,6 +3,11 @@ import arrowGreen from '../assets/map/arrows/map-arrow-green.png';
|
|
|
3
3
|
import arrowPurple from '../assets/map/arrows/map-arrow-purple.png';
|
|
4
4
|
import arrowRed from '../assets/map/arrows/map-arrow-red.png';
|
|
5
5
|
|
|
6
|
+
import pinBlue from '../assets/map/pins/map-pin-blue.png';
|
|
7
|
+
import pinGreen from '../assets/map/pins/map-pin-green.png';
|
|
8
|
+
import pinPurple from '../assets/map/pins/map-pin-purple.png';
|
|
9
|
+
import pinRed from '../assets/map/pins/map-pin-red.png';
|
|
10
|
+
|
|
6
11
|
import flagBlue from '../assets/map/flags/flag-blue.png';
|
|
7
12
|
import flagGreen from '../assets/map/flags/flag-green.png';
|
|
8
13
|
import flagRed from '../assets/map/flags/flag-red.png';
|
|
@@ -80,11 +85,19 @@ export function deviceArrowUrl(signal: number | null | undefined): string {
|
|
|
80
85
|
return ARROW_URLS[markerColor(signal)];
|
|
81
86
|
}
|
|
82
87
|
|
|
83
|
-
// ─── Pin
|
|
88
|
+
// ─── Pin markers (device has no heading) ─────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
/** Pin image URLs keyed by colour. */
|
|
91
|
+
export const PIN_URLS: Record<MarkerColor, string> = {
|
|
92
|
+
red: pinRed,
|
|
93
|
+
purple: pinPurple,
|
|
94
|
+
blue: pinBlue,
|
|
95
|
+
green: pinGreen,
|
|
96
|
+
};
|
|
84
97
|
|
|
85
98
|
/**
|
|
86
|
-
* Hex fill colours for the static pin marker (
|
|
87
|
-
*
|
|
99
|
+
* Hex fill colours for the static pin marker (CSS fallback when images
|
|
100
|
+
* are unavailable or for lightweight circle-only rendering).
|
|
88
101
|
*/
|
|
89
102
|
export const PIN_COLORS: Record<MarkerColor, string> = {
|
|
90
103
|
red: '#ef4444',
|
|
@@ -93,6 +106,11 @@ export const PIN_COLORS: Record<MarkerColor, string> = {
|
|
|
93
106
|
green: '#22c55e',
|
|
94
107
|
};
|
|
95
108
|
|
|
109
|
+
/** Returns the pin image URL for a device, given its network signal. */
|
|
110
|
+
export function devicePinUrl(signal: number | null | undefined): string {
|
|
111
|
+
return PIN_URLS[markerColor(signal)];
|
|
112
|
+
}
|
|
113
|
+
|
|
96
114
|
/** Returns the hex pin colour for a device, given its network signal. */
|
|
97
115
|
export function devicePinColor(signal: number | null | undefined): string {
|
|
98
116
|
return PIN_COLORS[markerColor(signal)];
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { AuthLayoutVariant } from "../../index";
|
|
3
|
+
import { ConfirmPasswordForm } from "../../elements/ConfirmPasswordForm";
|
|
4
|
+
import { AuthLayoutResolved, AUTH_LAYOUT_ARG_TYPE } from "../../layouts/LayoutSwitcher";
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
export default function ConfirmPasswordPage({
|
|
8
|
+
authLayout,
|
|
9
|
+
password ,
|
|
10
|
+
setPassword,
|
|
11
|
+
errors = {},
|
|
12
|
+
processing = false,
|
|
13
|
+
title ="Confirm password",
|
|
14
|
+
description = "This is a secure area — please re-enter your password"
|
|
15
|
+
}: {
|
|
16
|
+
authLayout: AuthLayoutVariant;
|
|
17
|
+
password: string;
|
|
18
|
+
setPassword: (password: string) => void;
|
|
19
|
+
errors?: { password?: string };
|
|
20
|
+
processing?: boolean;
|
|
21
|
+
title?: string;
|
|
22
|
+
description?: string;
|
|
23
|
+
}) {
|
|
24
|
+
return (
|
|
25
|
+
<AuthLayoutResolved variant={authLayout} title={title} description={description}>
|
|
26
|
+
<ConfirmPasswordForm
|
|
27
|
+
password={password}
|
|
28
|
+
errors={errors}
|
|
29
|
+
processing={processing}
|
|
30
|
+
onChange={setPassword}
|
|
31
|
+
onSubmit={(e) => e.preventDefault()}
|
|
32
|
+
/>
|
|
33
|
+
</AuthLayoutResolved>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { FormEvent } from 'react';
|
|
2
|
+
import type { AuthLayoutVariant } from '../../index';
|
|
3
|
+
import type { ForgotPasswordFormData, ForgotPasswordFormErrors } from '../../elements/ForgotPasswordForm';
|
|
4
|
+
import { ForgotPasswordForm } from '../../elements/ForgotPasswordForm';
|
|
5
|
+
import { AuthLayoutResolved } from '../../layouts/LayoutSwitcher';
|
|
6
|
+
|
|
7
|
+
export default function ForgotPasswordPage({
|
|
8
|
+
authLayout,
|
|
9
|
+
data,
|
|
10
|
+
setData,
|
|
11
|
+
errors = {},
|
|
12
|
+
processing = false,
|
|
13
|
+
status,
|
|
14
|
+
loginUrl = '/login',
|
|
15
|
+
title = 'Forgot password?',
|
|
16
|
+
description = "We'll email you a reset link",
|
|
17
|
+
}: {
|
|
18
|
+
authLayout: AuthLayoutVariant;
|
|
19
|
+
data: ForgotPasswordFormData;
|
|
20
|
+
setData: (d: ForgotPasswordFormData) => void;
|
|
21
|
+
errors?: ForgotPasswordFormErrors;
|
|
22
|
+
processing?: boolean;
|
|
23
|
+
status?: string;
|
|
24
|
+
loginUrl?: string;
|
|
25
|
+
title?: string;
|
|
26
|
+
description?: string;
|
|
27
|
+
}) {
|
|
28
|
+
return (
|
|
29
|
+
<AuthLayoutResolved variant={authLayout} title={title} description={description}>
|
|
30
|
+
<ForgotPasswordForm
|
|
31
|
+
data={data}
|
|
32
|
+
errors={errors}
|
|
33
|
+
processing={processing}
|
|
34
|
+
status={status}
|
|
35
|
+
loginUrl={loginUrl}
|
|
36
|
+
onChange={(field, value) => setData({ ...data, [field]: value })}
|
|
37
|
+
onSubmit={(e: FormEvent) => e.preventDefault()}
|
|
38
|
+
/>
|
|
39
|
+
</AuthLayoutResolved>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { FormEvent } from 'react';
|
|
2
|
+
import type { AuthLayoutVariant } from '../../index';
|
|
3
|
+
import type { LoginFormData, LoginFormErrors } from '../../elements/LoginForm';
|
|
4
|
+
import { LoginForm } from '../../elements/LoginForm';
|
|
5
|
+
import { AuthLayoutResolved } from '../../layouts/LayoutSwitcher';
|
|
6
|
+
|
|
7
|
+
export default function LoginPage({
|
|
8
|
+
authLayout,
|
|
9
|
+
data,
|
|
10
|
+
setData,
|
|
11
|
+
errors = {},
|
|
12
|
+
processing = false,
|
|
13
|
+
status,
|
|
14
|
+
canResetPassword = true,
|
|
15
|
+
canRegister = true,
|
|
16
|
+
forgotPasswordUrl = '/forgot-password',
|
|
17
|
+
registerUrl = '/register',
|
|
18
|
+
title = 'Sign in',
|
|
19
|
+
description = 'Enter your credentials to continue',
|
|
20
|
+
}: {
|
|
21
|
+
authLayout: AuthLayoutVariant;
|
|
22
|
+
data: LoginFormData;
|
|
23
|
+
setData: (d: LoginFormData) => void;
|
|
24
|
+
errors?: LoginFormErrors;
|
|
25
|
+
processing?: boolean;
|
|
26
|
+
status?: string;
|
|
27
|
+
canResetPassword?: boolean;
|
|
28
|
+
canRegister?: boolean;
|
|
29
|
+
forgotPasswordUrl?: string;
|
|
30
|
+
registerUrl?: string;
|
|
31
|
+
title?: string;
|
|
32
|
+
description?: string;
|
|
33
|
+
}) {
|
|
34
|
+
return (
|
|
35
|
+
<AuthLayoutResolved variant={authLayout} title={title} description={description}>
|
|
36
|
+
<LoginForm
|
|
37
|
+
data={data}
|
|
38
|
+
errors={errors}
|
|
39
|
+
processing={processing}
|
|
40
|
+
status={status}
|
|
41
|
+
canResetPassword={canResetPassword}
|
|
42
|
+
canRegister={canRegister}
|
|
43
|
+
forgotPasswordUrl={forgotPasswordUrl}
|
|
44
|
+
registerUrl={registerUrl}
|
|
45
|
+
onChange={(field, value) => setData({ ...data, [field]: value })}
|
|
46
|
+
onSubmit={(e: FormEvent) => e.preventDefault()}
|
|
47
|
+
/>
|
|
48
|
+
</AuthLayoutResolved>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { FormEvent } from 'react';
|
|
2
|
+
import type { AuthLayoutVariant } from '../../index';
|
|
3
|
+
import type { RegisterFormData, RegisterFormErrors, PhoneCountry } from '../../elements/RegisterForm';
|
|
4
|
+
import { RegisterForm } from '../../elements/RegisterForm';
|
|
5
|
+
import { AuthLayoutResolved } from '../../layouts/LayoutSwitcher';
|
|
6
|
+
|
|
7
|
+
export default function RegisterPage({
|
|
8
|
+
authLayout,
|
|
9
|
+
data,
|
|
10
|
+
setData,
|
|
11
|
+
errors = {},
|
|
12
|
+
processing = false,
|
|
13
|
+
loginUrl = '/login',
|
|
14
|
+
phoneCountries,
|
|
15
|
+
title = 'Create an account',
|
|
16
|
+
description = 'Fill in your details to get started',
|
|
17
|
+
}: {
|
|
18
|
+
authLayout: AuthLayoutVariant;
|
|
19
|
+
data: RegisterFormData;
|
|
20
|
+
setData: (d: RegisterFormData) => void;
|
|
21
|
+
errors?: RegisterFormErrors;
|
|
22
|
+
processing?: boolean;
|
|
23
|
+
loginUrl?: string;
|
|
24
|
+
phoneCountries?: PhoneCountry[];
|
|
25
|
+
title?: string;
|
|
26
|
+
description?: string;
|
|
27
|
+
}) {
|
|
28
|
+
return (
|
|
29
|
+
<AuthLayoutResolved variant={authLayout} title={title} description={description}>
|
|
30
|
+
<RegisterForm
|
|
31
|
+
data={data}
|
|
32
|
+
errors={errors}
|
|
33
|
+
processing={processing}
|
|
34
|
+
loginUrl={loginUrl}
|
|
35
|
+
phoneCountries={phoneCountries}
|
|
36
|
+
onChange={(field, value) => setData({ ...data, [field]: value })}
|
|
37
|
+
onSubmit={(e: FormEvent) => e.preventDefault()}
|
|
38
|
+
/>
|
|
39
|
+
</AuthLayoutResolved>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { FormEvent } from 'react';
|
|
2
|
+
import type { AuthLayoutVariant } from '../../index';
|
|
3
|
+
import type { ResetPasswordFormData, ResetPasswordFormErrors } from '../../elements/ResetPasswordForm';
|
|
4
|
+
import { ResetPasswordForm } from '../../elements/ResetPasswordForm';
|
|
5
|
+
import { AuthLayoutResolved } from '../../layouts/LayoutSwitcher';
|
|
6
|
+
|
|
7
|
+
export default function ResetPasswordPage({
|
|
8
|
+
authLayout,
|
|
9
|
+
data,
|
|
10
|
+
setData,
|
|
11
|
+
errors = {},
|
|
12
|
+
processing = false,
|
|
13
|
+
title = 'Reset password',
|
|
14
|
+
description = 'Enter your new password below',
|
|
15
|
+
}: {
|
|
16
|
+
authLayout: AuthLayoutVariant;
|
|
17
|
+
data: ResetPasswordFormData;
|
|
18
|
+
setData: (d: ResetPasswordFormData) => void;
|
|
19
|
+
errors?: ResetPasswordFormErrors;
|
|
20
|
+
processing?: boolean;
|
|
21
|
+
title?: string;
|
|
22
|
+
description?: string;
|
|
23
|
+
}) {
|
|
24
|
+
return (
|
|
25
|
+
<AuthLayoutResolved variant={authLayout} title={title} description={description}>
|
|
26
|
+
<ResetPasswordForm
|
|
27
|
+
data={data}
|
|
28
|
+
errors={errors}
|
|
29
|
+
processing={processing}
|
|
30
|
+
onChange={(field, value) => setData({ ...data, [field]: value })}
|
|
31
|
+
onSubmit={(e: FormEvent) => e.preventDefault()}
|
|
32
|
+
/>
|
|
33
|
+
</AuthLayoutResolved>
|
|
34
|
+
);
|
|
35
|
+
}
|