-
Notifications
You must be signed in to change notification settings - Fork 82
/
misc.tsx
312 lines (289 loc) · 8.42 KB
/
misc.tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
import { useFormAction, useNavigation } from '@remix-run/react'
import { clsx, type ClassValue } from 'clsx'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useSpinDelay } from 'spin-delay'
import { twMerge } from 'tailwind-merge'
import userFallback from '#app/assets/user.png'
export function getUserImgSrc(imageId?: string | null) {
return imageId ? `/resources/user-images/${imageId}` : userFallback
}
export function getNoteImgSrc(imageId: string) {
return `/resources/note-images/${imageId}`
}
export function getErrorMessage(error: unknown) {
if (typeof error === 'string') return error
if (
error &&
typeof error === 'object' &&
'message' in error &&
typeof error.message === 'string'
) {
return error.message
}
console.error('Unable to get error message for error', error)
return 'Unknown Error'
}
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
export function getDomainUrl(request: Request) {
const host =
request.headers.get('X-Forwarded-Host') ?? request.headers.get('host')
if (!host) {
throw new Error('Could not determine domain URL.')
}
const protocol = host.includes('localhost') ? 'http' : 'https'
return `${protocol}://${host}`
}
export function getReferrerRoute(request: Request) {
// spelling errors and whatever makes this annoyingly inconsistent
// in my own testing, `referer` returned the right value, but 🤷♂️
const referrer =
request.headers.get('referer') ??
request.headers.get('referrer') ??
request.referrer
const domain = getDomainUrl(request)
if (referrer?.startsWith(domain)) {
return referrer.slice(domain.length)
} else {
return '/'
}
}
/**
* Merge multiple headers objects into one (uses set so headers are overridden)
*/
export function mergeHeaders(
...headers: Array<ResponseInit['headers'] | null>
) {
const merged = new Headers()
for (const header of headers) {
if (!header) continue
for (const [key, value] of new Headers(header).entries()) {
merged.set(key, value)
}
}
return merged
}
/**
* Combine multiple header objects into one (uses append so headers are not overridden)
*/
export function combineHeaders(
...headers: Array<ResponseInit['headers'] | null>
) {
const combined = new Headers()
for (const header of headers) {
if (!header) continue
for (const [key, value] of new Headers(header).entries()) {
combined.append(key, value)
}
}
return combined
}
/**
* Combine multiple response init objects into one (uses combineHeaders)
*/
export function combineResponseInits(
...responseInits: Array<ResponseInit | undefined>
) {
let combined: ResponseInit = {}
for (const responseInit of responseInits) {
combined = {
...responseInit,
headers: combineHeaders(combined.headers, responseInit?.headers),
}
}
return combined
}
/**
* Provide a condition and if that condition is falsey, this throws an error
* with the given message.
*
* inspired by invariant from 'tiny-invariant' except will still include the
* message in production.
*
* @example
* invariant(typeof value === 'string', `value must be a string`)
*
* @param condition The condition to check
* @param message The message to throw (or a callback to generate the message)
* @param responseInit Additional response init options if a response is thrown
*
* @throws {Error} if condition is falsey
*/
export function invariant(
condition: any,
message: string | (() => string),
): asserts condition {
if (!condition) {
throw new Error(typeof message === 'function' ? message() : message)
}
}
/**
* Provide a condition and if that condition is falsey, this throws a 400
* Response with the given message.
*
* inspired by invariant from 'tiny-invariant'
*
* @example
* invariantResponse(typeof value === 'string', `value must be a string`)
*
* @param condition The condition to check
* @param message The message to throw (or a callback to generate the message)
* @param responseInit Additional response init options if a response is thrown
*
* @throws {Response} if condition is falsey
*/
export function invariantResponse(
condition: any,
message: string | (() => string),
responseInit?: ResponseInit,
): asserts condition {
if (!condition) {
throw new Response(typeof message === 'function' ? message() : message, {
status: 400,
...responseInit,
})
}
}
/**
* Returns true if the current navigation is submitting the current route's
* form. Defaults to the current route's form action and method POST.
*
* Defaults state to 'non-idle'
*
* NOTE: the default formAction will include query params, but the
* navigation.formAction will not, so don't use the default formAction if you
* want to know if a form is submitting without specific query params.
*/
export function useIsPending({
formAction,
formMethod = 'POST',
state = 'non-idle',
}: {
formAction?: string
formMethod?: 'POST' | 'GET' | 'PUT' | 'PATCH' | 'DELETE'
state?: 'submitting' | 'loading' | 'non-idle'
} = {}) {
const contextualFormAction = useFormAction()
const navigation = useNavigation()
const isPendingState =
state === 'non-idle'
? navigation.state !== 'idle'
: navigation.state === state
return (
isPendingState &&
navigation.formAction === (formAction ?? contextualFormAction) &&
navigation.formMethod === formMethod
)
}
/**
* This combines useSpinDelay (from https://npm.im/spin-delay) and useIsPending
* from our own utilities to give you a nice way to show a loading spinner for
* a minimum amount of time, even if the request finishes right after the delay.
*
* This avoids a flash of loading state regardless of how fast or slow the
* request is.
*/
export function useDelayedIsPending({
formAction,
formMethod,
delay = 400,
minDuration = 300,
}: Parameters<typeof useIsPending>[0] &
Parameters<typeof useSpinDelay>[1] = {}) {
const isPending = useIsPending({ formAction, formMethod })
const delayedIsPending = useSpinDelay(isPending, {
delay,
minDuration,
})
return delayedIsPending
}
function callAll<Args extends Array<unknown>>(
...fns: Array<((...args: Args) => unknown) | undefined>
) {
return (...args: Args) => fns.forEach(fn => fn?.(...args))
}
/**
* Use this hook with a button and it will make it so the first click sets a
* `doubleCheck` state to true, and the second click will actually trigger the
* `onClick` handler. This allows you to have a button that can be like a
* "are you sure?" experience for the user before doing destructive operations.
*/
export function useDoubleCheck() {
const [doubleCheck, setDoubleCheck] = useState(false)
function getButtonProps(
props?: React.ButtonHTMLAttributes<HTMLButtonElement>,
) {
const onBlur: React.ButtonHTMLAttributes<HTMLButtonElement>['onBlur'] =
() => setDoubleCheck(false)
const onClick: React.ButtonHTMLAttributes<HTMLButtonElement>['onClick'] =
doubleCheck
? undefined
: e => {
e.preventDefault()
setDoubleCheck(true)
}
const onKeyUp: React.ButtonHTMLAttributes<HTMLButtonElement>['onKeyUp'] =
e => {
if (e.key === 'Escape') {
setDoubleCheck(false)
}
}
return {
...props,
onBlur: callAll(onBlur, props?.onBlur),
onClick: callAll(onClick, props?.onClick),
onKeyUp: callAll(onKeyUp, props?.onKeyUp),
}
}
return { doubleCheck, getButtonProps }
}
/**
* Simple debounce implementation
*/
function debounce<Callback extends (...args: Parameters<Callback>) => void>(
fn: Callback,
delay: number,
) {
let timer: ReturnType<typeof setTimeout> | null = null
return (...args: Parameters<Callback>) => {
if (timer) clearTimeout(timer)
timer = setTimeout(() => {
fn(...args)
}, delay)
}
}
/**
* Debounce a callback function
*/
export function useDebounce<
Callback extends (...args: Parameters<Callback>) => ReturnType<Callback>,
>(callback: Callback, delay: number) {
const callbackRef = useRef(callback)
useEffect(() => {
callbackRef.current = callback
})
return useMemo(
() =>
debounce(
(...args: Parameters<Callback>) => callbackRef.current(...args),
delay,
),
[delay],
)
}
export async function downloadFile(url: string, retries: number = 0) {
const MAX_RETRIES = 3
try {
const response = await fetch(url)
if (!response.ok) {
throw new Error(`Failed to fetch image with status ${response.status}`)
}
const contentType = response.headers.get('content-type') ?? 'image/jpg'
const blob = Buffer.from(await response.arrayBuffer())
return { contentType, blob }
} catch (e) {
if (retries > MAX_RETRIES) throw e
return downloadFile(url, retries + 1)
}
}