uibee 3.1.6 → 3.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/dist/src/components/index.d.ts +165 -2
- package/dist/src/components/index.js +454 -9
- package/dist/style.css +252 -0
- package/package.json +1 -1
- package/src/components/badge/badge.tsx +44 -0
- package/src/components/buttons/button.tsx +2 -1
- package/src/components/code/code.tsx +55 -0
- package/src/components/container/accordion.tsx +5 -1
- package/src/components/container/expandableCard.tsx +80 -0
- package/src/components/container/glassCard.tsx +14 -0
- package/src/components/container/tabs.tsx +3 -3
- package/src/components/empty/emptyState.tsx +26 -0
- package/src/components/index.ts +17 -0
- package/src/components/inputs/fileInput.tsx +100 -0
- package/src/components/inputs/input.tsx +2 -2
- package/src/components/inputs/multiSelect.tsx +8 -2
- package/src/components/inputs/shared/colorPickerPopup.tsx +1 -1
- package/src/components/inputs/shared/dateTimePickerPopup.tsx +1 -1
- package/src/components/inputs/switch.tsx +5 -1
- package/src/components/modal/modal.tsx +74 -0
- package/src/components/sidebar/sidebar.tsx +177 -0
- package/src/components/spinner/spinner.tsx +20 -0
- package/src/components/table/body.tsx +37 -27
- package/src/components/table/header.tsx +32 -22
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import type { ElementType, ReactNode } from 'react'
|
|
4
|
+
import { ChevronDown } from 'lucide-react'
|
|
5
|
+
import Card from './card'
|
|
6
|
+
import IconBubble, { type IconBubbleTone } from './iconBubble'
|
|
7
|
+
import PulseDot from './pulseDot'
|
|
8
|
+
import Button from '@components/buttons/button'
|
|
9
|
+
|
|
10
|
+
type PulseVariant = 'online' | 'offline' | 'warning' | 'unknown'
|
|
11
|
+
|
|
12
|
+
type ExpandableCardProps = {
|
|
13
|
+
icon: ElementType
|
|
14
|
+
iconTone?: IconBubbleTone
|
|
15
|
+
title: string
|
|
16
|
+
subtitle?: ReactNode
|
|
17
|
+
pulse?: { variant: PulseVariant; label: string }
|
|
18
|
+
trailing?: ReactNode
|
|
19
|
+
isExpanded: boolean
|
|
20
|
+
onToggle: () => void
|
|
21
|
+
children?: ReactNode
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export default function ExpandableCard({
|
|
25
|
+
icon,
|
|
26
|
+
iconTone = 'orange',
|
|
27
|
+
title,
|
|
28
|
+
subtitle,
|
|
29
|
+
pulse,
|
|
30
|
+
trailing,
|
|
31
|
+
isExpanded,
|
|
32
|
+
onToggle,
|
|
33
|
+
children,
|
|
34
|
+
}: ExpandableCardProps) {
|
|
35
|
+
return (
|
|
36
|
+
<Card>
|
|
37
|
+
<div
|
|
38
|
+
role='button'
|
|
39
|
+
tabIndex={0}
|
|
40
|
+
onClick={onToggle}
|
|
41
|
+
onKeyDown={(e) => {
|
|
42
|
+
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onToggle() }
|
|
43
|
+
}}
|
|
44
|
+
aria-expanded={isExpanded}
|
|
45
|
+
className='group flex cursor-pointer select-none items-center gap-4 px-5 py-4'
|
|
46
|
+
>
|
|
47
|
+
<IconBubble icon={icon} tone={iconTone} size='sm' />
|
|
48
|
+
<div className='min-w-0 flex-1'>
|
|
49
|
+
<div className='flex flex-wrap items-center gap-3'>
|
|
50
|
+
<span className='font-semibold text-login-50 transition group-hover:text-login'>{title}</span>
|
|
51
|
+
{pulse && (
|
|
52
|
+
<span className='flex items-center gap-2 text-xs text-login-300'>
|
|
53
|
+
<span className='flex h-3 w-3 shrink-0 items-center justify-center'>
|
|
54
|
+
<PulseDot variant={pulse.variant} size='sm' />
|
|
55
|
+
</span>
|
|
56
|
+
{pulse.label}
|
|
57
|
+
</span>
|
|
58
|
+
)}
|
|
59
|
+
</div>
|
|
60
|
+
{subtitle && <div className='mt-0.5 text-xs text-login-300'>{subtitle}</div>}
|
|
61
|
+
</div>
|
|
62
|
+
{trailing && (
|
|
63
|
+
<div className='flex shrink-0 items-center gap-3' onClick={(e) => e.stopPropagation()}>
|
|
64
|
+
{trailing}
|
|
65
|
+
</div>
|
|
66
|
+
)}
|
|
67
|
+
<Button
|
|
68
|
+
variant='secondary'
|
|
69
|
+
icon={<ChevronDown className={`h-4 w-4 transition-transform duration-200 ${isExpanded ? 'rotate-180' : ''}`} />}
|
|
70
|
+
onClick={(e) => { e.stopPropagation(); onToggle() }}
|
|
71
|
+
/>
|
|
72
|
+
</div>
|
|
73
|
+
{isExpanded && children && (
|
|
74
|
+
<div className='border-t border-white/5 px-5 pb-5 pt-4'>
|
|
75
|
+
{children}
|
|
76
|
+
</div>
|
|
77
|
+
)}
|
|
78
|
+
</Card>
|
|
79
|
+
)
|
|
80
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
|
|
3
|
+
type GlassCardProps = {
|
|
4
|
+
children: ReactNode
|
|
5
|
+
className?: string
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export default function GlassCard({ children, className = '' }: GlassCardProps) {
|
|
9
|
+
return (
|
|
10
|
+
<div className={`rounded-xl border border-login-500/20 bg-login-800/60 backdrop-blur-sm ${className}`}>
|
|
11
|
+
{children}
|
|
12
|
+
</div>
|
|
13
|
+
)
|
|
14
|
+
}
|
|
@@ -42,9 +42,9 @@ export function Tabs({ tabs, defaultTab, activeTab: controlledTab, onTabChange,
|
|
|
42
42
|
className={`
|
|
43
43
|
px-3 py-1.5 rounded text-sm font-medium transition-all duration-150 cursor-pointer select-none
|
|
44
44
|
${activeTab === tab.id
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
45
|
+
? 'bg-login text-white shadow-sm'
|
|
46
|
+
: 'text-login-200 hover:text-login-50 hover:bg-login-600'
|
|
47
|
+
}
|
|
48
48
|
`}
|
|
49
49
|
>
|
|
50
50
|
{tab.label}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { ElementType, ReactNode } from 'react'
|
|
2
|
+
|
|
3
|
+
type EmptyStateProps = {
|
|
4
|
+
icon?: ElementType
|
|
5
|
+
title: string
|
|
6
|
+
description?: string
|
|
7
|
+
action?: ReactNode
|
|
8
|
+
className?: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export default function EmptyState({ icon: Icon, title, description, action, className = '' }: EmptyStateProps) {
|
|
12
|
+
return (
|
|
13
|
+
<div className={`flex flex-col items-center justify-center gap-3 py-12 text-center ${className}`}>
|
|
14
|
+
{Icon && (
|
|
15
|
+
<div className='mb-1 rounded-xl bg-login-600/30 p-3 text-login-400'>
|
|
16
|
+
<Icon className='h-7 w-7' />
|
|
17
|
+
</div>
|
|
18
|
+
)}
|
|
19
|
+
<p className='text-sm font-semibold text-login-100'>{title}</p>
|
|
20
|
+
{description && (
|
|
21
|
+
<p className='max-w-xs text-xs leading-relaxed text-login-400'>{description}</p>
|
|
22
|
+
)}
|
|
23
|
+
{action && <div className='mt-2'>{action}</div>}
|
|
24
|
+
</div>
|
|
25
|
+
)
|
|
26
|
+
}
|
package/src/components/index.ts
CHANGED
|
@@ -55,3 +55,20 @@ export { default as ConfirmPopup } from './confirm/confirmPopup'
|
|
|
55
55
|
export { default as SeverityPill } from './vulnerability/severityPill'
|
|
56
56
|
|
|
57
57
|
export { default as Toggle } from './inputs/toggle'
|
|
58
|
+
export { default as FileInput } from './inputs/fileInput'
|
|
59
|
+
|
|
60
|
+
export { default as GlassCard } from './container/glassCard'
|
|
61
|
+
export { default as ExpandableCard } from './container/expandableCard'
|
|
62
|
+
|
|
63
|
+
export { default as Modal } from './modal/modal'
|
|
64
|
+
|
|
65
|
+
export { default as Sidebar } from './sidebar/sidebar'
|
|
66
|
+
export type { SidebarItem, SidebarSubItem } from './sidebar/sidebar'
|
|
67
|
+
|
|
68
|
+
export { default as Badge } from './badge/badge'
|
|
69
|
+
|
|
70
|
+
export { default as EmptyState } from './empty/emptyState'
|
|
71
|
+
|
|
72
|
+
export { default as Spinner } from './spinner/spinner'
|
|
73
|
+
|
|
74
|
+
export { Code, CodeBlock } from './code/code'
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useRef, useState, type DragEvent, type ChangeEvent } from 'react'
|
|
4
|
+
import { UploadCloud, X, FileIcon } from 'lucide-react'
|
|
5
|
+
|
|
6
|
+
type FileInputProps = {
|
|
7
|
+
name: string
|
|
8
|
+
label?: string
|
|
9
|
+
accept?: string
|
|
10
|
+
multiple?: boolean
|
|
11
|
+
onChange: (files: File[]) => void
|
|
12
|
+
className?: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export default function FileInput({ name, label, accept, multiple = false, onChange, className = '' }: FileInputProps) {
|
|
16
|
+
const [files, setFiles] = useState<File[]>([])
|
|
17
|
+
const [dragging, setDragging] = useState(false)
|
|
18
|
+
const inputRef = useRef<HTMLInputElement>(null)
|
|
19
|
+
|
|
20
|
+
function handleFiles(incoming: FileList | null) {
|
|
21
|
+
if (!incoming) return
|
|
22
|
+
const arr = multiple ? Array.from(incoming) : [incoming[0]]
|
|
23
|
+
setFiles(arr)
|
|
24
|
+
onChange(arr)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function removeFile(index: number) {
|
|
28
|
+
const next = files.filter((_, i) => i !== index)
|
|
29
|
+
setFiles(next)
|
|
30
|
+
onChange(next)
|
|
31
|
+
if (inputRef.current) inputRef.current.value = ''
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function onDrop(e: DragEvent) {
|
|
35
|
+
e.preventDefault()
|
|
36
|
+
setDragging(false)
|
|
37
|
+
handleFiles(e.dataTransfer.files)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<div className={`flex flex-col gap-2 ${className}`}>
|
|
42
|
+
{label && <label className='text-sm font-medium text-login-200'>{label}</label>}
|
|
43
|
+
<div
|
|
44
|
+
role='button'
|
|
45
|
+
tabIndex={0}
|
|
46
|
+
onClick={() => inputRef.current?.click()}
|
|
47
|
+
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') inputRef.current?.click() }}
|
|
48
|
+
onDragOver={(e) => { e.preventDefault(); setDragging(true) }}
|
|
49
|
+
onDragLeave={() => setDragging(false)}
|
|
50
|
+
onDrop={onDrop}
|
|
51
|
+
className={`
|
|
52
|
+
flex cursor-pointer flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed p-6
|
|
53
|
+
transition-all duration-150
|
|
54
|
+
${dragging
|
|
55
|
+
? 'scale-[1.01] border-login bg-login/5'
|
|
56
|
+
: 'border-login-500/50 bg-login-800/30 hover:border-login/50 hover:bg-login/5'
|
|
57
|
+
}
|
|
58
|
+
`}
|
|
59
|
+
>
|
|
60
|
+
<input
|
|
61
|
+
ref={inputRef}
|
|
62
|
+
type='file'
|
|
63
|
+
name={name}
|
|
64
|
+
accept={accept}
|
|
65
|
+
multiple={multiple}
|
|
66
|
+
className='hidden'
|
|
67
|
+
onChange={(e: ChangeEvent<HTMLInputElement>) => handleFiles(e.target.files)}
|
|
68
|
+
/>
|
|
69
|
+
<UploadCloud className='h-7 w-7 text-login-400' />
|
|
70
|
+
<div className='text-center'>
|
|
71
|
+
<p className='text-sm text-login-200'>
|
|
72
|
+
Drop files here or <span className='text-login'>browse</span>
|
|
73
|
+
</p>
|
|
74
|
+
{accept && <p className='mt-0.5 text-xs text-login-400'>{accept}</p>}
|
|
75
|
+
</div>
|
|
76
|
+
</div>
|
|
77
|
+
{files.length > 0 && (
|
|
78
|
+
<ul className='flex flex-col gap-1.5'>
|
|
79
|
+
{files.map((file, i) => (
|
|
80
|
+
<li key={i} className='
|
|
81
|
+
flex items-center gap-2 rounded-lg
|
|
82
|
+
border border-login-500/25 bg-login-800/50 px-3 py-2 text-sm
|
|
83
|
+
'>
|
|
84
|
+
<FileIcon className='h-4 w-4 shrink-0 text-login-400' />
|
|
85
|
+
<span className='flex-1 truncate text-login-200'>{file.name}</span>
|
|
86
|
+
<span className='shrink-0 text-xs text-login-400'>{(file.size / 1024).toFixed(1)} KB</span>
|
|
87
|
+
<button
|
|
88
|
+
type='button'
|
|
89
|
+
onClick={(e) => { e.stopPropagation(); removeFile(i) }}
|
|
90
|
+
className='cursor-pointer text-login-400 transition-colors hover:text-red-400'
|
|
91
|
+
>
|
|
92
|
+
<X className='h-3.5 w-3.5' />
|
|
93
|
+
</button>
|
|
94
|
+
</li>
|
|
95
|
+
))}
|
|
96
|
+
</ul>
|
|
97
|
+
)}
|
|
98
|
+
</div>
|
|
99
|
+
)
|
|
100
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type ChangeEvent, type JSX, useRef, useState, useId } from 'react'
|
|
1
|
+
import React, { type ChangeEvent, type JSX, useRef, useState, useId } from 'react'
|
|
2
2
|
import { Calendar, Clock } from 'lucide-react'
|
|
3
3
|
import { FieldWrapper } from './shared'
|
|
4
4
|
import DateTimePickerPopup from './shared/dateTimePickerPopup'
|
|
@@ -158,7 +158,7 @@ export default function Input(props: InputProps) {
|
|
|
158
158
|
readOnly={isClickableType}
|
|
159
159
|
onClick={() => isClickableType && !inputProps.disabled && setIsOpen(true)}
|
|
160
160
|
aria-describedby={error ? `${name}-error` : undefined}
|
|
161
|
-
style={{ anchorName } as
|
|
161
|
+
style={{ anchorName } as React.CSSProperties & { anchorName?: string }}
|
|
162
162
|
className={`
|
|
163
163
|
w-full rounded-md bg-login-500/50 border border-login-500
|
|
164
164
|
text-login-text placeholder-login-200 text-sm
|
|
@@ -93,7 +93,10 @@ export default function MultiSelect({
|
|
|
93
93
|
return (
|
|
94
94
|
<span
|
|
95
95
|
key={val}
|
|
96
|
-
className='
|
|
96
|
+
className='
|
|
97
|
+
flex items-center gap-1 rounded px-2 py-0.5 text-sm
|
|
98
|
+
border border-login-500 bg-login-600 text-login-100
|
|
99
|
+
'
|
|
97
100
|
>
|
|
98
101
|
{option.label}
|
|
99
102
|
{!disabled && (
|
|
@@ -116,7 +119,10 @@ export default function MultiSelect({
|
|
|
116
119
|
</div>
|
|
117
120
|
|
|
118
121
|
{open && options.length > 0 && (
|
|
119
|
-
<div className='
|
|
122
|
+
<div className='
|
|
123
|
+
absolute z-50 mt-1 w-full max-h-60 overflow-auto
|
|
124
|
+
rounded-md border border-login-500/50 bg-login-800 shadow-lg
|
|
125
|
+
'>
|
|
120
126
|
{options.map((option) => {
|
|
121
127
|
const selected = value.includes(option.value)
|
|
122
128
|
return (
|
|
@@ -192,7 +192,7 @@ export default function ColorPickerPopup({ value, onChange, onClose, anchorName
|
|
|
192
192
|
}
|
|
193
193
|
|
|
194
194
|
return (
|
|
195
|
-
<div
|
|
195
|
+
<div
|
|
196
196
|
className='fixed z-50 bg-login-700 border border-login-500/50 rounded-md shadow-xl p-3 w-64 select-none anchor-popup'
|
|
197
197
|
style={{
|
|
198
198
|
positionAnchor: anchorName,
|
|
@@ -39,7 +39,11 @@ export default function Switch(props: SwitchProps) {
|
|
|
39
39
|
${inputProps.disabled ? 'opacity-40 cursor-not-allowed' : ''}
|
|
40
40
|
${error ? 'ring-1 ring-red-500/60' : ''}
|
|
41
41
|
`}>
|
|
42
|
-
<span className='
|
|
42
|
+
<span className='
|
|
43
|
+
absolute inset-y-0 my-auto left-0.5 h-5 w-5
|
|
44
|
+
rounded-full bg-white shadow-sm transition-all
|
|
45
|
+
group-has-[input:checked]/sw:translate-x-full
|
|
46
|
+
' />
|
|
43
47
|
</div>
|
|
44
48
|
</label>
|
|
45
49
|
</FieldWrapper>
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEffect, type ReactNode } from 'react'
|
|
4
|
+
import { X } from 'lucide-react'
|
|
5
|
+
|
|
6
|
+
type ModalSize = 'sm' | 'md' | 'lg'
|
|
7
|
+
|
|
8
|
+
type ModalProps = {
|
|
9
|
+
isOpen: boolean
|
|
10
|
+
onClose: () => void
|
|
11
|
+
title?: string
|
|
12
|
+
children: ReactNode
|
|
13
|
+
footer?: ReactNode
|
|
14
|
+
size?: ModalSize
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const sizes: Record<ModalSize, string> = {
|
|
18
|
+
sm: 'max-w-sm',
|
|
19
|
+
md: 'max-w-md',
|
|
20
|
+
lg: 'max-w-lg',
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export default function Modal({ isOpen, onClose, title, children, footer, size = 'md' }: ModalProps) {
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
if (!isOpen) return
|
|
26
|
+
const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }
|
|
27
|
+
document.addEventListener('keydown', handler)
|
|
28
|
+
return () => document.removeEventListener('keydown', handler)
|
|
29
|
+
}, [isOpen, onClose])
|
|
30
|
+
|
|
31
|
+
if (!isOpen) return null
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<div
|
|
35
|
+
role='dialog'
|
|
36
|
+
aria-modal='true'
|
|
37
|
+
className='fixed inset-0 z-50 flex items-center justify-center'
|
|
38
|
+
onClick={onClose}
|
|
39
|
+
>
|
|
40
|
+
<div className='absolute inset-0 bg-black/50 backdrop-blur-sm' />
|
|
41
|
+
<div
|
|
42
|
+
className={`
|
|
43
|
+
relative z-10 mx-4 w-full ${sizes[size]}
|
|
44
|
+
flex flex-col rounded-xl border border-login-500/50
|
|
45
|
+
bg-login-800 shadow-2xl
|
|
46
|
+
`}
|
|
47
|
+
onClick={(e) => e.stopPropagation()}
|
|
48
|
+
>
|
|
49
|
+
{title && (
|
|
50
|
+
<div className='flex items-center justify-between px-6 pt-5 pb-4 border-b border-login-500/25'>
|
|
51
|
+
<h2 className='text-login-50 text-base font-semibold leading-snug'>{title}</h2>
|
|
52
|
+
<button
|
|
53
|
+
type='button'
|
|
54
|
+
onClick={onClose}
|
|
55
|
+
className='
|
|
56
|
+
cursor-pointer rounded-md p-1.5
|
|
57
|
+
text-login-400 hover:text-login-100 hover:bg-login-600
|
|
58
|
+
transition-colors
|
|
59
|
+
'
|
|
60
|
+
>
|
|
61
|
+
<X className='w-4 h-4' />
|
|
62
|
+
</button>
|
|
63
|
+
</div>
|
|
64
|
+
)}
|
|
65
|
+
<div className='p-6'>{children}</div>
|
|
66
|
+
{footer && (
|
|
67
|
+
<div className='px-6 pb-5 pt-4 border-t border-login-500/25'>
|
|
68
|
+
{footer}
|
|
69
|
+
</div>
|
|
70
|
+
)}
|
|
71
|
+
</div>
|
|
72
|
+
</div>
|
|
73
|
+
)
|
|
74
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useState, type ElementType, type ReactNode } from 'react'
|
|
4
|
+
import { usePathname } from 'next/navigation'
|
|
5
|
+
import Link from 'next/link'
|
|
6
|
+
import { ChevronLeft, ChevronRight } from 'lucide-react'
|
|
7
|
+
|
|
8
|
+
export type SidebarSubItem = {
|
|
9
|
+
name: string
|
|
10
|
+
path: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type SidebarItem = {
|
|
14
|
+
name: string
|
|
15
|
+
path: string
|
|
16
|
+
icon: ElementType
|
|
17
|
+
status?: ReactNode
|
|
18
|
+
items?: SidebarSubItem[]
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
type SidebarProps = {
|
|
22
|
+
items: SidebarItem[]
|
|
23
|
+
header?: ReactNode
|
|
24
|
+
bottomAction?: (expanded: boolean) => ReactNode
|
|
25
|
+
mobile?: boolean
|
|
26
|
+
initialExpanded?: boolean
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const ITEM_HEIGHT = 48
|
|
30
|
+
const GAP = 4
|
|
31
|
+
const SUB_STRIDE = 40
|
|
32
|
+
|
|
33
|
+
export default function Sidebar({ items, header, bottomAction, mobile = false, initialExpanded = true }: SidebarProps) {
|
|
34
|
+
const [expanded, setExpanded] = useState(initialExpanded)
|
|
35
|
+
const pathname = usePathname()
|
|
36
|
+
|
|
37
|
+
function isItemActive(item: SidebarItem) {
|
|
38
|
+
return pathname === item.path ||
|
|
39
|
+
!!(item.items?.some(sub => pathname === sub.path || pathname.startsWith(sub.path + '/')))
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const activeIndex = items.findIndex(isItemActive)
|
|
43
|
+
const activeOffset = items
|
|
44
|
+
.slice(0, Math.max(activeIndex, 0))
|
|
45
|
+
.reduce((acc, item) => acc + ITEM_HEIGHT + (item.items ? GAP : 0) + GAP, 0)
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<aside className={`
|
|
49
|
+
flex flex-col border-r border-login-100/10 bg-login-900
|
|
50
|
+
transition-all duration-300 ease-in-out
|
|
51
|
+
${mobile ? 'w-full' : `h-full ${expanded ? 'w-64' : 'w-20'}`}
|
|
52
|
+
`}>
|
|
53
|
+
{!mobile && (
|
|
54
|
+
<div className={`relative mb-2 p-4 transition-all duration-300 ${expanded ? 'h-16' : 'h-20'}`}>
|
|
55
|
+
{header && (
|
|
56
|
+
<div className={`
|
|
57
|
+
absolute top-4 flex items-center transition-all duration-300
|
|
58
|
+
${expanded ? 'left-4 gap-3' : 'left-1/2 -translate-x-1/2 gap-0'}
|
|
59
|
+
`}>
|
|
60
|
+
{header}
|
|
61
|
+
</div>
|
|
62
|
+
)}
|
|
63
|
+
<button
|
|
64
|
+
onClick={() => setExpanded(!expanded)}
|
|
65
|
+
className={`
|
|
66
|
+
absolute cursor-pointer rounded-lg p-1.5
|
|
67
|
+
text-login-200 transition-all duration-300 hover:bg-login-800
|
|
68
|
+
${expanded ? 'right-4 top-4' : 'left-1/2 top-12 -translate-x-1/2'}
|
|
69
|
+
`}
|
|
70
|
+
>
|
|
71
|
+
{expanded ? <ChevronLeft size={20} /> : <ChevronRight size={20} />}
|
|
72
|
+
</button>
|
|
73
|
+
</div>
|
|
74
|
+
)}
|
|
75
|
+
|
|
76
|
+
{mobile && <div className='h-4' />}
|
|
77
|
+
|
|
78
|
+
<div className='relative flex flex-1 flex-col gap-1 overflow-x-hidden overflow-y-auto px-3'>
|
|
79
|
+
{activeIndex >= 0 && (
|
|
80
|
+
<span
|
|
81
|
+
aria-hidden
|
|
82
|
+
className='absolute left-3 right-3 top-0 h-12 rounded-lg bg-login-800 transition-transform duration-300 ease-in-out'
|
|
83
|
+
style={{ transform: `translateY(${activeOffset}px)` }}
|
|
84
|
+
/>
|
|
85
|
+
)}
|
|
86
|
+
{items.map((item, index) => {
|
|
87
|
+
const isActive = isItemActive(item)
|
|
88
|
+
const Icon = item.icon
|
|
89
|
+
const activeSubIndex = item.items
|
|
90
|
+
? item.items.findIndex(sub => pathname === sub.path || pathname.startsWith(sub.path + '/'))
|
|
91
|
+
: -1
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<div key={index} className='flex flex-col gap-1'>
|
|
95
|
+
<Link
|
|
96
|
+
href={item.path}
|
|
97
|
+
title={!expanded ? item.name : undefined}
|
|
98
|
+
className={`
|
|
99
|
+
group relative z-10 flex items-center
|
|
100
|
+
overflow-hidden rounded-lg p-3 transition-all duration-200
|
|
101
|
+
${isActive ? 'text-login' : 'text-login-200 hover:bg-login-800/50 hover:text-login-100'}
|
|
102
|
+
`}
|
|
103
|
+
>
|
|
104
|
+
<div className={`
|
|
105
|
+
flex min-w-6 w-6 items-center justify-center transition-all duration-300
|
|
106
|
+
${expanded ? '' : 'translate-x-1'}
|
|
107
|
+
${isActive ? '[&>svg]:stroke-login' : 'group-hover:[&>svg]:stroke-login-100'}
|
|
108
|
+
`}>
|
|
109
|
+
<Icon className='h-5 w-5' />
|
|
110
|
+
</div>
|
|
111
|
+
<span className={`
|
|
112
|
+
whitespace-nowrap overflow-hidden transition-all duration-300
|
|
113
|
+
${expanded ? 'ml-3 max-w-48 opacity-100' : 'ml-0 max-w-0 opacity-0'}
|
|
114
|
+
`}>
|
|
115
|
+
{item.name}
|
|
116
|
+
</span>
|
|
117
|
+
{item.status && (
|
|
118
|
+
<div className={`
|
|
119
|
+
flex items-center justify-center
|
|
120
|
+
${expanded ? 'ml-auto' : 'absolute right-1 top-1 scale-75'}
|
|
121
|
+
`}>
|
|
122
|
+
{item.status}
|
|
123
|
+
</div>
|
|
124
|
+
)}
|
|
125
|
+
</Link>
|
|
126
|
+
|
|
127
|
+
{item.items && (
|
|
128
|
+
<div className={`
|
|
129
|
+
relative ml-6 flex flex-col gap-1 overflow-hidden
|
|
130
|
+
border-l border-login-800 pl-2
|
|
131
|
+
transition-all duration-300 ease-in-out
|
|
132
|
+
${expanded && isActive ? 'max-h-96 opacity-100' : 'max-h-0 opacity-0'}
|
|
133
|
+
`}>
|
|
134
|
+
{activeSubIndex >= 0 && (
|
|
135
|
+
<span
|
|
136
|
+
aria-hidden
|
|
137
|
+
className='
|
|
138
|
+
absolute left-2 right-0 top-0 h-9 rounded-lg
|
|
139
|
+
bg-login-800/50 transition-transform duration-300 ease-in-out
|
|
140
|
+
'
|
|
141
|
+
style={{ transform: `translateY(${activeSubIndex * SUB_STRIDE}px)` }}
|
|
142
|
+
/>
|
|
143
|
+
)}
|
|
144
|
+
{item.items.map((sub, subIndex) => {
|
|
145
|
+
const isSubActive = pathname === sub.path || pathname.startsWith(sub.path + '/')
|
|
146
|
+
return (
|
|
147
|
+
<Link
|
|
148
|
+
key={`${index}-${subIndex}`}
|
|
149
|
+
href={sub.path}
|
|
150
|
+
className={`
|
|
151
|
+
relative z-10 rounded-lg p-2 text-sm
|
|
152
|
+
transition-all duration-200
|
|
153
|
+
${isSubActive
|
|
154
|
+
? 'text-login'
|
|
155
|
+
: 'text-login-300 hover:bg-login-800/30 hover:text-login-100'
|
|
156
|
+
}
|
|
157
|
+
`}
|
|
158
|
+
>
|
|
159
|
+
{sub.name}
|
|
160
|
+
</Link>
|
|
161
|
+
)
|
|
162
|
+
})}
|
|
163
|
+
</div>
|
|
164
|
+
)}
|
|
165
|
+
</div>
|
|
166
|
+
)
|
|
167
|
+
})}
|
|
168
|
+
</div>
|
|
169
|
+
|
|
170
|
+
{bottomAction && (
|
|
171
|
+
<div className='border-t border-login-100/10 p-3'>
|
|
172
|
+
{bottomAction(expanded)}
|
|
173
|
+
</div>
|
|
174
|
+
)}
|
|
175
|
+
</aside>
|
|
176
|
+
)
|
|
177
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
type SpinnerProps = {
|
|
2
|
+
size?: 'sm' | 'md' | 'lg'
|
|
3
|
+
className?: string
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
const sizes = {
|
|
7
|
+
sm: 'w-4 h-4 border-2',
|
|
8
|
+
md: 'w-6 h-6 border-2',
|
|
9
|
+
lg: 'w-9 h-9 border-[3px]',
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export default function Spinner({ size = 'md', className = '' }: SpinnerProps) {
|
|
13
|
+
return (
|
|
14
|
+
<div
|
|
15
|
+
role='status'
|
|
16
|
+
aria-label='Loading'
|
|
17
|
+
className={`rounded-full border-login-500 border-t-login animate-spin ${sizes[size]} ${className}`}
|
|
18
|
+
/>
|
|
19
|
+
)
|
|
20
|
+
}
|