@nestledjs/data-browser 0.1.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/.babelrc +12 -0
- package/LICENSE +21 -0
- package/README.md +320 -0
- package/eslint.config.mjs +12 -0
- package/package.json +44 -0
- package/project.json +39 -0
- package/src/index.ts +28 -0
- package/src/lib/admin-data.spec.tsx +10 -0
- package/src/lib/admin-data.tsx +9 -0
- package/src/lib/components/filters/DateRangeFilter.tsx +88 -0
- package/src/lib/components/filters/NumberRangeFilter.tsx +111 -0
- package/src/lib/components/filters/RelationComponents.tsx +176 -0
- package/src/lib/components/filters/RelationFilterField.tsx +106 -0
- package/src/lib/components/filters/index.ts +5 -0
- package/src/lib/components/index.ts +2 -0
- package/src/lib/components/shared/AdminBreadcrumbs.tsx +88 -0
- package/src/lib/components/shared/AdminErrorStates.tsx +180 -0
- package/src/lib/components/shared/AdminStatusDisplay.tsx +292 -0
- package/src/lib/components/shared/index.ts +3 -0
- package/src/lib/context/AdminDataContext.tsx +74 -0
- package/src/lib/hooks/useAdminList.ts +42 -0
- package/src/lib/hooks/useClickOutside.ts +21 -0
- package/src/lib/hooks/useDebounce.ts +16 -0
- package/src/lib/hooks/useRelationData.ts +114 -0
- package/src/lib/layouts/AdminDataLayout.tsx +251 -0
- package/src/lib/pages/AdminDataCreatePage.tsx +415 -0
- package/src/lib/pages/AdminDataEditPage.tsx +777 -0
- package/src/lib/pages/AdminDataIndexPage.tsx +50 -0
- package/src/lib/pages/AdminDataListPage.tsx +921 -0
- package/src/lib/types/index.ts +51 -0
- package/src/lib/utils/graphql-utils.ts +538 -0
- package/src/lib/utils/secure-storage.ts +305 -0
- package/src/lib/utils/string-utils.ts +53 -0
- package/tsconfig.json +17 -0
- package/tsconfig.lib.json +20 -0
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { formatFieldName } from '../../utils/string-utils'
|
|
2
|
+
|
|
3
|
+
interface NumberRangeFilterProps {
|
|
4
|
+
fieldName: string
|
|
5
|
+
fieldType: string
|
|
6
|
+
currentValue: any
|
|
7
|
+
onChange: (value: any) => void
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function NumberRangeFilter({
|
|
11
|
+
fieldName,
|
|
12
|
+
fieldType,
|
|
13
|
+
currentValue,
|
|
14
|
+
onChange
|
|
15
|
+
}: Readonly<NumberRangeFilterProps>) {
|
|
16
|
+
// Parse current value if it's a range object
|
|
17
|
+
const minValue = currentValue?.gte !== undefined ? currentValue.gte.toString() : ''
|
|
18
|
+
const maxValue = currentValue?.lte !== undefined ? currentValue.lte.toString() : ''
|
|
19
|
+
|
|
20
|
+
const parseNumber = (value: string) => {
|
|
21
|
+
if (!value) return undefined
|
|
22
|
+
|
|
23
|
+
// Parse based on field type
|
|
24
|
+
if (fieldType === 'int' || fieldType === 'bigint') {
|
|
25
|
+
const parsed = parseInt(value, 10)
|
|
26
|
+
return isNaN(parsed) ? undefined : parsed
|
|
27
|
+
} else if (fieldType === 'float' || fieldType === 'decimal') {
|
|
28
|
+
const parsed = parseFloat(value)
|
|
29
|
+
return isNaN(parsed) ? undefined : parsed
|
|
30
|
+
}
|
|
31
|
+
return undefined
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const handleMinChange = (value: string) => {
|
|
35
|
+
const newValue = { ...currentValue }
|
|
36
|
+
const parsedValue = parseNumber(value)
|
|
37
|
+
|
|
38
|
+
if (parsedValue !== undefined) {
|
|
39
|
+
newValue.gte = parsedValue
|
|
40
|
+
} else {
|
|
41
|
+
delete newValue.gte
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// If no min or max value, clear the filter entirely
|
|
45
|
+
if (newValue.gte === undefined && newValue.lte === undefined) {
|
|
46
|
+
onChange(undefined)
|
|
47
|
+
} else {
|
|
48
|
+
onChange(newValue)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const handleMaxChange = (value: string) => {
|
|
53
|
+
const newValue = { ...currentValue }
|
|
54
|
+
const parsedValue = parseNumber(value)
|
|
55
|
+
|
|
56
|
+
if (parsedValue !== undefined) {
|
|
57
|
+
newValue.lte = parsedValue
|
|
58
|
+
} else {
|
|
59
|
+
delete newValue.lte
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// If no min or max value, clear the filter entirely
|
|
63
|
+
if (newValue.gte === undefined && newValue.lte === undefined) {
|
|
64
|
+
onChange(undefined)
|
|
65
|
+
} else {
|
|
66
|
+
onChange(newValue)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Determine input step based on field type
|
|
71
|
+
const step = fieldType === 'int' || fieldType === 'bigint' ? '1' : 'any'
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<div className="space-y-1">
|
|
75
|
+
<label className="block text-sm font-medium text-gray-700">
|
|
76
|
+
{formatFieldName(fieldName)}
|
|
77
|
+
</label>
|
|
78
|
+
<div className="grid grid-cols-2 gap-2">
|
|
79
|
+
<div>
|
|
80
|
+
<label className="block text-xs text-gray-500 mb-1">Min</label>
|
|
81
|
+
<input
|
|
82
|
+
type="number"
|
|
83
|
+
step={step}
|
|
84
|
+
value={minValue}
|
|
85
|
+
onChange={(e) => handleMinChange(e.target.value)}
|
|
86
|
+
placeholder="No minimum"
|
|
87
|
+
className="w-full px-2 py-1 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-1 focus:ring-green-web focus:border-green-web"
|
|
88
|
+
/>
|
|
89
|
+
</div>
|
|
90
|
+
<div>
|
|
91
|
+
<label className="block text-xs text-gray-500 mb-1">Max</label>
|
|
92
|
+
<input
|
|
93
|
+
type="number"
|
|
94
|
+
step={step}
|
|
95
|
+
value={maxValue}
|
|
96
|
+
onChange={(e) => handleMaxChange(e.target.value)}
|
|
97
|
+
placeholder="No maximum"
|
|
98
|
+
className="w-full px-2 py-1 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-1 focus:ring-green-web focus:border-green-web"
|
|
99
|
+
/>
|
|
100
|
+
</div>
|
|
101
|
+
</div>
|
|
102
|
+
{(minValue || maxValue) && (
|
|
103
|
+
<div className="text-xs text-gray-500">
|
|
104
|
+
{minValue && maxValue && `${minValue} to ${maxValue}`}
|
|
105
|
+
{minValue && !maxValue && `≥ ${minValue}`}
|
|
106
|
+
{!minValue && maxValue && `≤ ${maxValue}`}
|
|
107
|
+
</div>
|
|
108
|
+
)}
|
|
109
|
+
</div>
|
|
110
|
+
)
|
|
111
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { getItemDisplayName } from '../../utils/string-utils'
|
|
2
|
+
|
|
3
|
+
// Dropdown button component
|
|
4
|
+
export function RelationDropdownButton({
|
|
5
|
+
currentItem,
|
|
6
|
+
relatedModelName,
|
|
7
|
+
isOpen,
|
|
8
|
+
onClick
|
|
9
|
+
}: Readonly<{
|
|
10
|
+
currentItem: any
|
|
11
|
+
relatedModelName: string
|
|
12
|
+
isOpen: boolean
|
|
13
|
+
onClick: () => void
|
|
14
|
+
}>) {
|
|
15
|
+
return (
|
|
16
|
+
<button
|
|
17
|
+
type="button"
|
|
18
|
+
onClick={onClick}
|
|
19
|
+
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-web focus:border-green-web text-sm text-left bg-white flex items-center justify-between"
|
|
20
|
+
>
|
|
21
|
+
<span className={currentItem ? 'text-gray-900' : 'text-gray-500'}>
|
|
22
|
+
{currentItem ? getItemDisplayName(currentItem) : `Select ${relatedModelName.toLowerCase()}...`}
|
|
23
|
+
</span>
|
|
24
|
+
<svg
|
|
25
|
+
className={`w-4 h-4 text-gray-400 transition-transform ${isOpen ? 'rotate-180' : ''}`}
|
|
26
|
+
fill="none"
|
|
27
|
+
stroke="currentColor"
|
|
28
|
+
viewBox="0 0 24 24"
|
|
29
|
+
>
|
|
30
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
|
31
|
+
</svg>
|
|
32
|
+
</button>
|
|
33
|
+
)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Search input component
|
|
37
|
+
export function RelationSearchInput({
|
|
38
|
+
searchTerm,
|
|
39
|
+
onSearchChange,
|
|
40
|
+
relatedModelName
|
|
41
|
+
}: Readonly<{
|
|
42
|
+
searchTerm: string
|
|
43
|
+
onSearchChange: (value: string) => void
|
|
44
|
+
relatedModelName: string
|
|
45
|
+
}>) {
|
|
46
|
+
return (
|
|
47
|
+
<div className="p-2">
|
|
48
|
+
<input
|
|
49
|
+
type="text"
|
|
50
|
+
placeholder={`Search ${relatedModelName.toLowerCase()}...`}
|
|
51
|
+
value={searchTerm}
|
|
52
|
+
onChange={(e) => onSearchChange(e.target.value)}
|
|
53
|
+
className="w-full px-2 py-1 text-sm border border-gray-300 rounded focus:outline-none focus:ring-1 focus:ring-green-web"
|
|
54
|
+
autoFocus
|
|
55
|
+
/>
|
|
56
|
+
</div>
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Individual item component
|
|
61
|
+
export function RelationItem({
|
|
62
|
+
item,
|
|
63
|
+
onSelect
|
|
64
|
+
}: {
|
|
65
|
+
item: any
|
|
66
|
+
onSelect: (item: any) => void
|
|
67
|
+
}) {
|
|
68
|
+
return (
|
|
69
|
+
<button
|
|
70
|
+
type="button"
|
|
71
|
+
onClick={() => onSelect(item)}
|
|
72
|
+
className="w-full px-3 py-2 text-left text-sm hover:bg-gray-100 focus:bg-gray-100 transition-colors"
|
|
73
|
+
>
|
|
74
|
+
{getItemDisplayName(item)}
|
|
75
|
+
</button>
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Item list component with error handling
|
|
80
|
+
export function RelationItemList({
|
|
81
|
+
items,
|
|
82
|
+
loading,
|
|
83
|
+
error,
|
|
84
|
+
onSelect,
|
|
85
|
+
onClear
|
|
86
|
+
}: Readonly<{
|
|
87
|
+
items: any[]
|
|
88
|
+
loading: boolean
|
|
89
|
+
error?: any
|
|
90
|
+
onSelect: (item: any) => void
|
|
91
|
+
onClear: () => void
|
|
92
|
+
}>) {
|
|
93
|
+
return (
|
|
94
|
+
<div className="max-h-48 overflow-y-auto">
|
|
95
|
+
<button
|
|
96
|
+
type="button"
|
|
97
|
+
onClick={onClear}
|
|
98
|
+
className="w-full px-3 py-2 text-left text-sm hover:bg-gray-100 text-gray-500 border-b border-gray-200"
|
|
99
|
+
>
|
|
100
|
+
Clear selection
|
|
101
|
+
</button>
|
|
102
|
+
|
|
103
|
+
{error ? (
|
|
104
|
+
<div className="px-3 py-2 text-sm text-red-600">
|
|
105
|
+
<div className="flex items-center">
|
|
106
|
+
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
107
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
108
|
+
</svg>
|
|
109
|
+
Failed to load options
|
|
110
|
+
</div>
|
|
111
|
+
<div className="text-xs text-gray-500 mt-1">
|
|
112
|
+
{error.networkError ? 'Network error' : 'Please try again'}
|
|
113
|
+
</div>
|
|
114
|
+
</div>
|
|
115
|
+
) : loading ? (
|
|
116
|
+
<div className="px-3 py-2 text-sm text-gray-500">
|
|
117
|
+
<div className="flex items-center">
|
|
118
|
+
<svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-gray-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
|
119
|
+
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
|
120
|
+
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
|
121
|
+
</svg>
|
|
122
|
+
Loading...
|
|
123
|
+
</div>
|
|
124
|
+
</div>
|
|
125
|
+
) : items.length === 0 ? (
|
|
126
|
+
<div className="px-3 py-2 text-sm text-gray-500">No items found</div>
|
|
127
|
+
) : (
|
|
128
|
+
items.map((item: any) => (
|
|
129
|
+
<RelationItem
|
|
130
|
+
key={item.id}
|
|
131
|
+
item={item}
|
|
132
|
+
onSelect={onSelect}
|
|
133
|
+
/>
|
|
134
|
+
))
|
|
135
|
+
)}
|
|
136
|
+
</div>
|
|
137
|
+
)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Dropdown content container with error handling
|
|
141
|
+
export function RelationDropdownContent({
|
|
142
|
+
searchTerm,
|
|
143
|
+
onSearchChange,
|
|
144
|
+
relatedModelName,
|
|
145
|
+
items,
|
|
146
|
+
loading,
|
|
147
|
+
error,
|
|
148
|
+
onSelect,
|
|
149
|
+
onClear
|
|
150
|
+
}: {
|
|
151
|
+
searchTerm: string
|
|
152
|
+
onSearchChange: (value: string) => void
|
|
153
|
+
relatedModelName: string
|
|
154
|
+
items: any[]
|
|
155
|
+
loading: boolean
|
|
156
|
+
error?: any
|
|
157
|
+
onSelect: (item: any) => void
|
|
158
|
+
onClear: () => void
|
|
159
|
+
}) {
|
|
160
|
+
return (
|
|
161
|
+
<div className="absolute z-10 w-full mt-1 bg-white border border-gray-300 rounded-md shadow-lg">
|
|
162
|
+
<RelationSearchInput
|
|
163
|
+
searchTerm={searchTerm}
|
|
164
|
+
onSearchChange={onSearchChange}
|
|
165
|
+
relatedModelName={relatedModelName}
|
|
166
|
+
/>
|
|
167
|
+
<RelationItemList
|
|
168
|
+
items={items}
|
|
169
|
+
loading={loading}
|
|
170
|
+
error={error}
|
|
171
|
+
onSelect={onSelect}
|
|
172
|
+
onClear={onClear}
|
|
173
|
+
/>
|
|
174
|
+
</div>
|
|
175
|
+
)
|
|
176
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { useCallback, useMemo, useRef, useState } from 'react'
|
|
2
|
+
import { useClickOutside } from '../../hooks/useClickOutside'
|
|
3
|
+
import { useRelationData } from '../../hooks/useRelationData'
|
|
4
|
+
import { formatFieldName } from '../../utils/string-utils'
|
|
5
|
+
import { RelationDropdownButton, RelationDropdownContent } from './RelationComponents'
|
|
6
|
+
|
|
7
|
+
interface RelationFilterFieldProps {
|
|
8
|
+
fieldName: string
|
|
9
|
+
relatedModelName: string
|
|
10
|
+
currentValue: any
|
|
11
|
+
onChange: (value: any) => void
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Refactored relation field filtering component
|
|
15
|
+
export function RelationFilterField({
|
|
16
|
+
fieldName,
|
|
17
|
+
relatedModelName,
|
|
18
|
+
currentValue,
|
|
19
|
+
onChange
|
|
20
|
+
}: Readonly<RelationFilterFieldProps>) {
|
|
21
|
+
const [searchTerm, setSearchTerm] = useState('')
|
|
22
|
+
const [isOpen, setIsOpen] = useState(false)
|
|
23
|
+
const dropdownRef = useRef<HTMLDivElement>(null)
|
|
24
|
+
|
|
25
|
+
// Memoize the close handler to prevent memory leaks
|
|
26
|
+
const handleCloseDropdown = useCallback(() => {
|
|
27
|
+
setIsOpen(false)
|
|
28
|
+
}, [])
|
|
29
|
+
|
|
30
|
+
// Custom hooks for behavior
|
|
31
|
+
useClickOutside(dropdownRef, handleCloseDropdown, isOpen)
|
|
32
|
+
const { relatedItems, loading, error: relationError, hasDocument } = useRelationData(relatedModelName, searchTerm, isOpen)
|
|
33
|
+
|
|
34
|
+
// Find current item to display
|
|
35
|
+
const currentItem = useMemo(() =>
|
|
36
|
+
currentValue?.id ? relatedItems.find((item: any) => item.id === currentValue.id) : null,
|
|
37
|
+
[currentValue?.id, relatedItems]
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
// Event handlers
|
|
41
|
+
const handleSelect = useCallback((item: any) => {
|
|
42
|
+
onChange({ id: item.id })
|
|
43
|
+
setIsOpen(false)
|
|
44
|
+
setSearchTerm('') // Reset search when item selected
|
|
45
|
+
}, [onChange])
|
|
46
|
+
|
|
47
|
+
const handleClear = useCallback(() => {
|
|
48
|
+
onChange(undefined)
|
|
49
|
+
setIsOpen(false)
|
|
50
|
+
setSearchTerm('')
|
|
51
|
+
}, [onChange])
|
|
52
|
+
|
|
53
|
+
const handleToggleOpen = useCallback(() => {
|
|
54
|
+
setIsOpen(!isOpen)
|
|
55
|
+
if (!isOpen) {
|
|
56
|
+
setSearchTerm('') // Reset search when opening
|
|
57
|
+
}
|
|
58
|
+
}, [isOpen])
|
|
59
|
+
|
|
60
|
+
// If no document available, fall back to text input
|
|
61
|
+
if (!hasDocument) {
|
|
62
|
+
return (
|
|
63
|
+
<div className="space-y-1">
|
|
64
|
+
<label className="block text-sm font-medium text-gray-700">
|
|
65
|
+
{formatFieldName(fieldName)} ID
|
|
66
|
+
</label>
|
|
67
|
+
<input
|
|
68
|
+
type="text"
|
|
69
|
+
value={currentValue?.id || ''}
|
|
70
|
+
onChange={(e) => onChange(e.target.value ? { id: e.target.value } : undefined)}
|
|
71
|
+
placeholder="Enter ID..."
|
|
72
|
+
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-web focus:border-green-web text-sm"
|
|
73
|
+
/>
|
|
74
|
+
</div>
|
|
75
|
+
)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<div className="space-y-1" ref={dropdownRef}>
|
|
80
|
+
<label className="block text-sm font-medium text-gray-700">
|
|
81
|
+
{formatFieldName(fieldName)}
|
|
82
|
+
</label>
|
|
83
|
+
<div className="relative">
|
|
84
|
+
<RelationDropdownButton
|
|
85
|
+
currentItem={currentItem}
|
|
86
|
+
relatedModelName={relatedModelName}
|
|
87
|
+
isOpen={isOpen}
|
|
88
|
+
onClick={handleToggleOpen}
|
|
89
|
+
/>
|
|
90
|
+
|
|
91
|
+
{isOpen && (
|
|
92
|
+
<RelationDropdownContent
|
|
93
|
+
searchTerm={searchTerm}
|
|
94
|
+
onSearchChange={setSearchTerm}
|
|
95
|
+
relatedModelName={relatedModelName}
|
|
96
|
+
items={relatedItems}
|
|
97
|
+
loading={loading}
|
|
98
|
+
error={relationError}
|
|
99
|
+
onSelect={handleSelect}
|
|
100
|
+
onClear={handleClear}
|
|
101
|
+
/>
|
|
102
|
+
)}
|
|
103
|
+
</div>
|
|
104
|
+
</div>
|
|
105
|
+
)
|
|
106
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { Link } from 'react-router'
|
|
3
|
+
import { ChevronRightIcon, HomeIcon } from '@heroicons/react/24/outline'
|
|
4
|
+
|
|
5
|
+
export interface BreadcrumbItem {
|
|
6
|
+
id: string
|
|
7
|
+
label: string
|
|
8
|
+
href?: string
|
|
9
|
+
isActive?: boolean
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface AdminBreadcrumbsProps {
|
|
13
|
+
readonly items: readonly BreadcrumbItem[]
|
|
14
|
+
readonly showHome?: boolean
|
|
15
|
+
readonly homeHref?: string
|
|
16
|
+
readonly className?: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function AdminBreadcrumbs({
|
|
20
|
+
items,
|
|
21
|
+
showHome = true,
|
|
22
|
+
homeHref = '/admin',
|
|
23
|
+
className = '',
|
|
24
|
+
}: Readonly<AdminBreadcrumbsProps>) {
|
|
25
|
+
// Extract nested ternary logic into clear helper functions
|
|
26
|
+
const renderHomeLink = () => {
|
|
27
|
+
if (!showHome) return null
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<li key="home" className="flex items-center">
|
|
31
|
+
<Link
|
|
32
|
+
to={homeHref}
|
|
33
|
+
className="text-gray-400 hover:text-gray-500"
|
|
34
|
+
aria-label="Home"
|
|
35
|
+
>
|
|
36
|
+
<HomeIcon className="h-5 w-5 flex-shrink-0" />
|
|
37
|
+
</Link>
|
|
38
|
+
</li>
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const renderBreadcrumbItem = (item: BreadcrumbItem, index: number, isLast: boolean) => {
|
|
43
|
+
const content = item.href && !item.isActive ? (
|
|
44
|
+
<Link
|
|
45
|
+
to={item.href}
|
|
46
|
+
className="text-sm font-medium text-gray-500 hover:text-gray-700"
|
|
47
|
+
>
|
|
48
|
+
{item.label}
|
|
49
|
+
</Link>
|
|
50
|
+
) : (
|
|
51
|
+
<span
|
|
52
|
+
className={`text-sm font-medium ${
|
|
53
|
+
item.isActive ? 'text-gray-900' : 'text-gray-500'
|
|
54
|
+
}`}
|
|
55
|
+
>
|
|
56
|
+
{item.label}
|
|
57
|
+
</span>
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<li key={item.id} className="flex items-center">
|
|
62
|
+
{(showHome || index > 0) && (
|
|
63
|
+
<ChevronRightIcon
|
|
64
|
+
className="h-5 w-5 flex-shrink-0 text-gray-400 mr-4"
|
|
65
|
+
aria-hidden="true"
|
|
66
|
+
/>
|
|
67
|
+
)}
|
|
68
|
+
{content}
|
|
69
|
+
</li>
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const shouldShowSeparator = (index: number) => {
|
|
74
|
+
return showHome || index > 0
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return (
|
|
78
|
+
<nav className={`flex ${className}`} aria-label="Breadcrumb">
|
|
79
|
+
<ol className="flex items-center space-x-4">
|
|
80
|
+
{renderHomeLink()}
|
|
81
|
+
{items.map((item, index) => {
|
|
82
|
+
const isLast = index === items.length - 1
|
|
83
|
+
return renderBreadcrumbItem(item, index, isLast)
|
|
84
|
+
})}
|
|
85
|
+
</ol>
|
|
86
|
+
</nav>
|
|
87
|
+
)
|
|
88
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { ExclamationTriangleIcon, ExclamationCircleIcon, XCircleIcon } from '@heroicons/react/24/outline'
|
|
3
|
+
|
|
4
|
+
export type ErrorSeverity = 'warning' | 'error' | 'critical'
|
|
5
|
+
|
|
6
|
+
export interface AdminErrorStateProps {
|
|
7
|
+
readonly title: string
|
|
8
|
+
readonly message?: string
|
|
9
|
+
readonly severity?: ErrorSeverity
|
|
10
|
+
readonly onRetry?: () => void
|
|
11
|
+
readonly onDismiss?: () => void
|
|
12
|
+
readonly className?: string
|
|
13
|
+
readonly showIcon?: boolean
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface AdminEmptyStateProps {
|
|
17
|
+
readonly title: string
|
|
18
|
+
readonly message?: string
|
|
19
|
+
readonly actionLabel?: string
|
|
20
|
+
readonly onAction?: () => void
|
|
21
|
+
readonly className?: string
|
|
22
|
+
readonly icon?: React.ComponentType<{ className?: string }>
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface AdminLoadingStateProps {
|
|
26
|
+
readonly title?: string
|
|
27
|
+
readonly message?: string
|
|
28
|
+
readonly className?: string
|
|
29
|
+
readonly size?: 'small' | 'medium' | 'large'
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const getSeverityConfig = (severity: ErrorSeverity) => {
|
|
33
|
+
const configs = {
|
|
34
|
+
warning: {
|
|
35
|
+
icon: ExclamationTriangleIcon,
|
|
36
|
+
bgColor: 'bg-yellow-50',
|
|
37
|
+
borderColor: 'border-yellow-200',
|
|
38
|
+
iconColor: 'text-yellow-400',
|
|
39
|
+
titleColor: 'text-yellow-800',
|
|
40
|
+
messageColor: 'text-yellow-700',
|
|
41
|
+
buttonColor: 'bg-yellow-100 text-yellow-800 hover:bg-yellow-200',
|
|
42
|
+
},
|
|
43
|
+
error: {
|
|
44
|
+
icon: ExclamationCircleIcon,
|
|
45
|
+
bgColor: 'bg-red-50',
|
|
46
|
+
borderColor: 'border-red-200',
|
|
47
|
+
iconColor: 'text-red-400',
|
|
48
|
+
titleColor: 'text-red-800',
|
|
49
|
+
messageColor: 'text-red-700',
|
|
50
|
+
buttonColor: 'bg-red-100 text-red-800 hover:bg-red-200',
|
|
51
|
+
},
|
|
52
|
+
critical: {
|
|
53
|
+
icon: XCircleIcon,
|
|
54
|
+
bgColor: 'bg-red-100',
|
|
55
|
+
borderColor: 'border-red-300',
|
|
56
|
+
iconColor: 'text-red-500',
|
|
57
|
+
titleColor: 'text-red-900',
|
|
58
|
+
messageColor: 'text-red-800',
|
|
59
|
+
buttonColor: 'bg-red-200 text-red-900 hover:bg-red-300',
|
|
60
|
+
},
|
|
61
|
+
}
|
|
62
|
+
return configs[severity]
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function AdminErrorState({
|
|
66
|
+
title,
|
|
67
|
+
message,
|
|
68
|
+
severity = 'error',
|
|
69
|
+
onRetry,
|
|
70
|
+
onDismiss,
|
|
71
|
+
className = '',
|
|
72
|
+
showIcon = true,
|
|
73
|
+
}: Readonly<AdminErrorStateProps>) {
|
|
74
|
+
const config = getSeverityConfig(severity)
|
|
75
|
+
const IconComponent = config.icon
|
|
76
|
+
|
|
77
|
+
return (
|
|
78
|
+
<div className={`rounded-md border p-4 ${config.bgColor} ${config.borderColor} ${className}`}>
|
|
79
|
+
<div className="flex">
|
|
80
|
+
{showIcon && (
|
|
81
|
+
<div className="flex-shrink-0">
|
|
82
|
+
<IconComponent className={`h-5 w-5 ${config.iconColor}`} aria-hidden="true" />
|
|
83
|
+
</div>
|
|
84
|
+
)}
|
|
85
|
+
<div className={showIcon ? 'ml-3' : ''}>
|
|
86
|
+
<h3 className={`text-sm font-medium ${config.titleColor}`}>
|
|
87
|
+
{title}
|
|
88
|
+
</h3>
|
|
89
|
+
{message && (
|
|
90
|
+
<div className={`mt-2 text-sm ${config.messageColor}`}>
|
|
91
|
+
<p>{message}</p>
|
|
92
|
+
</div>
|
|
93
|
+
)}
|
|
94
|
+
{(onRetry || onDismiss) && (
|
|
95
|
+
<div className="mt-4">
|
|
96
|
+
<div className="-mx-2 -my-1.5 flex">
|
|
97
|
+
{onRetry && (
|
|
98
|
+
<button
|
|
99
|
+
type="button"
|
|
100
|
+
onClick={onRetry}
|
|
101
|
+
className={`rounded-md px-2 py-1.5 text-sm font-medium ${config.buttonColor} focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500`}
|
|
102
|
+
>
|
|
103
|
+
Try Again
|
|
104
|
+
</button>
|
|
105
|
+
)}
|
|
106
|
+
{onDismiss && (
|
|
107
|
+
<button
|
|
108
|
+
type="button"
|
|
109
|
+
onClick={onDismiss}
|
|
110
|
+
className={`ml-3 rounded-md px-2 py-1.5 text-sm font-medium ${config.buttonColor} focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500`}
|
|
111
|
+
>
|
|
112
|
+
Dismiss
|
|
113
|
+
</button>
|
|
114
|
+
)}
|
|
115
|
+
</div>
|
|
116
|
+
</div>
|
|
117
|
+
)}
|
|
118
|
+
</div>
|
|
119
|
+
</div>
|
|
120
|
+
</div>
|
|
121
|
+
)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function AdminEmptyState({
|
|
125
|
+
title,
|
|
126
|
+
message,
|
|
127
|
+
actionLabel,
|
|
128
|
+
onAction,
|
|
129
|
+
className = '',
|
|
130
|
+
icon: IconComponent,
|
|
131
|
+
}: Readonly<AdminEmptyStateProps>) {
|
|
132
|
+
return (
|
|
133
|
+
<div className={`text-center ${className}`}>
|
|
134
|
+
{IconComponent && (
|
|
135
|
+
<IconComponent className="mx-auto h-12 w-12 text-gray-400" />
|
|
136
|
+
)}
|
|
137
|
+
<h3 className="mt-2 text-sm font-medium text-gray-900">{title}</h3>
|
|
138
|
+
{message && (
|
|
139
|
+
<p className="mt-1 text-sm text-gray-500">{message}</p>
|
|
140
|
+
)}
|
|
141
|
+
{actionLabel && onAction && (
|
|
142
|
+
<div className="mt-6">
|
|
143
|
+
<button
|
|
144
|
+
type="button"
|
|
145
|
+
onClick={onAction}
|
|
146
|
+
className="inline-flex items-center rounded-md bg-green-web px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-green-web-800 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-green-web"
|
|
147
|
+
>
|
|
148
|
+
{actionLabel}
|
|
149
|
+
</button>
|
|
150
|
+
</div>
|
|
151
|
+
)}
|
|
152
|
+
</div>
|
|
153
|
+
)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function AdminLoadingState({
|
|
157
|
+
title = 'Loading...',
|
|
158
|
+
message,
|
|
159
|
+
className = '',
|
|
160
|
+
size = 'medium',
|
|
161
|
+
}: Readonly<AdminLoadingStateProps>) {
|
|
162
|
+
const getSizeClasses = () => {
|
|
163
|
+
const sizes = {
|
|
164
|
+
small: 'h-4 w-4',
|
|
165
|
+
medium: 'h-8 w-8',
|
|
166
|
+
large: 'h-12 w-12',
|
|
167
|
+
}
|
|
168
|
+
return sizes[size]
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return (
|
|
172
|
+
<div className={`flex flex-col items-center justify-center p-8 ${className}`}>
|
|
173
|
+
<div className={`animate-spin rounded-full border-b-2 border-green-web ${getSizeClasses()}`}></div>
|
|
174
|
+
<h3 className="mt-4 text-sm font-medium text-gray-900">{title}</h3>
|
|
175
|
+
{message && (
|
|
176
|
+
<p className="mt-1 text-sm text-gray-500">{message}</p>
|
|
177
|
+
)}
|
|
178
|
+
</div>
|
|
179
|
+
)
|
|
180
|
+
}
|