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.
Files changed (70) hide show
  1. package/README.md +926 -0
  2. package/dist/components/index.mjs +937 -0
  3. package/dist/error-D4GuI0ot.mjs +71 -0
  4. package/dist/file-field-BqVgy8xY.mjs +205 -0
  5. package/dist/form-BXJK_j10.d.mts +99 -0
  6. package/dist/index.d.mts +433 -0
  7. package/dist/index.mjs +1 -0
  8. package/dist/index2.d.mts +5 -0
  9. package/dist/index3.d.mts +35 -0
  10. package/dist/index4.d.mts +101 -0
  11. package/dist/index5.d.mts +842 -0
  12. package/dist/next/index.mjs +151 -0
  13. package/dist/org-CmJBb8z-.d.mts +56 -0
  14. package/dist/react/index.mjs +158 -0
  15. package/dist/retry.d.mts +12 -0
  16. package/dist/retry.mjs +35 -0
  17. package/dist/schema.d.mts +23 -0
  18. package/dist/schema.mjs +15 -0
  19. package/dist/server/index.mjs +2572 -0
  20. package/dist/types-DWBVRtit.d.mts +322 -0
  21. package/dist/use-online-status-CMr73Jlk.mjs +155 -0
  22. package/dist/use-upload-DtELytQi.mjs +95 -0
  23. package/dist/zod.d.mts +18 -0
  24. package/dist/zod.mjs +87 -0
  25. package/package.json +40 -0
  26. package/src/components/editors-section.tsx +86 -0
  27. package/src/components/fields.tsx +884 -0
  28. package/src/components/file-field.tsx +234 -0
  29. package/src/components/form.tsx +191 -0
  30. package/src/components/index.ts +11 -0
  31. package/src/components/offline-indicator.tsx +15 -0
  32. package/src/components/org-avatar.tsx +13 -0
  33. package/src/components/permission-guard.tsx +36 -0
  34. package/src/components/role-badge.tsx +14 -0
  35. package/src/components/suspense-wrap.tsx +8 -0
  36. package/src/index.ts +40 -0
  37. package/src/next/active-org.ts +33 -0
  38. package/src/next/auth.ts +9 -0
  39. package/src/next/image.ts +134 -0
  40. package/src/next/index.ts +3 -0
  41. package/src/react/form-meta.ts +53 -0
  42. package/src/react/form.ts +201 -0
  43. package/src/react/index.ts +8 -0
  44. package/src/react/org.tsx +96 -0
  45. package/src/react/use-active-org.ts +48 -0
  46. package/src/react/use-bulk-selection.ts +47 -0
  47. package/src/react/use-online-status.ts +21 -0
  48. package/src/react/use-optimistic.ts +54 -0
  49. package/src/react/use-upload.ts +101 -0
  50. package/src/retry.ts +47 -0
  51. package/src/schema.ts +30 -0
  52. package/src/server/cache-crud.ts +175 -0
  53. package/src/server/check-schema.ts +29 -0
  54. package/src/server/child.ts +98 -0
  55. package/src/server/crud.ts +384 -0
  56. package/src/server/db.ts +7 -0
  57. package/src/server/error.ts +39 -0
  58. package/src/server/file.ts +372 -0
  59. package/src/server/helpers.ts +214 -0
  60. package/src/server/index.ts +12 -0
  61. package/src/server/org-crud.ts +307 -0
  62. package/src/server/org-helpers.ts +54 -0
  63. package/src/server/org.ts +572 -0
  64. package/src/server/schema-helpers.ts +107 -0
  65. package/src/server/setup.ts +138 -0
  66. package/src/server/test-crud.ts +211 -0
  67. package/src/server/test.ts +554 -0
  68. package/src/server/types.ts +392 -0
  69. package/src/server/unique.ts +28 -0
  70. 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,3 @@
1
+ export { clearActiveOrgCookie, getActiveOrg, setActiveOrgCookie } from './active-org'
2
+ export { getToken, isAuthenticated } from './auth'
3
+ export { makeImageRoute } from './image'
@@ -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 }