lazyconvex 0.0.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 +926 -0
- package/dist/components/index.mjs +937 -0
- package/dist/error-D4GuI0ot.mjs +71 -0
- package/dist/file-field-BqVgy8xY.mjs +205 -0
- package/dist/form-BXJK_j10.d.mts +99 -0
- package/dist/index.d.mts +433 -0
- package/dist/index.mjs +1 -0
- package/dist/index2.d.mts +5 -0
- package/dist/index3.d.mts +35 -0
- package/dist/index4.d.mts +101 -0
- package/dist/index5.d.mts +842 -0
- package/dist/next/index.mjs +151 -0
- package/dist/org-CmJBb8z-.d.mts +56 -0
- package/dist/react/index.mjs +158 -0
- package/dist/retry.d.mts +12 -0
- package/dist/retry.mjs +35 -0
- package/dist/schema.d.mts +23 -0
- package/dist/schema.mjs +15 -0
- package/dist/server/index.mjs +2572 -0
- package/dist/types-DWBVRtit.d.mts +322 -0
- package/dist/use-online-status-CMr73Jlk.mjs +155 -0
- package/dist/use-upload-DtELytQi.mjs +95 -0
- package/dist/zod.d.mts +18 -0
- package/dist/zod.mjs +87 -0
- package/package.json +40 -0
- package/src/components/editors-section.tsx +86 -0
- package/src/components/fields.tsx +884 -0
- package/src/components/file-field.tsx +234 -0
- package/src/components/form.tsx +191 -0
- package/src/components/index.ts +11 -0
- package/src/components/offline-indicator.tsx +15 -0
- package/src/components/org-avatar.tsx +13 -0
- package/src/components/permission-guard.tsx +36 -0
- package/src/components/role-badge.tsx +14 -0
- package/src/components/suspense-wrap.tsx +8 -0
- package/src/index.ts +40 -0
- package/src/next/active-org.ts +33 -0
- package/src/next/auth.ts +9 -0
- package/src/next/image.ts +134 -0
- package/src/next/index.ts +3 -0
- package/src/react/form-meta.ts +53 -0
- package/src/react/form.ts +201 -0
- package/src/react/index.ts +8 -0
- package/src/react/org.tsx +96 -0
- package/src/react/use-active-org.ts +48 -0
- package/src/react/use-bulk-selection.ts +47 -0
- package/src/react/use-online-status.ts +21 -0
- package/src/react/use-optimistic.ts +54 -0
- package/src/react/use-upload.ts +101 -0
- package/src/retry.ts +47 -0
- package/src/schema.ts +30 -0
- package/src/server/cache-crud.ts +175 -0
- package/src/server/check-schema.ts +29 -0
- package/src/server/child.ts +98 -0
- package/src/server/crud.ts +384 -0
- package/src/server/db.ts +7 -0
- package/src/server/error.ts +39 -0
- package/src/server/file.ts +372 -0
- package/src/server/helpers.ts +214 -0
- package/src/server/index.ts +12 -0
- package/src/server/org-crud.ts +307 -0
- package/src/server/org-helpers.ts +54 -0
- package/src/server/org.ts +572 -0
- package/src/server/schema-helpers.ts +107 -0
- package/src/server/setup.ts +138 -0
- package/src/server/test-crud.ts +211 -0
- package/src/server/test.ts +554 -0
- package/src/server/types.ts +392 -0
- package/src/server/unique.ts +28 -0
- package/src/zod.ts +141 -0
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
'use server'
|
|
2
|
+
import type { FunctionReference } from 'convex/server'
|
|
3
|
+
import type { NextRequest } from 'next/server'
|
|
4
|
+
import type { Sharp } from 'sharp'
|
|
5
|
+
|
|
6
|
+
import { ConvexHttpClient } from 'convex/browser'
|
|
7
|
+
import { NextResponse } from 'next/server'
|
|
8
|
+
import sharp from 'sharp'
|
|
9
|
+
|
|
10
|
+
type Format = 'jpeg' | 'png' | 'webp'
|
|
11
|
+
interface FormatOpts {
|
|
12
|
+
contentType: string
|
|
13
|
+
format: Format | undefined
|
|
14
|
+
quality: number
|
|
15
|
+
}
|
|
16
|
+
interface ImageRouteConfig {
|
|
17
|
+
convexUrl: string
|
|
18
|
+
fileInfoQuery?: string
|
|
19
|
+
}
|
|
20
|
+
interface ProcessOptions {
|
|
21
|
+
compress?: { quality?: number }
|
|
22
|
+
format?: Format
|
|
23
|
+
resize?: { fit?: 'contain' | 'cover' | 'fill' | 'inside' | 'outside'; height?: number; width?: number }
|
|
24
|
+
}
|
|
25
|
+
interface TransformOpts {
|
|
26
|
+
contentType: string
|
|
27
|
+
options: ProcessOptions | undefined
|
|
28
|
+
pipeline: Sharp
|
|
29
|
+
thumbnail: boolean
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const IMAGE_TYPES = new Set(['image/gif', 'image/jpeg', 'image/png', 'image/svg+xml', 'image/webp']),
|
|
33
|
+
isImageType = (contentType: string): boolean => IMAGE_TYPES.has(contentType),
|
|
34
|
+
formatToMime: Record<Format, string> = {
|
|
35
|
+
jpeg: 'image/jpeg',
|
|
36
|
+
png: 'image/png',
|
|
37
|
+
webp: 'image/webp'
|
|
38
|
+
},
|
|
39
|
+
applyFormat = ({ contentType, format, pipeline, quality }: FormatOpts & { pipeline: Sharp }): Sharp => {
|
|
40
|
+
if (format === 'jpeg') return pipeline.jpeg({ quality })
|
|
41
|
+
if (format === 'png') return pipeline.png({ quality })
|
|
42
|
+
if (format === 'webp') return pipeline.webp({ quality })
|
|
43
|
+
const [, ext] = contentType.split('/')
|
|
44
|
+
if (ext === 'jpeg' || ext === 'jpg') return pipeline.jpeg({ quality })
|
|
45
|
+
if (ext === 'png') return pipeline.png({ quality })
|
|
46
|
+
if (ext === 'webp') return pipeline.webp({ quality })
|
|
47
|
+
return pipeline
|
|
48
|
+
},
|
|
49
|
+
applyTransforms = ({ contentType, options, pipeline, thumbnail }: TransformOpts): Sharp => {
|
|
50
|
+
const quality = options?.compress?.quality ?? 80
|
|
51
|
+
if (thumbnail) return pipeline.resize({ fit: 'cover', height: 200, width: 200 }).webp({ quality: 80 })
|
|
52
|
+
let result = pipeline
|
|
53
|
+
if (options?.resize)
|
|
54
|
+
result = result.resize({
|
|
55
|
+
fit: options.resize.fit ?? 'cover',
|
|
56
|
+
height: options.resize.height,
|
|
57
|
+
width: options.resize.width
|
|
58
|
+
})
|
|
59
|
+
if (options?.format || options?.compress)
|
|
60
|
+
result = applyFormat({ contentType, format: options.format, pipeline: result, quality })
|
|
61
|
+
return result
|
|
62
|
+
},
|
|
63
|
+
fetchImage = async ({
|
|
64
|
+
client,
|
|
65
|
+
queryRef,
|
|
66
|
+
storageId
|
|
67
|
+
}: {
|
|
68
|
+
client: ConvexHttpClient
|
|
69
|
+
queryRef: string
|
|
70
|
+
storageId: string
|
|
71
|
+
}): Promise<{ buffer: Buffer; contentType: string } | { error: string; status: number }> => {
|
|
72
|
+
const info = (await client.query(queryRef as unknown as FunctionReference<'query'>, { id: storageId })) as null | {
|
|
73
|
+
url: string
|
|
74
|
+
},
|
|
75
|
+
url = info?.url
|
|
76
|
+
if (!url) return { error: 'File not found', status: 404 }
|
|
77
|
+
const response = await fetch(url)
|
|
78
|
+
if (!response.ok) return { error: 'Failed to fetch image', status: 500 }
|
|
79
|
+
const contentType = response.headers.get('content-type') ?? ''
|
|
80
|
+
if (!isImageType(contentType)) return { error: 'Not an image file', status: 400 }
|
|
81
|
+
return { buffer: Buffer.from(await response.arrayBuffer()), contentType }
|
|
82
|
+
},
|
|
83
|
+
makeGet =
|
|
84
|
+
({ getClient, queryRef }: { getClient: () => ConvexHttpClient; queryRef: string }) =>
|
|
85
|
+
async (req: NextRequest): Promise<NextResponse> => {
|
|
86
|
+
try {
|
|
87
|
+
const storageId = req.nextUrl.searchParams.get('id')
|
|
88
|
+
if (!storageId) return NextResponse.json({ error: 'id is required' }, { status: 400 })
|
|
89
|
+
const result = await fetchImage({ client: getClient(), queryRef, storageId })
|
|
90
|
+
if ('error' in result) return NextResponse.json({ error: result.error }, { status: result.status })
|
|
91
|
+
return new NextResponse(new Uint8Array(result.buffer), {
|
|
92
|
+
headers: { 'Cache-Control': 'public, max-age=31536000, immutable', 'Content-Type': result.contentType }
|
|
93
|
+
})
|
|
94
|
+
} catch (error) {
|
|
95
|
+
return NextResponse.json(
|
|
96
|
+
{ error: error instanceof Error ? error.message : 'Failed to fetch image' },
|
|
97
|
+
{ status: 500 }
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
makePost =
|
|
102
|
+
({ getClient, queryRef }: { getClient: () => ConvexHttpClient; queryRef: string }) =>
|
|
103
|
+
async (req: NextRequest): Promise<NextResponse> => {
|
|
104
|
+
try {
|
|
105
|
+
const body = (await req.json()) as { options?: ProcessOptions; storageId: string; thumbnail?: boolean },
|
|
106
|
+
{ options, storageId, thumbnail } = body
|
|
107
|
+
if (!storageId) return NextResponse.json({ error: 'storageId is required' }, { status: 400 })
|
|
108
|
+
const result = await fetchImage({ client: getClient(), queryRef, storageId })
|
|
109
|
+
if ('error' in result) return NextResponse.json({ error: result.error }, { status: result.status })
|
|
110
|
+
const { buffer, contentType } = result,
|
|
111
|
+
pipeline = applyTransforms({
|
|
112
|
+
contentType,
|
|
113
|
+
options,
|
|
114
|
+
pipeline: sharp(buffer),
|
|
115
|
+
thumbnail: thumbnail ?? false
|
|
116
|
+
}),
|
|
117
|
+
outputBuffer = await pipeline.toBuffer(),
|
|
118
|
+
outputMime = thumbnail ? 'image/webp' : options?.format ? formatToMime[options.format] : contentType
|
|
119
|
+
return new NextResponse(new Uint8Array(outputBuffer), {
|
|
120
|
+
headers: { 'Cache-Control': 'public, max-age=31536000, immutable', 'Content-Type': outputMime }
|
|
121
|
+
})
|
|
122
|
+
} catch (error) {
|
|
123
|
+
return NextResponse.json({ error: error instanceof Error ? error.message : 'Processing failed' }, { status: 500 })
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
/* eslint-disable @typescript-eslint/promise-function-async, @typescript-eslint/require-await */
|
|
127
|
+
makeImageRoute = async ({ convexUrl, fileInfoQuery = 'file:info' }: ImageRouteConfig) => {
|
|
128
|
+
const getClient = () => new ConvexHttpClient(convexUrl),
|
|
129
|
+
opts = { getClient, queryRef: fileInfoQuery }
|
|
130
|
+
return { GET: makeGet(opts), POST: makePost(opts) }
|
|
131
|
+
}
|
|
132
|
+
/* eslint-enable @typescript-eslint/promise-function-async, @typescript-eslint/require-await */
|
|
133
|
+
|
|
134
|
+
export { makeImageRoute }
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/* eslint-disable max-statements */
|
|
2
|
+
import type { ZodObject, ZodRawShape } from 'zod/v4'
|
|
3
|
+
|
|
4
|
+
import type { ZodSchema } from '../zod'
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
cvFileKindOf,
|
|
8
|
+
elementOf,
|
|
9
|
+
isArrayType,
|
|
10
|
+
isBooleanType,
|
|
11
|
+
isDateType,
|
|
12
|
+
isNumberType,
|
|
13
|
+
isStringType,
|
|
14
|
+
unwrapZod
|
|
15
|
+
} from '../zod'
|
|
16
|
+
|
|
17
|
+
type FieldKind = 'boolean' | 'date' | 'file' | 'files' | 'number' | 'string' | 'stringArray' | 'unknown'
|
|
18
|
+
interface FieldMeta {
|
|
19
|
+
kind: FieldKind
|
|
20
|
+
max?: number
|
|
21
|
+
}
|
|
22
|
+
type FieldMetaMap = Record<string, FieldMeta>
|
|
23
|
+
|
|
24
|
+
const getMax = (schema: undefined | ZodSchema): number | undefined => {
|
|
25
|
+
const checks = schema?.def.checks as (undefined | { _zod: { def: { check: string; maximum?: number } } })[] | undefined
|
|
26
|
+
if (checks)
|
|
27
|
+
for (const c of checks)
|
|
28
|
+
if (c?._zod.def.check === 'max_length' && c._zod.def.maximum !== undefined) return c._zod.def.maximum
|
|
29
|
+
},
|
|
30
|
+
getMeta = (s: unknown): FieldMeta => {
|
|
31
|
+
const { schema: base, type } = unwrapZod(s),
|
|
32
|
+
fk = cvFileKindOf(s)
|
|
33
|
+
if (fk === 'file') return { kind: 'file' }
|
|
34
|
+
if (fk === 'files') return { kind: 'files', max: getMax(base) }
|
|
35
|
+
if (isArrayType(type)) {
|
|
36
|
+
const el = unwrapZod(elementOf(base))
|
|
37
|
+
return { kind: isStringType(el.type) ? 'stringArray' : 'unknown', max: getMax(base) }
|
|
38
|
+
}
|
|
39
|
+
if (isStringType(type)) return { kind: 'string' }
|
|
40
|
+
if (isNumberType(type)) return { kind: 'number' }
|
|
41
|
+
if (isBooleanType(type)) return { kind: 'boolean' }
|
|
42
|
+
if (isDateType(type)) return { kind: 'date' }
|
|
43
|
+
return { kind: 'unknown' }
|
|
44
|
+
},
|
|
45
|
+
buildMeta = (s: ZodObject<ZodRawShape>): FieldMetaMap => {
|
|
46
|
+
const m: FieldMetaMap = {}
|
|
47
|
+
// biome-ignore lint/nursery/noForIn: x
|
|
48
|
+
for (const k in s.shape) if (Object.hasOwn(s.shape, k)) m[k] = getMeta(s.shape[k])
|
|
49
|
+
return m
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export type { FieldKind, FieldMeta, FieldMetaMap }
|
|
53
|
+
export { buildMeta, getMeta }
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/* eslint-disable react-hooks/rules-of-hooks */
|
|
2
|
+
// biome-ignore-all lint/correctness/useHookAtTopLevel: watch hook is called inside component render context
|
|
3
|
+
'use client'
|
|
4
|
+
import type { StandardSchemaV1 } from '@tanstack/form-core'
|
|
5
|
+
import type { FormValidateOrFn, ReactFormExtendedApi } from '@tanstack/react-form'
|
|
6
|
+
import type { FunctionReference } from 'convex/server'
|
|
7
|
+
import type { output, ZodObject, ZodRawShape } from 'zod/v4'
|
|
8
|
+
|
|
9
|
+
import { useForm as useTanStackForm } from '@tanstack/react-form'
|
|
10
|
+
import { useStore } from '@tanstack/react-store'
|
|
11
|
+
import { useMutation } from 'convex/react'
|
|
12
|
+
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
13
|
+
|
|
14
|
+
import type { FieldMetaMap } from './form-meta'
|
|
15
|
+
|
|
16
|
+
import { getErrorCode, getErrorMessage, isRecord } from '../server/error'
|
|
17
|
+
import { coerceOptionals, defaultValues as dv } from '../zod'
|
|
18
|
+
import { buildMeta } from './form-meta'
|
|
19
|
+
|
|
20
|
+
type Api<T extends Record<string, unknown>> = ReactFormExtendedApi<
|
|
21
|
+
T,
|
|
22
|
+
undefined,
|
|
23
|
+
undefined,
|
|
24
|
+
undefined,
|
|
25
|
+
undefined,
|
|
26
|
+
undefined,
|
|
27
|
+
FormValidateOrFn<T>,
|
|
28
|
+
undefined,
|
|
29
|
+
undefined,
|
|
30
|
+
undefined,
|
|
31
|
+
undefined,
|
|
32
|
+
unknown
|
|
33
|
+
>
|
|
34
|
+
|
|
35
|
+
interface ConflictData {
|
|
36
|
+
code: string
|
|
37
|
+
current?: unknown
|
|
38
|
+
incoming?: unknown
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface FormReturn<T extends Record<string, unknown>, S extends ZodObject<ZodRawShape>> {
|
|
42
|
+
conflict: ConflictData | null
|
|
43
|
+
error: Error | null
|
|
44
|
+
instance: Api<T>
|
|
45
|
+
isDirty: boolean
|
|
46
|
+
isPending: boolean
|
|
47
|
+
lastSaved: null | number
|
|
48
|
+
meta: FieldMetaMap
|
|
49
|
+
reset: (values?: T) => void
|
|
50
|
+
resolveConflict: (action: 'cancel' | 'overwrite' | 'reload') => void
|
|
51
|
+
schema: S
|
|
52
|
+
watch: <K extends keyof T>(name: K) => T[K]
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const submitError = (e: unknown): Error => new Error(getErrorMessage(e), { cause: e }),
|
|
56
|
+
handleConflict = (error: unknown): ConflictData | null => {
|
|
57
|
+
if (getErrorCode(error) !== 'CONFLICT') return null
|
|
58
|
+
const { data } = error as { data?: { current?: unknown; incoming?: unknown } }
|
|
59
|
+
return {
|
|
60
|
+
code: 'CONFLICT',
|
|
61
|
+
current: data?.current,
|
|
62
|
+
incoming: data?.incoming
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
useForm = <S extends ZodObject<ZodRawShape>>({
|
|
66
|
+
autoSave,
|
|
67
|
+
onConflict,
|
|
68
|
+
onError,
|
|
69
|
+
onSubmit,
|
|
70
|
+
onSuccess,
|
|
71
|
+
resetOnSuccess,
|
|
72
|
+
schema,
|
|
73
|
+
values
|
|
74
|
+
}: {
|
|
75
|
+
autoSave?: { debounceMs: number; enabled: boolean }
|
|
76
|
+
onConflict?: (data: ConflictData) => void
|
|
77
|
+
onError?: (e: unknown) => void
|
|
78
|
+
onSubmit: (d: output<S>, force?: boolean) => output<S> | Promise<output<S> | undefined> | undefined
|
|
79
|
+
onSuccess?: () => void
|
|
80
|
+
resetOnSuccess?: boolean
|
|
81
|
+
schema: S
|
|
82
|
+
values?: output<S>
|
|
83
|
+
}) => {
|
|
84
|
+
const resolved = values ?? dv(schema),
|
|
85
|
+
[conflict, setConflict] = useState<ConflictData | null>(null),
|
|
86
|
+
[er, setEr] = useState<Error | null>(null),
|
|
87
|
+
[forceSubmit, setForceSubmit] = useState(false),
|
|
88
|
+
[lastSaved, setLastSaved] = useState<null | number>(null),
|
|
89
|
+
vRef = useRef(resolved),
|
|
90
|
+
autoSaveTimerRef = useRef<NodeJS.Timeout | null>(null)
|
|
91
|
+
|
|
92
|
+
vRef.current = resolved
|
|
93
|
+
if (Object.keys(resolved).some(k => !(k in schema.shape))) throw new Error('Form values include keys not in schema')
|
|
94
|
+
const meta = useMemo(() => buildMeta(schema), [schema]),
|
|
95
|
+
instance = useTanStackForm({
|
|
96
|
+
defaultValues: resolved,
|
|
97
|
+
// eslint-disable-next-line max-statements
|
|
98
|
+
onSubmit: async ({ value }) => {
|
|
99
|
+
setEr(null)
|
|
100
|
+
try {
|
|
101
|
+
const coerced = coerceOptionals(schema, value),
|
|
102
|
+
result = await onSubmit(coerced, forceSubmit),
|
|
103
|
+
newValues = resetOnSuccess && isRecord(result) ? result : value
|
|
104
|
+
instance.reset(newValues)
|
|
105
|
+
if (resetOnSuccess && isRecord(result)) vRef.current = result
|
|
106
|
+
setForceSubmit(false)
|
|
107
|
+
setLastSaved(Date.now())
|
|
108
|
+
onSuccess?.()
|
|
109
|
+
} catch (error) {
|
|
110
|
+
const conflictData = handleConflict(error)
|
|
111
|
+
if (conflictData) {
|
|
112
|
+
setConflict(conflictData)
|
|
113
|
+
onConflict?.(conflictData)
|
|
114
|
+
return
|
|
115
|
+
}
|
|
116
|
+
const err = submitError(error)
|
|
117
|
+
setEr(err)
|
|
118
|
+
onError?.(err)
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
validators: { onSubmit: schema as unknown as StandardSchemaV1<output<S>, unknown> }
|
|
122
|
+
}) as unknown as Api<output<S>>,
|
|
123
|
+
{ isDirty, isSubmitting } = useStore(instance.store, s => ({ isDirty: s.isDirty, isSubmitting: s.isSubmitting }))
|
|
124
|
+
|
|
125
|
+
useEffect(() => {
|
|
126
|
+
if (!(autoSave?.enabled && isDirty)) return
|
|
127
|
+
if (autoSaveTimerRef.current) clearTimeout(autoSaveTimerRef.current)
|
|
128
|
+
autoSaveTimerRef.current = setTimeout(() => {
|
|
129
|
+
instance.handleSubmit()
|
|
130
|
+
}, autoSave.debounceMs)
|
|
131
|
+
return () => {
|
|
132
|
+
if (autoSaveTimerRef.current) clearTimeout(autoSaveTimerRef.current)
|
|
133
|
+
}
|
|
134
|
+
}, [autoSave?.enabled, autoSave?.debounceMs, isDirty, instance])
|
|
135
|
+
return {
|
|
136
|
+
conflict,
|
|
137
|
+
error: er,
|
|
138
|
+
instance,
|
|
139
|
+
isDirty,
|
|
140
|
+
isPending: isSubmitting,
|
|
141
|
+
lastSaved,
|
|
142
|
+
meta,
|
|
143
|
+
reset: (vals?: output<S>) => {
|
|
144
|
+
const resetVals = vals ?? vRef.current
|
|
145
|
+
instance.reset(resetVals)
|
|
146
|
+
if (vals) vRef.current = vals
|
|
147
|
+
setEr(null)
|
|
148
|
+
setLastSaved(null)
|
|
149
|
+
},
|
|
150
|
+
resolveConflict: (action: 'cancel' | 'overwrite' | 'reload') => {
|
|
151
|
+
if (action === 'overwrite') {
|
|
152
|
+
setConflict(null)
|
|
153
|
+
setForceSubmit(true)
|
|
154
|
+
instance.handleSubmit()
|
|
155
|
+
} else if (action === 'reload') {
|
|
156
|
+
setConflict(null)
|
|
157
|
+
instance.reset(vRef.current)
|
|
158
|
+
} else setConflict(null)
|
|
159
|
+
},
|
|
160
|
+
schema,
|
|
161
|
+
watch: <K extends keyof output<S>>(name: K) =>
|
|
162
|
+
useStore(instance.store, s => s.values[name as string]) as output<S>[K]
|
|
163
|
+
} satisfies FormReturn<output<S>, S>
|
|
164
|
+
},
|
|
165
|
+
useFormMutation = <S extends ZodObject<ZodRawShape>>({
|
|
166
|
+
mutation: mutationRef,
|
|
167
|
+
onConflict,
|
|
168
|
+
onError,
|
|
169
|
+
onSuccess,
|
|
170
|
+
resetOnSuccess = true,
|
|
171
|
+
schema,
|
|
172
|
+
transform,
|
|
173
|
+
values
|
|
174
|
+
}: {
|
|
175
|
+
mutation: FunctionReference<'mutation'>
|
|
176
|
+
onConflict?: (data: ConflictData) => void
|
|
177
|
+
onError?: (e: unknown) => void
|
|
178
|
+
onSuccess?: () => void
|
|
179
|
+
resetOnSuccess?: boolean
|
|
180
|
+
schema: S
|
|
181
|
+
transform?: (d: output<S>) => Record<string, unknown>
|
|
182
|
+
values?: output<S>
|
|
183
|
+
}) => {
|
|
184
|
+
const mutate = useMutation(mutationRef)
|
|
185
|
+
return useForm({
|
|
186
|
+
onConflict,
|
|
187
|
+
onError,
|
|
188
|
+
onSubmit: async (d: output<S>) => {
|
|
189
|
+
const args = transform ? transform(d) : d
|
|
190
|
+
await mutate(args)
|
|
191
|
+
return d
|
|
192
|
+
},
|
|
193
|
+
onSuccess,
|
|
194
|
+
resetOnSuccess,
|
|
195
|
+
schema,
|
|
196
|
+
values
|
|
197
|
+
})
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export type { Api, ConflictData, FieldMetaMap, FormReturn }
|
|
201
|
+
export { buildMeta, useForm, useFormMutation }
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { useForm, useFormMutation } from './form'
|
|
2
|
+
export { buildMeta, getMeta } from './form-meta'
|
|
3
|
+
export { canEditResource, OrgProvider, useMyOrgs, useOrg, useOrgMutation, useOrgQuery } from './org'
|
|
4
|
+
export { setActiveOrgCookieClient, useActiveOrg } from './use-active-org'
|
|
5
|
+
export { useBulkSelection } from './use-bulk-selection'
|
|
6
|
+
export { default as useOnlineStatus } from './use-online-status'
|
|
7
|
+
export { useOptimisticMutation } from './use-optimistic'
|
|
8
|
+
export { default as useUpload } from './use-upload'
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-unnecessary-type-parameters, @typescript-eslint/no-unsafe-return */
|
|
2
|
+
'use client'
|
|
3
|
+
|
|
4
|
+
import type { FunctionReference } from 'convex/server'
|
|
5
|
+
import type { ReactNode } from 'react'
|
|
6
|
+
|
|
7
|
+
import { useMutation, useQuery } from 'convex/react'
|
|
8
|
+
import { createContext, use, useCallback, useMemo } from 'react'
|
|
9
|
+
|
|
10
|
+
import type { OrgRole } from '../server/types'
|
|
11
|
+
|
|
12
|
+
interface OrgContextValue<O extends OrgDoc = OrgDoc, M = unknown> {
|
|
13
|
+
canDeleteOrg: boolean
|
|
14
|
+
canManageAdmins: boolean
|
|
15
|
+
canManageMembers: boolean
|
|
16
|
+
isAdmin: boolean
|
|
17
|
+
isMember: boolean
|
|
18
|
+
isOwner: boolean
|
|
19
|
+
membership: M | null
|
|
20
|
+
org: O
|
|
21
|
+
orgId: string
|
|
22
|
+
role: OrgRole
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface OrgDoc {
|
|
26
|
+
[key: string]: unknown
|
|
27
|
+
_id: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const OrgContext = createContext<null | OrgContextValue>(null)
|
|
31
|
+
|
|
32
|
+
interface OrgProviderProps<O extends OrgDoc, M> {
|
|
33
|
+
children: ReactNode
|
|
34
|
+
membership: M | null
|
|
35
|
+
org: O
|
|
36
|
+
role: OrgRole
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const OrgProvider = <O extends OrgDoc, M>({ children, membership, org, role }: OrgProviderProps<O, M>) => {
|
|
40
|
+
const value = useMemo<OrgContextValue<O, M>>(() => {
|
|
41
|
+
const isOwner = role === 'owner',
|
|
42
|
+
isAdmin = role === 'owner' || role === 'admin'
|
|
43
|
+
return {
|
|
44
|
+
canDeleteOrg: isOwner,
|
|
45
|
+
canManageAdmins: isOwner,
|
|
46
|
+
canManageMembers: isAdmin,
|
|
47
|
+
isAdmin,
|
|
48
|
+
isMember: true,
|
|
49
|
+
isOwner,
|
|
50
|
+
membership,
|
|
51
|
+
org,
|
|
52
|
+
orgId: org._id,
|
|
53
|
+
role
|
|
54
|
+
}
|
|
55
|
+
}, [membership, org, role])
|
|
56
|
+
|
|
57
|
+
return <OrgContext value={value as OrgContextValue}>{children}</OrgContext>
|
|
58
|
+
},
|
|
59
|
+
useOrg = <O extends OrgDoc = OrgDoc, M = unknown>() => {
|
|
60
|
+
const ctx = use(OrgContext)
|
|
61
|
+
if (!ctx) throw new Error('useOrg must be used inside OrgProvider')
|
|
62
|
+
return ctx as OrgContextValue<O, M>
|
|
63
|
+
},
|
|
64
|
+
useOrgQuery = <F extends FunctionReference<'query'>>(
|
|
65
|
+
query: F,
|
|
66
|
+
args?: 'skip' | Omit<F['_args'], 'orgId'>
|
|
67
|
+
): F['_returnType'] | undefined => {
|
|
68
|
+
const { orgId } = useOrg()
|
|
69
|
+
return useQuery(query as FunctionReference<'query'>, args === 'skip' ? 'skip' : { ...args, orgId })
|
|
70
|
+
},
|
|
71
|
+
useOrgMutation = <F extends FunctionReference<'mutation'>>(mutation: F) => {
|
|
72
|
+
const { orgId } = useOrg(),
|
|
73
|
+
mutate = useMutation(mutation as FunctionReference<'mutation'>)
|
|
74
|
+
return useCallback(
|
|
75
|
+
async (args?: Omit<F['_args'], 'orgId'>): Promise<F['_returnType']> => mutate({ ...args, orgId }),
|
|
76
|
+
[mutate, orgId]
|
|
77
|
+
)
|
|
78
|
+
},
|
|
79
|
+
canEditResource = ({
|
|
80
|
+
editorsList,
|
|
81
|
+
isAdmin,
|
|
82
|
+
resource,
|
|
83
|
+
userId
|
|
84
|
+
}: {
|
|
85
|
+
editorsList: { userId: string }[]
|
|
86
|
+
isAdmin: boolean
|
|
87
|
+
resource: { userId: string }
|
|
88
|
+
userId: string
|
|
89
|
+
}): boolean => isAdmin || resource.userId === userId || editorsList.some(e => e.userId === userId),
|
|
90
|
+
useMyOrgs = <O extends OrgDoc>(myOrgsQuery: FunctionReference<'query'>) => {
|
|
91
|
+
const data = useQuery(myOrgsQuery) as undefined | { org: O; role: OrgRole }[]
|
|
92
|
+
return { isLoading: data === undefined, orgs: (data ?? []) as { org: O; role: OrgRole }[] }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export type { OrgContextValue, OrgDoc, OrgProviderProps }
|
|
96
|
+
export { canEditResource, OrgProvider, useMyOrgs, useOrg, useOrgMutation, useOrgQuery }
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-unnecessary-type-parameters */
|
|
2
|
+
'use client'
|
|
3
|
+
|
|
4
|
+
import type { FunctionReference } from 'convex/server'
|
|
5
|
+
|
|
6
|
+
import { useQuery } from 'convex/react'
|
|
7
|
+
import { useCallback, useState } from 'react'
|
|
8
|
+
|
|
9
|
+
interface OrgDoc {
|
|
10
|
+
[key: string]: unknown
|
|
11
|
+
_id: string
|
|
12
|
+
slug: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const ACTIVE_ORG_REGEX = /activeOrgId=(?<id>[^;]+)/u,
|
|
16
|
+
getActiveOrgIdFromCookie = (): null | string => {
|
|
17
|
+
if (typeof document === 'undefined') return null
|
|
18
|
+
const match = ACTIVE_ORG_REGEX.exec(document.cookie)
|
|
19
|
+
return match?.groups?.id ?? null
|
|
20
|
+
},
|
|
21
|
+
setActiveOrgCookieClient = ({ orgId, slug }: { orgId: string; slug: string }) => {
|
|
22
|
+
const maxAge = 60 * 60 * 24 * 365
|
|
23
|
+
document.cookie = `activeOrgId=${orgId}; path=/; max-age=${maxAge}`
|
|
24
|
+
document.cookie = `activeOrgSlug=${slug}; path=/; max-age=${maxAge}`
|
|
25
|
+
},
|
|
26
|
+
useActiveOrg = <O extends OrgDoc>(orgGetQuery: FunctionReference<'query'>) => {
|
|
27
|
+
const [activeOrgId, setActiveOrgId] = useState<null | string>(getActiveOrgIdFromCookie),
|
|
28
|
+
activeOrg = useQuery(orgGetQuery, activeOrgId ? { orgId: activeOrgId } : 'skip') as null | O | undefined,
|
|
29
|
+
setActiveOrg = useCallback((org: OrgDoc) => {
|
|
30
|
+
setActiveOrgCookieClient({ orgId: org._id, slug: org.slug })
|
|
31
|
+
setActiveOrgId(org._id)
|
|
32
|
+
}, []),
|
|
33
|
+
clearActiveOrg = useCallback(() => {
|
|
34
|
+
document.cookie = 'activeOrgId=; path=/; max-age=0'
|
|
35
|
+
document.cookie = 'activeOrgSlug=; path=/; max-age=0'
|
|
36
|
+
setActiveOrgId(null)
|
|
37
|
+
}, [])
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
activeOrg: activeOrg ?? null,
|
|
41
|
+
activeOrgId,
|
|
42
|
+
clearActiveOrg,
|
|
43
|
+
isLoading: activeOrgId ? activeOrg === undefined : false,
|
|
44
|
+
setActiveOrg
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export { setActiveOrgCookieClient, useActiveOrg }
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/* oxlint-disable promise/prefer-await-to-then */
|
|
2
|
+
'use client'
|
|
3
|
+
|
|
4
|
+
import { useState } from 'react'
|
|
5
|
+
|
|
6
|
+
interface UseBulkSelectionOpts {
|
|
7
|
+
bulkRm: (args: { ids: string[]; orgId: string }) => Promise<unknown>
|
|
8
|
+
items: { _id: string }[]
|
|
9
|
+
onError?: (error: unknown) => void
|
|
10
|
+
onSuccess?: (count: number) => void
|
|
11
|
+
orgId: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const useBulkSelection = ({ bulkRm, items, onError, onSuccess, orgId }: UseBulkSelectionOpts) => {
|
|
15
|
+
const [selected, setSelected] = useState<Set<string>>(new Set()),
|
|
16
|
+
clear = () => {
|
|
17
|
+
setSelected(new Set<string>())
|
|
18
|
+
},
|
|
19
|
+
toggleSelect = (id: string) => {
|
|
20
|
+
setSelected(prev => {
|
|
21
|
+
const next = new Set(prev)
|
|
22
|
+
if (next.has(id)) next.delete(id)
|
|
23
|
+
else next.add(id)
|
|
24
|
+
return next
|
|
25
|
+
})
|
|
26
|
+
},
|
|
27
|
+
toggleSelectAll = () => {
|
|
28
|
+
if (selected.size === items.length) setSelected(new Set<string>())
|
|
29
|
+
else setSelected(new Set(items.map(i => i._id)))
|
|
30
|
+
},
|
|
31
|
+
handleBulkDelete = () => {
|
|
32
|
+
if (selected.size === 0) return
|
|
33
|
+
const count = selected.size
|
|
34
|
+
bulkRm({ ids: [...selected], orgId })
|
|
35
|
+
.then(() => {
|
|
36
|
+
onSuccess?.(count)
|
|
37
|
+
setSelected(new Set<string>())
|
|
38
|
+
return null
|
|
39
|
+
})
|
|
40
|
+
.catch((bulkError: unknown) => onError?.(bulkError))
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return { clear, handleBulkDelete, selected, toggleSelect, toggleSelectAll }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export type { UseBulkSelectionOpts }
|
|
47
|
+
export { useBulkSelection }
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from 'react'
|
|
4
|
+
|
|
5
|
+
const useOnlineStatus = () => {
|
|
6
|
+
const [online, setOnline] = useState(true)
|
|
7
|
+
useEffect(() => {
|
|
8
|
+
setOnline(navigator.onLine)
|
|
9
|
+
const handleOnline = () => setOnline(true),
|
|
10
|
+
handleOffline = () => setOnline(false)
|
|
11
|
+
window.addEventListener('online', handleOnline)
|
|
12
|
+
window.addEventListener('offline', handleOffline)
|
|
13
|
+
return () => {
|
|
14
|
+
window.removeEventListener('online', handleOnline)
|
|
15
|
+
window.removeEventListener('offline', handleOffline)
|
|
16
|
+
}
|
|
17
|
+
}, [])
|
|
18
|
+
return online
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export default useOnlineStatus
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/* eslint-disable max-statements, @typescript-eslint/no-unsafe-return */
|
|
2
|
+
'use client'
|
|
3
|
+
|
|
4
|
+
import type { FunctionReference, FunctionReturnType, OptionalRestArgs } from 'convex/server'
|
|
5
|
+
|
|
6
|
+
import { useMutation } from 'convex/react'
|
|
7
|
+
import { useCallback, useRef, useState } from 'react'
|
|
8
|
+
|
|
9
|
+
type Args<T extends MutationFn> = OptionalRestArgs<T>[0]
|
|
10
|
+
type MutationFn = FunctionReference<'mutation'>
|
|
11
|
+
|
|
12
|
+
interface OptimisticOptions<T extends MutationFn, R = FunctionReturnType<T>> {
|
|
13
|
+
mutation: T
|
|
14
|
+
onOptimistic?: (args: Args<T>) => void
|
|
15
|
+
onRollback?: (args: Args<T>, catchError: Error) => void
|
|
16
|
+
onSuccess?: (result: R, args: Args<T>) => void
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const useOptimisticMutation = <T extends MutationFn>({
|
|
20
|
+
mutation,
|
|
21
|
+
onOptimistic,
|
|
22
|
+
onRollback,
|
|
23
|
+
onSuccess
|
|
24
|
+
}: OptimisticOptions<T>) => {
|
|
25
|
+
const mutate = useMutation(mutation),
|
|
26
|
+
[isPending, setIsPending] = useState(false),
|
|
27
|
+
[mutationError, setMutationError] = useState<Error | null>(null),
|
|
28
|
+
pendingCount = useRef(0),
|
|
29
|
+
execute = useCallback(
|
|
30
|
+
async (args: Args<T>): Promise<FunctionReturnType<T> | null> => {
|
|
31
|
+
pendingCount.current += 1
|
|
32
|
+
setIsPending(true)
|
|
33
|
+
setMutationError(null)
|
|
34
|
+
onOptimistic?.(args)
|
|
35
|
+
try {
|
|
36
|
+
const result = await (mutate as (a: Args<T>) => Promise<FunctionReturnType<T>>)(args)
|
|
37
|
+
onSuccess?.(result, args)
|
|
38
|
+
return result
|
|
39
|
+
} catch (error) {
|
|
40
|
+
const err = error instanceof Error ? error : new Error('Mutation failed')
|
|
41
|
+
setMutationError(err)
|
|
42
|
+
onRollback?.(args, err)
|
|
43
|
+
return null
|
|
44
|
+
} finally {
|
|
45
|
+
pendingCount.current -= 1
|
|
46
|
+
if (pendingCount.current === 0) setIsPending(false)
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
[mutate, onOptimistic, onRollback, onSuccess]
|
|
50
|
+
)
|
|
51
|
+
return { error: mutationError, execute, isPending }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export { useOptimisticMutation }
|