nitro-web 0.0.87 → 0.0.89
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/client/index.ts +0 -3
- package/components/auth/auth.api.js +411 -0
- package/components/auth/reset.tsx +86 -0
- package/components/auth/signin.tsx +76 -0
- package/components/auth/signup.tsx +62 -0
- package/components/billing/stripe.api.js +268 -0
- package/components/dashboard/dashboard.tsx +32 -0
- package/components/partials/element/accordion.tsx +102 -0
- package/components/partials/element/avatar.tsx +40 -0
- package/components/partials/element/button.tsx +98 -0
- package/components/partials/element/calendar.tsx +125 -0
- package/components/partials/element/dropdown.tsx +248 -0
- package/components/partials/element/filters.tsx +194 -0
- package/components/partials/element/github-link.tsx +16 -0
- package/components/partials/element/initials.tsx +66 -0
- package/components/partials/element/message.tsx +141 -0
- package/components/partials/element/modal.tsx +90 -0
- package/components/partials/element/sidebar.tsx +195 -0
- package/components/partials/element/tooltip.tsx +154 -0
- package/components/partials/element/topbar.tsx +15 -0
- package/components/partials/form/checkbox.tsx +150 -0
- package/components/partials/form/drop-handler.tsx +68 -0
- package/components/partials/form/drop.tsx +141 -0
- package/components/partials/form/field-color.tsx +86 -0
- package/components/partials/form/field-currency.tsx +158 -0
- package/components/partials/form/field-date.tsx +252 -0
- package/components/partials/form/field.tsx +231 -0
- package/components/partials/form/form-error.tsx +27 -0
- package/components/partials/form/location.tsx +225 -0
- package/components/partials/form/select.tsx +360 -0
- package/components/partials/is-first-render.ts +14 -0
- package/components/partials/not-found.tsx +7 -0
- package/components/partials/styleguide.tsx +407 -0
- package/package.json +2 -1
- package/types/globals.d.ts +0 -1
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import { css } from 'twin.macro'
|
|
2
|
+
import { forwardRef, cloneElement } from 'react'
|
|
3
|
+
import { getSelectStyle, twMerge } from 'nitro-web'
|
|
4
|
+
import { CheckCircleIcon } from '@heroicons/react/24/solid'
|
|
5
|
+
|
|
6
|
+
type DropdownProps = {
|
|
7
|
+
allowOverflow?: boolean
|
|
8
|
+
animate?: boolean
|
|
9
|
+
children?: React.ReactNode
|
|
10
|
+
className?: string
|
|
11
|
+
css?: string
|
|
12
|
+
/** The direction of the menu **/
|
|
13
|
+
dir?: 'bottom-left'|'bottom-right'|'top-left'|'top-right'
|
|
14
|
+
options?: { label: string|React.ReactNode, onClick?: Function, isSelected?: boolean, icon?: React.ReactNode, className?: string }[]
|
|
15
|
+
/** Whether the dropdown is hoverable **/
|
|
16
|
+
isHoverable?: boolean
|
|
17
|
+
/** The content to render inside the top of the dropdown **/
|
|
18
|
+
menuContent?: React.ReactNode
|
|
19
|
+
menuClassName?: string
|
|
20
|
+
menuOptionClassName?: string
|
|
21
|
+
menuIsOpen?: boolean
|
|
22
|
+
menuToggles?: boolean
|
|
23
|
+
/** The minimum width of the menu **/
|
|
24
|
+
minWidth?: number | string
|
|
25
|
+
toggleCallback?: (isActive: boolean) => void
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const Dropdown = forwardRef(function Dropdown({
|
|
29
|
+
allowOverflow=false,
|
|
30
|
+
animate=true,
|
|
31
|
+
children,
|
|
32
|
+
className,
|
|
33
|
+
dir='bottom-left',
|
|
34
|
+
options,
|
|
35
|
+
isHoverable,
|
|
36
|
+
menuClassName,
|
|
37
|
+
menuOptionClassName,
|
|
38
|
+
menuContent,
|
|
39
|
+
menuIsOpen,
|
|
40
|
+
menuToggles=true,
|
|
41
|
+
minWidth,
|
|
42
|
+
toggleCallback,
|
|
43
|
+
}: DropdownProps, ref) {
|
|
44
|
+
// https://letsbuildui.dev/articles/building-a-dropdown-menu-component-with-react-hooks
|
|
45
|
+
isHoverable = isHoverable && !menuIsOpen
|
|
46
|
+
const dropdownRef = useRef<HTMLDivElement|null>(null)
|
|
47
|
+
const [isActive, setIsActive] = useState(!!menuIsOpen)
|
|
48
|
+
const menuStyle = getSelectStyle({ name: 'menu' })
|
|
49
|
+
const [direction, setDirection] = useState<null | 'bottom-left' | 'bottom-right' | 'top-left' | 'top-right'>(null)
|
|
50
|
+
const [ready, setReady] = useState(false)
|
|
51
|
+
|
|
52
|
+
// Expose the setIsActive function to the parent component
|
|
53
|
+
useImperativeHandle(ref, () => ({ setIsActive }))
|
|
54
|
+
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
const pageClick = (event: MouseEvent | FocusEvent) => {
|
|
57
|
+
try {
|
|
58
|
+
// If the active element exists and is clicked outside of the dropdown, toggle the dropdown
|
|
59
|
+
if (dropdownRef.current !== null && !dropdownRef.current.contains(event.target as Node)) setIsActive(!isActive)
|
|
60
|
+
} catch (_e) {
|
|
61
|
+
// Errors throw for contains() when the user clicks off the webpage when open
|
|
62
|
+
setIsActive(!isActive)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
if (isActive && !menuIsOpen) {
|
|
66
|
+
// Wait for the next event loop in the case of mousedown'ing the dropdown, while loosing click focus from a checkbox
|
|
67
|
+
setTimeout(() => {
|
|
68
|
+
window.addEventListener('mousedown', pageClick)
|
|
69
|
+
window.addEventListener('focus', pageClick, true) // true needed to capture focus events
|
|
70
|
+
}, 0)
|
|
71
|
+
}
|
|
72
|
+
return () => {
|
|
73
|
+
window.removeEventListener('mousedown', pageClick)
|
|
74
|
+
window.removeEventListener('focus', pageClick, true) // true needed to capture focus events
|
|
75
|
+
}
|
|
76
|
+
}, [isActive, dropdownRef])
|
|
77
|
+
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
if (toggleCallback) toggleCallback(isActive)
|
|
80
|
+
}, [isActive])
|
|
81
|
+
|
|
82
|
+
useEffect(() => {
|
|
83
|
+
setReady(false)
|
|
84
|
+
if (!isActive || !dropdownRef.current) return
|
|
85
|
+
|
|
86
|
+
const ul = dropdownRef.current.querySelector('ul') as HTMLElement
|
|
87
|
+
if (!ul) return
|
|
88
|
+
|
|
89
|
+
// Temporarily show the ul for measurement
|
|
90
|
+
const originalMaxHeight = ul.style.maxHeight
|
|
91
|
+
const originalVisibility = ul.style.visibility
|
|
92
|
+
const originalOpacity = ul.style.opacity
|
|
93
|
+
const originalPointerEvents = ul.style.pointerEvents
|
|
94
|
+
|
|
95
|
+
ul.style.maxHeight = 'none'
|
|
96
|
+
ul.style.visibility = 'hidden'
|
|
97
|
+
ul.style.opacity = '0'
|
|
98
|
+
ul.style.pointerEvents = 'none'
|
|
99
|
+
|
|
100
|
+
const dropdownHeight = ul.getBoundingClientRect().height
|
|
101
|
+
|
|
102
|
+
// Revert styles
|
|
103
|
+
ul.style.maxHeight = originalMaxHeight
|
|
104
|
+
ul.style.visibility = originalVisibility
|
|
105
|
+
ul.style.opacity = originalOpacity
|
|
106
|
+
ul.style.pointerEvents = originalPointerEvents
|
|
107
|
+
|
|
108
|
+
const rect = dropdownRef.current.getBoundingClientRect()
|
|
109
|
+
const spaceBelow = window.innerHeight - rect.bottom
|
|
110
|
+
const spaceAbove = rect.top
|
|
111
|
+
|
|
112
|
+
const side = dir.endsWith('right') ? 'right' : 'left'
|
|
113
|
+
|
|
114
|
+
const newDirection = dir.startsWith('bottom')
|
|
115
|
+
? `${spaceBelow < dropdownHeight && spaceAbove > dropdownHeight ? 'top' : 'bottom'}-${side}`
|
|
116
|
+
: `${spaceAbove < dropdownHeight && spaceBelow > dropdownHeight ? 'bottom' : 'top'}-${side}`
|
|
117
|
+
|
|
118
|
+
setDirection(newDirection as 'bottom-left' | 'bottom-right' | 'top-left' | 'top-right')
|
|
119
|
+
|
|
120
|
+
requestAnimationFrame(() => {
|
|
121
|
+
setReady(true)
|
|
122
|
+
})
|
|
123
|
+
}, [isActive, dir])
|
|
124
|
+
|
|
125
|
+
function onMouseDown(e: { key: string, preventDefault: Function }) {
|
|
126
|
+
if (e.key && e.key != 'Enter') return
|
|
127
|
+
if (e.key) e.preventDefault() // for button, stops buttons firing twice
|
|
128
|
+
if (!isHoverable && !menuIsOpen && ((menuToggles || e.key) || !isActive)) setIsActive(!isActive)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function onClick(option: { onClick?: Function }, e: React.MouseEvent) {
|
|
132
|
+
if (option.onClick) option.onClick(e)
|
|
133
|
+
if (!menuIsOpen) setIsActive(!isActive)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return (
|
|
137
|
+
<div
|
|
138
|
+
class={
|
|
139
|
+
`relative is-${direction || dir}` + // until hovered, show the original direction to prevent scrollbars
|
|
140
|
+
(isHoverable ? ' is-hoverable' : '') +
|
|
141
|
+
(isActive ? ' is-active' : '') +
|
|
142
|
+
(!animate ? ' no-animation' : '') +
|
|
143
|
+
(allowOverflow ? ' is-allowOverflow' : '') +
|
|
144
|
+
' nitro-dropdown' +
|
|
145
|
+
(className ? ` ${className}` : '')
|
|
146
|
+
}
|
|
147
|
+
onClick={(e) => e.stopPropagation()} // required for dropdowns inside row links
|
|
148
|
+
ref={dropdownRef}
|
|
149
|
+
css={style}
|
|
150
|
+
>
|
|
151
|
+
{
|
|
152
|
+
(Array.isArray(children) ? children : [children]).map((el, key) => {
|
|
153
|
+
const onKeyDown = onMouseDown
|
|
154
|
+
if (!el.type) throw new Error('Dropdown component requires a valid child element')
|
|
155
|
+
return cloneElement(el, { key, onMouseDown, onKeyDown }) // adds onClick
|
|
156
|
+
})
|
|
157
|
+
}
|
|
158
|
+
<ul
|
|
159
|
+
style={{ minWidth }}
|
|
160
|
+
class={
|
|
161
|
+
twMerge(`${menuStyle} ${ready ? 'is-ready' : ''} absolute invisible opacity-0 select-none min-w-full z-[1] ${menuClassName||''}`)}
|
|
162
|
+
>
|
|
163
|
+
{menuContent}
|
|
164
|
+
{
|
|
165
|
+
options && options.map((option, i) => {
|
|
166
|
+
const optionStyle = getSelectStyle({ name: 'option', usePrefixes: true, isSelected: option.isSelected })
|
|
167
|
+
return (
|
|
168
|
+
<li
|
|
169
|
+
key={i}
|
|
170
|
+
className={twMerge(`${optionStyle} ${option.className} ${menuOptionClassName}`)}
|
|
171
|
+
onClick={(e: React.MouseEvent) => onClick(option, e)}
|
|
172
|
+
>
|
|
173
|
+
<span class="flex-auto">{option.label}</span>
|
|
174
|
+
{ !!option.icon && option.icon }
|
|
175
|
+
{ option.isSelected && <CheckCircleIcon className="size-[22px] text-primary -my-1 -mx-0.5" /> }
|
|
176
|
+
</li>
|
|
177
|
+
)
|
|
178
|
+
})
|
|
179
|
+
}
|
|
180
|
+
</ul>
|
|
181
|
+
</div>
|
|
182
|
+
)
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
const style = css`
|
|
186
|
+
&>ul {
|
|
187
|
+
transition: transform 0.15s ease, opacity 0.15s ease, visibility 0s 0.15s ease, max-width 0s 0.15s ease, max-height 0s 0.15s ease;
|
|
188
|
+
max-width: 0; // handy if the dropdown ul exceeds the viewport width
|
|
189
|
+
max-height: 0; // handy if the dropdown ul exceeds the viewport height
|
|
190
|
+
pointer-events: none;
|
|
191
|
+
}
|
|
192
|
+
&.is-bottom-right,
|
|
193
|
+
&.is-top-right {
|
|
194
|
+
ul {
|
|
195
|
+
left: auto;
|
|
196
|
+
right: 0;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
&.is-bottom-left,
|
|
200
|
+
&.is-bottom-right {
|
|
201
|
+
&>ul {
|
|
202
|
+
top: 100%;
|
|
203
|
+
transform: translateY(6px);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
&.is-top-left,
|
|
207
|
+
&.is-top-right {
|
|
208
|
+
&>ul {
|
|
209
|
+
bottom: 100%;
|
|
210
|
+
transform: translateY(-10px);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
// active submenu
|
|
214
|
+
&.is-hoverable:hover,
|
|
215
|
+
&:focus,
|
|
216
|
+
&.is-active,
|
|
217
|
+
&>ul>li:hover,
|
|
218
|
+
&>ul>li:focus,
|
|
219
|
+
&>ul>li.is-active {
|
|
220
|
+
&>ul.is-ready {
|
|
221
|
+
opacity: 1;
|
|
222
|
+
visibility: visible;
|
|
223
|
+
transition: transform 0.15s ease, opacity 0.15s ease;
|
|
224
|
+
max-width: 1000px;
|
|
225
|
+
max-height: 1000px;
|
|
226
|
+
pointer-events: auto;
|
|
227
|
+
}
|
|
228
|
+
&.is-allowOverflow > ul {
|
|
229
|
+
overflow: visible;
|
|
230
|
+
}
|
|
231
|
+
&.is-bottom-left > ul,
|
|
232
|
+
&.is-bottom-right > ul {
|
|
233
|
+
transform: translateY(3px) !important;
|
|
234
|
+
}
|
|
235
|
+
&.is-top-left > ul,
|
|
236
|
+
&.is-top-right > ul {
|
|
237
|
+
transform: translateY(-7px) !important;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
// no animation
|
|
241
|
+
&.no-animation {
|
|
242
|
+
&>ul {
|
|
243
|
+
transition: none;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
`
|
|
247
|
+
|
|
248
|
+
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { forwardRef, Dispatch, SetStateAction, useRef, useEffect, useImperativeHandle } from 'react'
|
|
2
|
+
import { Button, Dropdown, Field, Select, type FieldProps, type SelectProps } from 'nitro-web'
|
|
3
|
+
import { camelCaseToTitle, debounce, omit, queryString, queryObject, twMerge } from 'nitro-web/util'
|
|
4
|
+
import { ListFilterIcon } from 'lucide-react'
|
|
5
|
+
|
|
6
|
+
type CommonProps = {
|
|
7
|
+
label?: string
|
|
8
|
+
rowClassName?: string
|
|
9
|
+
}
|
|
10
|
+
export type FilterType = (
|
|
11
|
+
| FieldProps & CommonProps
|
|
12
|
+
| ({ type: 'select' } & SelectProps & CommonProps)
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
type FilterState = {
|
|
16
|
+
[key: string]: string | true
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
type FiltersProps = {
|
|
20
|
+
state?: FilterState
|
|
21
|
+
setState?: Dispatch<SetStateAction<FilterState>>
|
|
22
|
+
filters?: FilterType[]
|
|
23
|
+
elements?: {
|
|
24
|
+
Button?: typeof Button
|
|
25
|
+
Dropdown?: typeof Dropdown
|
|
26
|
+
Field?: typeof Field
|
|
27
|
+
Select?: typeof Select
|
|
28
|
+
FilterIcon?: typeof ListFilterIcon
|
|
29
|
+
}
|
|
30
|
+
buttonProps?: Partial<React.ComponentProps<typeof Button>>
|
|
31
|
+
buttonText?: string
|
|
32
|
+
buttonCounterClassName?: string
|
|
33
|
+
dropdownProps?: Partial<React.ComponentProps<typeof Dropdown>>
|
|
34
|
+
dropdownFiltersClassName?: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type FiltersHandleType = {
|
|
38
|
+
submit: (includePagination?: boolean) => void
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const debounceTime = 250
|
|
42
|
+
|
|
43
|
+
export const Filters = forwardRef<FiltersHandleType, FiltersProps>(({
|
|
44
|
+
filters,
|
|
45
|
+
setState: setState2,
|
|
46
|
+
state: state2,
|
|
47
|
+
buttonProps,
|
|
48
|
+
buttonCounterClassName,
|
|
49
|
+
buttonText,
|
|
50
|
+
dropdownProps,
|
|
51
|
+
dropdownFiltersClassName,
|
|
52
|
+
elements,
|
|
53
|
+
}, ref) => {
|
|
54
|
+
const location = useLocation()
|
|
55
|
+
const navigate = useNavigate()
|
|
56
|
+
const [lastUpdated, setLastUpdated] = useState(0)
|
|
57
|
+
const [debouncedSubmit] = useState(() => debounce(submit, debounceTime))
|
|
58
|
+
const [state3, setState3] = useState(() => ({ ...queryObject(location.search) }))
|
|
59
|
+
const [state, setState] = [state2 || state3, setState2 || setState3]
|
|
60
|
+
const stateRef = useRef(state)
|
|
61
|
+
const count = useMemo(() => Object.keys(state).filter((k) => state[k] && filters?.some((f) => f.name === k)).length, [state, filters])
|
|
62
|
+
|
|
63
|
+
const Elements = {
|
|
64
|
+
Button: elements?.Button || Button,
|
|
65
|
+
Dropdown: elements?.Dropdown || Dropdown,
|
|
66
|
+
Field: elements?.Field || Field,
|
|
67
|
+
Select: elements?.Select || Select,
|
|
68
|
+
FilterIcon: elements?.FilterIcon || ListFilterIcon,
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
useImperativeHandle(ref, () => ({
|
|
72
|
+
submit: debouncedSubmit,
|
|
73
|
+
}))
|
|
74
|
+
|
|
75
|
+
useEffect(() => {
|
|
76
|
+
return () => debouncedSubmit.cancel()
|
|
77
|
+
}, [])
|
|
78
|
+
|
|
79
|
+
useEffect(() => {
|
|
80
|
+
stateRef.current = state
|
|
81
|
+
}, [state])
|
|
82
|
+
|
|
83
|
+
useEffect(() => {
|
|
84
|
+
// Only update the state if the filters haven't been input changed in the last 500ms
|
|
85
|
+
if (Date.now() - lastUpdated > (debounceTime + 250)) {
|
|
86
|
+
setState(() => ({
|
|
87
|
+
...queryObject(location.search),
|
|
88
|
+
}))
|
|
89
|
+
}
|
|
90
|
+
}, [location.search])
|
|
91
|
+
|
|
92
|
+
function reset(e: React.MouseEvent<HTMLAnchorElement>, filter: FilterType) {
|
|
93
|
+
e.preventDefault()
|
|
94
|
+
setState((s) => omit(s, [filter.name]) as FilterState)
|
|
95
|
+
onAfterChange()
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function resetAll(e: React.MouseEvent<HTMLButtonElement>) {
|
|
99
|
+
e.preventDefault()
|
|
100
|
+
setState((s) => omit(s, filters?.map((f) => f.name) || []) as FilterState)
|
|
101
|
+
onAfterChange()
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function onInputChange(e: {target: {name: string, value: unknown}}) {
|
|
105
|
+
// basic name and value (keeping deep paths intact e.g. 'jobCache.location': '10')
|
|
106
|
+
setState((s) => ({ ...s, [e.target.name]: e.target.value as string }))
|
|
107
|
+
onAfterChange()
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function onAfterChange() {
|
|
111
|
+
setLastUpdated(Date.now())
|
|
112
|
+
debouncedSubmit()
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Update the URL by replacing the current entry in the history stack
|
|
116
|
+
function submit(includePagination?: boolean) {
|
|
117
|
+
const queryStr = queryString(omit(stateRef.current, includePagination ? [] : ['page']))
|
|
118
|
+
navigate(location.pathname + queryStr, { replace: true })
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return (
|
|
122
|
+
<Elements.Dropdown
|
|
123
|
+
// menuIsOpen={true}
|
|
124
|
+
dir="bottom-right"
|
|
125
|
+
allowOverflow={true}
|
|
126
|
+
{...dropdownProps}
|
|
127
|
+
menuClassName={twMerge(`min-w-[330px] ${dropdownProps?.menuClassName || ''}`)}
|
|
128
|
+
menuContent={
|
|
129
|
+
<div>
|
|
130
|
+
<div class="flex justify-between items-center border-b p-4 py-3.5">
|
|
131
|
+
<div class="text-lg font-semibold">Filters</div>
|
|
132
|
+
<Button color="clear" size="sm" onClick={resetAll}>Reset All</Button>
|
|
133
|
+
</div>
|
|
134
|
+
{/* <div class="w-[1330px] bg-red-500 absolute">
|
|
135
|
+
This div shouldnt produce a page scrollbar when the dropdown is closed.
|
|
136
|
+
But should be visibile if allowedOverflow is true.
|
|
137
|
+
</div> */}
|
|
138
|
+
<div class={twMerge(`flex flex-wrap gap-4 px-4 py-4 pb-6 ${dropdownFiltersClassName || ''}`)}>
|
|
139
|
+
{
|
|
140
|
+
filters?.map(({label, rowClassName, ...filter}, i) => (
|
|
141
|
+
<div key={i} class={twMerge(`w-full ${rowClassName||''}`)}>
|
|
142
|
+
<div class="flex justify-between">
|
|
143
|
+
<label for={filter.id || filter.name}>{label || camelCaseToTitle(filter.name)}</label>
|
|
144
|
+
<a href="#" class="label font-normal text-secondary underline" onClick={(e) => reset(e, filter)}>Reset</a>
|
|
145
|
+
</div>
|
|
146
|
+
{
|
|
147
|
+
filter.type === 'select' &&
|
|
148
|
+
<Elements.Select
|
|
149
|
+
{...filter}
|
|
150
|
+
class="!mb-0"
|
|
151
|
+
value={state[filter.name] || ''}
|
|
152
|
+
onChange={onInputChange}
|
|
153
|
+
type={undefined}
|
|
154
|
+
/>
|
|
155
|
+
}
|
|
156
|
+
{
|
|
157
|
+
filter.type !== 'select' &&
|
|
158
|
+
<Elements.Field
|
|
159
|
+
{...filter}
|
|
160
|
+
class="!mb-0"
|
|
161
|
+
value={(state[filter.name] as string) || ''}
|
|
162
|
+
onChange={onInputChange}
|
|
163
|
+
/>
|
|
164
|
+
}
|
|
165
|
+
</div>
|
|
166
|
+
))
|
|
167
|
+
}
|
|
168
|
+
</div>
|
|
169
|
+
</div>
|
|
170
|
+
}
|
|
171
|
+
>
|
|
172
|
+
<Elements.Button
|
|
173
|
+
color="white"
|
|
174
|
+
IconLeft={<Elements.FilterIcon size={16} />}
|
|
175
|
+
{...buttonProps}
|
|
176
|
+
className={twMerge(`flex gap-x-2.5 ${buttonProps?.className || ''}`)}
|
|
177
|
+
>
|
|
178
|
+
<span class="flex items-center gap-x-2.5">
|
|
179
|
+
{ buttonText || 'Filter By' }
|
|
180
|
+
{
|
|
181
|
+
!!count &&
|
|
182
|
+
<span
|
|
183
|
+
class={twMerge(`inline-flex items-center justify-center rounded-full text-white bg-primary box-content w-[1em] h-[1em] p-[2px] ${buttonCounterClassName || ''}`)}
|
|
184
|
+
>
|
|
185
|
+
<span class="text-xs">{count}</span>
|
|
186
|
+
</span>
|
|
187
|
+
}
|
|
188
|
+
</span>
|
|
189
|
+
</Elements.Button>
|
|
190
|
+
</Elements.Dropdown>
|
|
191
|
+
)
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
Filters.displayName = 'Filters'
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import GithubIcon from 'nitro-web/client/imgs/github.svg'
|
|
2
|
+
|
|
3
|
+
export function GithubLink({ filename }: { filename: string }) {
|
|
4
|
+
const base = 'https://github.com/boycce/nitro-web/blob/master/packages/'
|
|
5
|
+
// Filenames are relative to the webpack start directory
|
|
6
|
+
// 1. Remove ../ from filename (i.e. for _example build)
|
|
7
|
+
// 2. Remove node_modules/nitro-web/ from filename (i.e. for packages using nitro-web)
|
|
8
|
+
const link = base + filename.replace(/^(\.\.\/|.*node_modules\/nitro-web\/)/, '')
|
|
9
|
+
|
|
10
|
+
return (
|
|
11
|
+
// <a href={link}>Go to Github</a>
|
|
12
|
+
<a href={link} className="fixed top-0 right-0 nitro-github">
|
|
13
|
+
<GithubIcon />
|
|
14
|
+
</a>
|
|
15
|
+
)
|
|
16
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { css } from 'twin.macro'
|
|
2
|
+
|
|
3
|
+
type InitialsProps = {
|
|
4
|
+
icon?: { initials: string, hex: string }
|
|
5
|
+
isBig?: boolean
|
|
6
|
+
isMedium?: boolean
|
|
7
|
+
isSmall?: boolean
|
|
8
|
+
isRound?: boolean
|
|
9
|
+
className?: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function Initials({ icon, isBig, isMedium, isSmall, isRound, className }: InitialsProps) {
|
|
13
|
+
return (
|
|
14
|
+
<span
|
|
15
|
+
css={style}
|
|
16
|
+
class={
|
|
17
|
+
'initials-square' +
|
|
18
|
+
(isBig ? ' is-big' : '') +
|
|
19
|
+
(isMedium ? ' is-medium' : '') +
|
|
20
|
+
(isSmall ? ' is-small' : '') +
|
|
21
|
+
(isRound ? ' is-round' : '') +
|
|
22
|
+
(icon ? '' : ' is-empty') +
|
|
23
|
+
' nitro-initials' +
|
|
24
|
+
(className ? ' ' + className : '')
|
|
25
|
+
}
|
|
26
|
+
style={icon ? {backgroundColor: icon?.hex + '15', color: icon?.hex} : {}}
|
|
27
|
+
>
|
|
28
|
+
{icon?.initials}
|
|
29
|
+
</span>
|
|
30
|
+
)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const style = css`
|
|
34
|
+
// seen in input.jsx
|
|
35
|
+
display: flex;
|
|
36
|
+
align-items: center;
|
|
37
|
+
justify-content: center;
|
|
38
|
+
border-radius: 5px;
|
|
39
|
+
font-weight: 700;
|
|
40
|
+
font-size: 11px;
|
|
41
|
+
width: 24px;
|
|
42
|
+
height: 24px;
|
|
43
|
+
// new
|
|
44
|
+
&.is-medium {
|
|
45
|
+
width: 30px;
|
|
46
|
+
height: 30px;
|
|
47
|
+
font-size: 12px;
|
|
48
|
+
}
|
|
49
|
+
// seen in select.jsx
|
|
50
|
+
&.is-small {
|
|
51
|
+
width: 22px;
|
|
52
|
+
height: 22px;
|
|
53
|
+
font-size: 11px;
|
|
54
|
+
}
|
|
55
|
+
&.is-big {
|
|
56
|
+
width: 48px;
|
|
57
|
+
height: 48px;
|
|
58
|
+
font-size: 14px;
|
|
59
|
+
}
|
|
60
|
+
&.is-round {
|
|
61
|
+
border-radius: 50%;
|
|
62
|
+
}
|
|
63
|
+
&.is-empty {
|
|
64
|
+
width: 0;
|
|
65
|
+
}
|
|
66
|
+
`
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// Todo: show correct message type, e.g. error, warning, info, success `${store.message.type || 'success'}`
|
|
2
|
+
import { isObject, isString, queryObject } from 'nitro-web/util'
|
|
3
|
+
import { X, CircleCheck } from 'lucide-react'
|
|
4
|
+
import { MessageObject } from 'nitro-web/types'
|
|
5
|
+
import { twMerge } from 'nitro-web'
|
|
6
|
+
|
|
7
|
+
type MessageProps = {
|
|
8
|
+
className?: string
|
|
9
|
+
classNameWrapper?: string
|
|
10
|
+
position?: 'top-left' | 'top-center' | 'top-right' | 'bottom-left' | 'bottom-center' | 'bottom-right'
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Shows a message
|
|
14
|
+
* Triggered by navigating to a link with a valid query string, or by setting store.message to a string or more explicitly, to an object
|
|
15
|
+
**/
|
|
16
|
+
export function Message({ className, classNameWrapper, position='top-right' }: MessageProps) {
|
|
17
|
+
const devDontHide = false
|
|
18
|
+
const [store, setStore] = useTracked()
|
|
19
|
+
const [visible, setVisible] = useState(false)
|
|
20
|
+
const location = useLocation()
|
|
21
|
+
const messageQueryMap = {
|
|
22
|
+
'added': { type: 'success', text: 'Added successfully 👍️' },
|
|
23
|
+
'created': { type: 'success', text: 'Created successfully 👍️' },
|
|
24
|
+
'error': { type: 'error', text: 'Sorry, there was an error' },
|
|
25
|
+
'oauth-error': { type: 'error', text: 'There was an error trying to signin, please try again' },
|
|
26
|
+
'removed': { type: 'success', text: 'Removed' },
|
|
27
|
+
'signin': { type: 'error', text: 'Please sign in to access this page' },
|
|
28
|
+
'updated': { type: 'success', text: 'Updated successfully' },
|
|
29
|
+
'unauth': { type: 'error', text: 'You are unauthorised' },
|
|
30
|
+
}
|
|
31
|
+
const colorMap = {
|
|
32
|
+
'error': 'text-danger',
|
|
33
|
+
'warning': 'text-warning',
|
|
34
|
+
'info': 'text-info',
|
|
35
|
+
'success': 'text-success',
|
|
36
|
+
}
|
|
37
|
+
const positionMap = {
|
|
38
|
+
'top-left': ['sm:items-start sm:justify-start', 'sm:translate-y-0 sm:translate-x-[-0.5rem]'],
|
|
39
|
+
'top-center': ['sm:items-start sm:justify-center', 'sm:translate-y-[-0.5rem]'],
|
|
40
|
+
'top-right': ['sm:items-start sm:justify-end', 'sm:translate-y-0 sm:translate-x-1'],
|
|
41
|
+
'bottom-left': ['sm:items-end sm:justify-start', 'sm:translate-y-0 sm:translate-x-[-0.5rem]'],
|
|
42
|
+
'bottom-center': ['sm:items-end sm:justify-center', 'sm:translate-y-1'],
|
|
43
|
+
'bottom-right': ['sm:items-end sm:justify-end', 'sm:translate-y-0 sm:translate-x-1'],
|
|
44
|
+
}
|
|
45
|
+
const color = colorMap[(store.message as MessageObject)?.type || 'success']
|
|
46
|
+
const positionArr = positionMap[(position as keyof typeof positionMap)]
|
|
47
|
+
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
return () => {
|
|
50
|
+
setStore(s => ({ ...s, message: '' }))
|
|
51
|
+
}
|
|
52
|
+
}, [])
|
|
53
|
+
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
// Finds a message in a query string and show it
|
|
56
|
+
let message
|
|
57
|
+
const query = queryObject(location.search, true)
|
|
58
|
+
for (const key in query) {
|
|
59
|
+
if (!query.hasOwnProperty(key)) continue
|
|
60
|
+
for (const key2 in messageQueryMap) {
|
|
61
|
+
if (key != key2) continue
|
|
62
|
+
// @ts-expect-error
|
|
63
|
+
message = { ...messageQueryMap[key] }
|
|
64
|
+
if (query[key] !== true) message.text = decodeURIComponent(query[key])
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (message) setStore(s => ({ ...s, message: message }))
|
|
68
|
+
}, [location.search])
|
|
69
|
+
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
// Message detection and autohiding
|
|
72
|
+
const now = new Date().getTime()
|
|
73
|
+
const messageObject = store.message as MessageObject
|
|
74
|
+
|
|
75
|
+
if (!store.message) {
|
|
76
|
+
return
|
|
77
|
+
// Convert a string into a message object
|
|
78
|
+
} else if (isString(store.message)) {
|
|
79
|
+
setStore(s => ({ ...s, message: { type: 'success', text: store.message as string, date: now }}))
|
|
80
|
+
// Add a date to the message
|
|
81
|
+
} else if (!messageObject.date) {
|
|
82
|
+
setStore(s => ({ ...s, message: { ...messageObject, date: now }}))
|
|
83
|
+
// Show message and hide it again after some time. Send back cleanup if store.message changes
|
|
84
|
+
} else if (messageObject && now - 500 < messageObject.date) {
|
|
85
|
+
const timeout1 = setTimeout(() => setVisible(true), 50)
|
|
86
|
+
if (messageObject.timeout !== 0 && !devDontHide) var timeout2 = setTimeout(hide, messageObject.timeout || 5000)
|
|
87
|
+
return () => {
|
|
88
|
+
clearTimeout(timeout1)
|
|
89
|
+
clearTimeout(timeout2)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}, [JSON.stringify(store.message)])
|
|
93
|
+
|
|
94
|
+
function hide() {
|
|
95
|
+
setVisible(false)
|
|
96
|
+
setTimeout(() => setStore(s => ({ ...s, message: undefined })), 250)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return (
|
|
100
|
+
<>
|
|
101
|
+
{/* Global notification live region, render this permanently at the end of the document */}
|
|
102
|
+
<div
|
|
103
|
+
aria-live="assertive"
|
|
104
|
+
className={`${twMerge(`pointer-events-none items-end justify-center fixed inset-0 flex px-4 py-6 sm:p-6 z-[101] nitro-message ${positionArr[0]} ${classNameWrapper || ''}`)}`}
|
|
105
|
+
>
|
|
106
|
+
<div className="flex flex-col items-center space-y-4">
|
|
107
|
+
{isObject(store.message) && (
|
|
108
|
+
<div className={twMerge(
|
|
109
|
+
'overflow-hidden translate-y-[0.5rem] opacity-0 pointer-events-auto max-w-[350px] rounded-md bg-white shadow-lg ring-1 ring-black/5 transition text-sm font-medium text-gray-900',
|
|
110
|
+
positionArr[1],
|
|
111
|
+
(visible ? 'translate-x-0 translate-y-0 sm:translate-x-0 sm:translate-y-0 opacity-100' : ''),
|
|
112
|
+
className
|
|
113
|
+
)}>
|
|
114
|
+
<div className="p-3">
|
|
115
|
+
<div className="flex items-start gap-3 leading-[1.4em]">
|
|
116
|
+
<div className="flex items-center shrink-0 min-h-[1.4em]">
|
|
117
|
+
<CircleCheck aria-hidden="true" size={19} className={`${color}`} />
|
|
118
|
+
</div>
|
|
119
|
+
<div className="flex flex-1 items-center min-h-[1.4em]">
|
|
120
|
+
<p>{typeof store.message === 'object' && store.message?.text}</p>
|
|
121
|
+
</div>
|
|
122
|
+
<div className="flex items-center shrink-0 min-h-[1.4em]">
|
|
123
|
+
<button
|
|
124
|
+
type="button"
|
|
125
|
+
onClick={hide}
|
|
126
|
+
className="inline-flex rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2
|
|
127
|
+
focus:ring-indigo-500 focus:ring-offset-2"
|
|
128
|
+
>
|
|
129
|
+
<span className="sr-only">Close</span>
|
|
130
|
+
<X aria-hidden="true" size={19} />
|
|
131
|
+
</button>
|
|
132
|
+
</div>
|
|
133
|
+
</div>
|
|
134
|
+
</div>
|
|
135
|
+
</div>
|
|
136
|
+
)}
|
|
137
|
+
</div>
|
|
138
|
+
</div>
|
|
139
|
+
</>
|
|
140
|
+
)
|
|
141
|
+
}
|