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,234 @@
1
+ // oxlint-disable promise/prefer-await-to-then, next/no-img-element
2
+ /* eslint-disable @typescript-eslint/no-unsafe-assignment, no-await-in-loop, complexity */
3
+ // biome-ignore-all lint/performance/noImgElement: x
4
+ // biome-ignore-all lint/performance/noAwaitInLoops: x
5
+ // biome-ignore-all lint/suspicious/noExplicitAny: x
6
+ 'use client'
7
+ import type { AnyFieldApi } from '@tanstack/react-form'
8
+ import type { FunctionReference } from 'convex/server'
9
+ import type { ReactNode } from 'react'
10
+
11
+ import { cn } from '@a/ui'
12
+ import { Field, FieldError, FieldLabel } from '@a/ui/field'
13
+ import imageCompression from 'browser-image-compression'
14
+ import { useQuery } from 'convex/react'
15
+ import { FileIcon, ImageIcon, Upload, X } from 'lucide-react'
16
+ import { createContext, use, useCallback } from 'react'
17
+ import { useDropzone } from 'react-dropzone'
18
+ import { toast } from 'sonner'
19
+
20
+ import useUpload from '../react/use-upload'
21
+
22
+ interface FileApi {
23
+ info: FunctionReference<'query'>
24
+ upload: FunctionReference<'mutation'>
25
+ }
26
+
27
+ const FileApiContext = createContext<FileApi | null>(null),
28
+ FileApiProvider = ({ children, value }: { children: ReactNode; value: FileApi }) => (
29
+ <FileApiContext value={value}>{children}</FileApiContext>
30
+ ),
31
+ useFileApi = () => {
32
+ const ctx = use(FileApiContext)
33
+ if (!ctx) throw new Error('FileApiProvider is required')
34
+ return ctx
35
+ },
36
+ fmt = (n: number) =>
37
+ n < 1024 ? `${n} B` : n < 1_048_576 ? `${(n / 1024).toFixed(1)} KB` : `${(n / 1_048_576).toFixed(1)} MB`,
38
+ isImg = (t: string) => t.startsWith('image/'),
39
+ parseAccept = (a?: string): Record<string, string[]> | undefined =>
40
+ a ? Object.fromEntries(a.split(',').map(t => [t.trim(), []])) : undefined,
41
+ compress = async (f: File, on: boolean) =>
42
+ on && f.type.startsWith('image/')
43
+ ? imageCompression(f, { maxSizeMB: 1, maxWidthOrHeight: 1920, useWebWorker: true }).catch(() => f)
44
+ : f,
45
+ Preview = ({ id, onRemove }: { id: string; onRemove?: () => void }) => {
46
+ const { info } = useFileApi(),
47
+ d = useQuery(info, { id }) as null | undefined | { contentType: string; size: number; url: null | string }
48
+ if (!d) return <p className='size-16 animate-pulse rounded-lg bg-muted' />
49
+ return (
50
+ <div className='relative'>
51
+ {d.contentType && isImg(d.contentType) && d.url ? (
52
+ <img alt='' className='size-16 rounded-lg object-cover' height={64} src={d.url} width={64} />
53
+ ) : (
54
+ <div className='flex size-16 flex-col items-center justify-center rounded-lg bg-muted text-xs'>
55
+ <FileIcon className='size-6 text-muted-foreground' />
56
+ <span className='mt-1'>{fmt(d.size)}</span>
57
+ </div>
58
+ )}
59
+ {onRemove ? (
60
+ <button
61
+ className='absolute -top-2 -right-2 rounded-full bg-destructive p-1 text-white transition-transform hover:scale-110'
62
+ onClick={onRemove}
63
+ type='button'>
64
+ <X className='size-3' />
65
+ </button>
66
+ ) : null}
67
+ </div>
68
+ )
69
+ },
70
+ Progress = ({ v }: { v: number }) => (
71
+ <div className='flex flex-col items-center'>
72
+ <div className='mb-2 h-2 w-32 overflow-hidden rounded-full bg-muted'>
73
+ <div className='h-full bg-primary transition-all' style={{ width: `${v}%` }} />
74
+ </div>
75
+ <span className='text-sm text-muted-foreground'>{v}%</span>
76
+ </div>
77
+ ),
78
+ FileFieldImpl = ({
79
+ accept,
80
+ className,
81
+ compressImg = true,
82
+ 'data-testid': testId,
83
+ disabled,
84
+ field: f,
85
+ label,
86
+ max,
87
+ maxSize,
88
+ multiple
89
+ }: {
90
+ accept?: string
91
+ className?: string
92
+ compressImg?: boolean
93
+ 'data-testid'?: string
94
+ disabled?: boolean
95
+ field: AnyFieldApi
96
+ label?: string
97
+ max?: number
98
+ maxSize?: number
99
+ multiple?: boolean
100
+ }) => {
101
+ const { upload: uploadRef } = useFileApi(),
102
+ raw = f.state.value,
103
+ vals = multiple ? ((raw ?? []) as string[]) : raw ? [raw as string] : [],
104
+ inv = f.state.meta.isTouched && !f.state.meta.isValid,
105
+ canAdd = multiple ? !max || vals.length < max : !vals.length,
106
+ { isUploading, progress, reset, upload } = useUpload(uploadRef),
107
+ errorId = `${f.name}-error`,
108
+ onDrop = useCallback(
109
+ async (accepted: File[]) => {
110
+ if (multiple && max && vals.length + accepted.length > max) return toast.error(`Max ${max}`)
111
+ const ids: string[] = []
112
+ for (const file of accepted) {
113
+ const res = await upload(await compress(file, compressImg))
114
+ if (res.ok) ids.push(res.storageId)
115
+ else if (res.code === 'HTTP') toast.error(`${file.name}: Upload failed (${res.status})`)
116
+ else if (res.code === 'ABORTED') toast.error(`${file.name}: Upload canceled`)
117
+ else if (res.code === 'NETWORK') toast.error(`${file.name}: Network error`)
118
+ else if (res.code === 'INVALID_RESPONSE') toast.error(`${file.name}: Invalid response`)
119
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
120
+ else if (res.code === 'URL') toast.error(`${file.name}: Failed to start upload`)
121
+ }
122
+ if (multiple) f.handleChange([...vals, ...ids])
123
+ else if (ids[0]) f.handleChange(ids[0])
124
+ },
125
+ [compressImg, f, max, multiple, upload, vals]
126
+ ),
127
+ { getInputProps, getRootProps, inputRef, isDragActive } = useDropzone({
128
+ accept: parseAccept(accept),
129
+ disabled: disabled ?? (isUploading || !canAdd),
130
+ maxSize,
131
+ multiple: Boolean(multiple),
132
+ // eslint-disable-next-line @typescript-eslint/strict-void-return, @typescript-eslint/no-misused-promises
133
+ onDrop,
134
+ onDropRejected: r => {
135
+ const code = r[0]?.errors[0]?.code
136
+ if (code === 'file-too-large' && maxSize) toast.error(`Max ${fmt(maxSize)}`)
137
+ else if (code === 'file-invalid-type') toast.error('Invalid type')
138
+ else if (code === 'too-many-files' && max) toast.error(`Max ${max}`)
139
+ }
140
+ }),
141
+ dropCls = cn(
142
+ 'flex cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none',
143
+ multiple ? 'size-16' : 'p-6',
144
+ isDragActive ? 'border-primary bg-primary/5' : 'border-muted-foreground/25 hover:border-primary/50',
145
+ (disabled ?? isUploading) && 'cursor-not-allowed opacity-50',
146
+ className
147
+ ),
148
+ tid = testId ?? f.name
149
+ return (
150
+ <Field data-invalid={inv} data-testid={tid}>
151
+ {label ? (
152
+ <FieldLabel htmlFor={f.name}>
153
+ {label}
154
+ {multiple && max ? (
155
+ <span className='text-muted-foreground'>
156
+ {' '}
157
+ ({vals.length}/{max})
158
+ </span>
159
+ ) : null}
160
+ </FieldLabel>
161
+ ) : null}
162
+ {multiple ? (
163
+ <div className='flex flex-wrap gap-2'>
164
+ {vals.map((id, i) => (
165
+ <Preview id={id} key={id} onRemove={() => f.handleChange(vals.filter((_, j) => j !== i))} />
166
+ ))}
167
+ {canAdd ? (
168
+ <div
169
+ {...getRootProps()}
170
+ aria-label='Upload file'
171
+ className={dropCls}
172
+ onKeyDown={e => {
173
+ if (e.key === 'Enter' || e.key === ' ') {
174
+ e.preventDefault()
175
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
176
+ inputRef.current?.click()
177
+ }
178
+ }}
179
+ role='button'
180
+ tabIndex={0}>
181
+ <input {...getInputProps()} aria-describedby={inv ? errorId : undefined} aria-invalid={inv} />
182
+ {isUploading ? (
183
+ <span className='text-xs'>{progress}%</span>
184
+ ) : (
185
+ <Upload className='size-5 text-muted-foreground' />
186
+ )}
187
+ </div>
188
+ ) : null}
189
+ </div>
190
+ ) : vals[0] ? (
191
+ <Preview
192
+ id={vals[0]}
193
+ onRemove={() => {
194
+ f.handleChange(null)
195
+ reset()
196
+ }}
197
+ />
198
+ ) : (
199
+ <div
200
+ {...getRootProps()}
201
+ aria-label='Upload file'
202
+ className={dropCls}
203
+ onKeyDown={e => {
204
+ if (e.key === 'Enter' || e.key === ' ') {
205
+ e.preventDefault()
206
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
207
+ inputRef.current?.click()
208
+ }
209
+ }}
210
+ role='button'
211
+ tabIndex={0}>
212
+ <input {...getInputProps()} aria-describedby={inv ? errorId : undefined} aria-invalid={inv} />
213
+ {isUploading ? (
214
+ <Progress v={progress} />
215
+ ) : (
216
+ <>
217
+ {accept?.includes('image') ? (
218
+ <ImageIcon className='mb-2 size-8 text-muted-foreground' />
219
+ ) : (
220
+ <Upload className='mb-2 size-8 text-muted-foreground' />
221
+ )}
222
+ <span className='text-sm text-muted-foreground'>Click or drag</span>
223
+ {maxSize ? <span className='mt-1 text-xs text-muted-foreground'>Max {fmt(maxSize)}</span> : null}
224
+ </>
225
+ )}
226
+ </div>
227
+ )}
228
+ {inv ? <FieldError errors={f.state.meta.errors} id={errorId} /> : null}
229
+ </Field>
230
+ )
231
+ }
232
+
233
+ export default FileFieldImpl
234
+ export { FileApiContext, FileApiProvider }
@@ -0,0 +1,191 @@
1
+ /* eslint-disable react-hooks/rules-of-hooks */
2
+ // biome-ignore-all lint/suspicious/noExplicitAny: x
3
+ // biome-ignore-all lint/correctness/useHookAtTopLevel: watch hook is called inside component render context
4
+ 'use client'
5
+ import type { FunctionReference } from 'convex/server'
6
+ import type { ReactNode } from 'react'
7
+ import type { infer as zinfer, ZodObject, ZodRawShape } from 'zod/v4'
8
+
9
+ import { Button } from '@a/ui/button'
10
+ import { Dialog, DialogContent } from '@a/ui/dialog'
11
+ import { useNavigationGuard } from 'next-navigation-guard'
12
+ import { useEffect, useState } from 'react'
13
+
14
+ import type { FormReturn as BaseFormReturn, ConflictData } from '../react/form'
15
+ import type { Api } from './fields'
16
+
17
+ import { useForm as useBaseForm, useFormMutation as useBaseFormMutation } from '../react/form'
18
+ import { fields, FormContext } from './fields'
19
+
20
+ const ConflictDialog = ({
21
+ conflict,
22
+ onResolve
23
+ }: {
24
+ conflict: ConflictData | null
25
+ onResolve: (action: 'cancel' | 'overwrite' | 'reload') => void
26
+ }) => (
27
+ <Dialog open={Boolean(conflict)}>
28
+ <DialogContent
29
+ className='[&>button]:hidden'
30
+ onEscapeKeyDown={() => onResolve('cancel')}
31
+ onInteractOutside={() => onResolve('cancel')}>
32
+ <h2 className='text-lg font-semibold'>Conflict Detected</h2>
33
+ <p className='text-sm text-muted-foreground'>
34
+ This record was modified by someone else. Choose how to resolve the conflict.
35
+ </p>
36
+ {conflict?.current || conflict?.incoming ? (
37
+ <div className='space-y-3'>
38
+ {conflict.current ? (
39
+ <div className='rounded-lg bg-muted p-3'>
40
+ <p className='mb-1 text-xs font-medium text-muted-foreground'>Server version:</p>
41
+ <pre className='text-xs'>{JSON.stringify(conflict.current, null, 2)}</pre>
42
+ </div>
43
+ ) : null}
44
+ {conflict.incoming ? (
45
+ <div className='rounded-lg bg-muted p-3'>
46
+ <p className='mb-1 text-xs font-medium text-muted-foreground'>Your version:</p>
47
+ <pre className='text-xs'>{JSON.stringify(conflict.incoming, null, 2)}</pre>
48
+ </div>
49
+ ) : null}
50
+ </div>
51
+ ) : null}
52
+ <div className='flex justify-end gap-2'>
53
+ <Button onClick={() => onResolve('cancel')} variant='outline'>
54
+ Cancel
55
+ </Button>
56
+ <Button onClick={() => onResolve('reload')} variant='outline'>
57
+ Reload
58
+ </Button>
59
+ <Button onClick={() => onResolve('overwrite')} variant='destructive'>
60
+ Overwrite
61
+ </Button>
62
+ </div>
63
+ </DialogContent>
64
+ </Dialog>
65
+ )
66
+
67
+ interface FormReturn<T extends Record<string, unknown>, S extends ZodObject<ZodRawShape>> extends BaseFormReturn<T, S> {
68
+ guard: ReturnType<typeof useNavigationGuard>
69
+ }
70
+ type Key<T, V> = string & { [K in keyof T]: T[K] extends V ? K : never }[keyof T]
71
+ type Props<K extends keyof typeof fields> = Parameters<(typeof fields)[K]>[0]
72
+
73
+ interface TypedFields<T> {
74
+ Arr: (p: WithName<Props<'Arr'>, Key<T, readonly string[] | string[] | undefined>>) => ReactNode
75
+ Choose: (p: WithName<Props<'Choose'>, Key<T, string | undefined>>) => ReactNode
76
+ Colorpick: (p: WithName<Props<'Colorpick'>, Key<T, string | undefined>>) => ReactNode
77
+ Combobox: (p: WithName<Props<'Combobox'>, Key<T, string | undefined>>) => ReactNode
78
+ Datepick: (p: WithName<Props<'Datepick'>, Key<T, null | number | undefined>>) => ReactNode
79
+ Err: typeof fields.Err
80
+ File: (p: WithName<Props<'File'>, Key<T, null | string | undefined>>) => ReactNode
81
+ Files: (p: WithName<Props<'Files'>, Key<T, readonly string[] | string[] | undefined>>) => ReactNode
82
+ MultiSelect: (p: WithName<Props<'MultiSelect'>, Key<T, readonly string[] | string[] | undefined>>) => ReactNode
83
+ Num: (p: WithName<Props<'Num'>, Key<T, number | undefined>>) => ReactNode
84
+ Rating: (p: WithName<Props<'Rating'>, Key<T, number | undefined>>) => ReactNode
85
+ Slider: (p: WithName<Props<'Slider'>, Key<T, number | undefined>>) => ReactNode
86
+ Submit: typeof fields.Submit
87
+ Text: (
88
+ p: WithName<Props<'Text'>, Key<T, string | undefined>> & {
89
+ asyncDebounceMs?: number
90
+ asyncValidate?: (value: string) => Promise<string | undefined>
91
+ }
92
+ ) => ReactNode
93
+ Timepick: (p: WithName<Props<'Timepick'>, Key<T, string | undefined>>) => ReactNode
94
+ Toggle: (p: { falseLabel?: string; name: Key<T, boolean | undefined>; trueLabel: string }) => ReactNode
95
+ }
96
+
97
+ type WithName<P, K> = Omit<P, 'name'> & { name: K }
98
+
99
+ const withGuard = <T extends Record<string, unknown>, S extends ZodObject<ZodRawShape>>(
100
+ base: BaseFormReturn<T, S>
101
+ ): FormReturn<T, S> => {
102
+ const dirty = base.isDirty || base.isPending,
103
+ guard = useNavigationGuard({ enabled: dirty })
104
+ useEffect(() => {
105
+ if (!dirty) return
106
+ const h = (e: BeforeUnloadEvent) => {
107
+ e.preventDefault()
108
+ // eslint-disable-next-line @typescript-eslint/no-deprecated
109
+ e.returnValue = ''
110
+ }
111
+ window.addEventListener('beforeunload', h)
112
+ return () => window.removeEventListener('beforeunload', h)
113
+ }, [dirty])
114
+ return { ...base, guard }
115
+ },
116
+ useForm = <S extends ZodObject<ZodRawShape>>(opts: {
117
+ autoSave?: { debounceMs: number; enabled: boolean }
118
+ onConflict?: (data: ConflictData) => void
119
+ onError?: (e: unknown) => void
120
+ onSubmit: (d: zinfer<S>, force?: boolean) => Promise<undefined | zinfer<S>> | undefined | zinfer<S>
121
+ onSuccess?: () => void
122
+ resetOnSuccess?: boolean
123
+ schema: S
124
+ values?: zinfer<S>
125
+ }) => withGuard(useBaseForm(opts)),
126
+ useFormMutation = <S extends ZodObject<ZodRawShape>>(opts: {
127
+ mutation: FunctionReference<'mutation'>
128
+ onConflict?: (data: ConflictData) => void
129
+ onError?: (e: unknown) => void
130
+ onSuccess?: () => void
131
+ resetOnSuccess?: boolean
132
+ schema: S
133
+ transform?: (d: zinfer<S>) => Record<string, unknown>
134
+ values?: zinfer<S>
135
+ }) => withGuard(useBaseFormMutation(opts)),
136
+ Form = <T extends Record<string, unknown>, S extends ZodObject<ZodRawShape>>({
137
+ className,
138
+ form: { conflict, error, guard, instance, meta, resolveConflict, schema },
139
+ render,
140
+ showError = true
141
+ }: {
142
+ className?: string
143
+ form: FormReturn<T, S>
144
+ render: (f: TypedFields<T>) => ReactNode
145
+ showError?: boolean
146
+ }) => (
147
+ <FormContext value={{ form: instance as Api<Record<string, unknown>>, meta, schema }}>
148
+ <form
149
+ className={className}
150
+ onSubmit={e => {
151
+ e.preventDefault()
152
+ instance.handleSubmit()
153
+ }}>
154
+ {showError && error ? (
155
+ <p className='mb-4 rounded-lg bg-destructive/10 p-3 text-sm text-destructive' role='alert'>
156
+ {error.message}
157
+ </p>
158
+ ) : null}
159
+ {render(fields as TypedFields<T>)}
160
+ </form>
161
+ <ConflictDialog conflict={conflict} onResolve={resolveConflict} />
162
+ <Dialog open={guard.active}>
163
+ <DialogContent className='[&>button]:hidden' onEscapeKeyDown={guard.reject} onInteractOutside={guard.reject}>
164
+ <p>You have unsaved changes. Are you sure you want to leave?</p>
165
+ <div className='flex justify-end gap-2'>
166
+ <Button onClick={guard.reject} variant='outline'>
167
+ Cancel
168
+ </Button>
169
+ <Button onClick={guard.accept} variant='destructive'>
170
+ Discard
171
+ </Button>
172
+ </div>
173
+ </DialogContent>
174
+ </Dialog>
175
+ </FormContext>
176
+ ),
177
+ AutoSaveIndicator = ({ lastSaved }: { lastSaved: null | number }) => {
178
+ const [, forceUpdate] = useState(0)
179
+
180
+ useEffect(() => {
181
+ if (!lastSaved) return
182
+ const id = setInterval(() => forceUpdate(n => n + 1), 10_000)
183
+ return () => clearInterval(id)
184
+ }, [lastSaved])
185
+
186
+ if (!lastSaved) return null
187
+ const ago = Math.round((Date.now() - lastSaved) / 1000)
188
+ return <span className='text-xs text-muted-foreground'>{ago < 5 ? 'Saved' : `Saved ${ago}s ago`}</span>
189
+ }
190
+
191
+ export { AutoSaveIndicator, ConflictDialog, Form, useForm, useFormMutation }
@@ -0,0 +1,11 @@
1
+ export { default as EditorsSection } from './editors-section'
2
+ export { fields, FormContext } from './fields'
3
+ export type { Api } from './fields'
4
+ export { FileApiContext, FileApiProvider } from './file-field'
5
+ export { default as FileFieldImpl } from './file-field'
6
+ export { AutoSaveIndicator, ConflictDialog, Form, useForm, useFormMutation } from './form'
7
+ export { default as OfflineIndicator } from './offline-indicator'
8
+ export { default as OrgAvatar } from './org-avatar'
9
+ export { default as PermissionGuard } from './permission-guard'
10
+ export { default as RoleBadge } from './role-badge'
11
+ export { default as suspenseWrap } from './suspense-wrap'
@@ -0,0 +1,15 @@
1
+ 'use client'
2
+
3
+ import useOnlineStatus from '../react/use-online-status'
4
+
5
+ const OfflineIndicator = () => {
6
+ const online = useOnlineStatus()
7
+ if (online) return null
8
+ return (
9
+ <div className='fixed bottom-4 left-4 z-50 rounded-md bg-destructive px-4 py-2 text-sm font-medium text-destructive-foreground shadow-lg'>
10
+ You are offline
11
+ </div>
12
+ )
13
+ }
14
+
15
+ export default OfflineIndicator
@@ -0,0 +1,13 @@
1
+ 'use client'
2
+
3
+ import { Avatar, AvatarFallback, AvatarImage } from '@a/ui/avatar'
4
+
5
+ const sizes = { lg: 'size-12', md: 'size-8', sm: 'size-6' },
6
+ OrgAvatar = ({ avatarUrl, name, size = 'md' }: { avatarUrl?: string; name: string; size?: 'lg' | 'md' | 'sm' }) => (
7
+ <Avatar className={sizes[size]}>
8
+ {avatarUrl ? <AvatarImage src={avatarUrl} /> : null}
9
+ <AvatarFallback>{name.slice(0, 2).toUpperCase()}</AvatarFallback>
10
+ </Avatar>
11
+ )
12
+
13
+ export default OrgAvatar
@@ -0,0 +1,36 @@
1
+ /* eslint-disable @typescript-eslint/promise-function-async */
2
+ 'use client'
3
+
4
+ import type { ReactNode } from 'react'
5
+
6
+ import { Badge } from '@a/ui/badge'
7
+ import { Button } from '@a/ui/button'
8
+ import Link from 'next/link'
9
+
10
+ const PermissionGuard = ({
11
+ backHref,
12
+ backLabel,
13
+ canAccess,
14
+ children,
15
+ resource
16
+ }: {
17
+ backHref: string
18
+ backLabel: string
19
+ canAccess: boolean
20
+ children: ReactNode
21
+ resource: string
22
+ }) => {
23
+ if (!canAccess)
24
+ return (
25
+ <div className='flex flex-col items-center gap-4 py-12'>
26
+ <Badge variant='secondary'>View only</Badge>
27
+ <p className='text-muted-foreground'>You don&apos;t have edit permission for this {resource}.</p>
28
+ <Button asChild variant='outline'>
29
+ <Link href={backHref}>Back to {backLabel}</Link>
30
+ </Button>
31
+ </div>
32
+ )
33
+ return children
34
+ }
35
+
36
+ export default PermissionGuard
@@ -0,0 +1,14 @@
1
+ 'use client'
2
+
3
+ import { Badge } from '@a/ui/badge'
4
+
5
+ import type { OrgRole } from '../server/types'
6
+
7
+ const variants: Record<OrgRole, 'default' | 'outline' | 'secondary'> = {
8
+ admin: 'secondary',
9
+ member: 'outline',
10
+ owner: 'default'
11
+ },
12
+ RoleBadge = ({ role }: { role: OrgRole }) => <Badge variant={variants[role]}>{role}</Badge>
13
+
14
+ export default RoleBadge
@@ -0,0 +1,8 @@
1
+ // oxlint-disable unicorn/no-anonymous-default-export
2
+
3
+ import type { ReactNode } from 'react'
4
+
5
+ import { Suspense } from 'react'
6
+
7
+ // eslint-disable-next-line react/display-name
8
+ export default (f: (...args: unknown[]) => ReactNode) => () => <Suspense fallback=''>{f()}</Suspense>
package/src/index.ts ADDED
@@ -0,0 +1,40 @@
1
+ export type { Api, ConflictData, FormReturn } from './react/form'
2
+ export type { FieldKind, FieldMeta, FieldMetaMap } from './react/form-meta'
3
+ export type { OrgContextValue, OrgDoc, OrgProviderProps } from './react/org'
4
+ export type {
5
+ Ab,
6
+ ActionCtxLike,
7
+ AuthorInfo,
8
+ CacheCrudResult,
9
+ CacheOptions,
10
+ CanEditOpts,
11
+ ChildConfig,
12
+ ChildCrudResult,
13
+ ComparisonOp,
14
+ CrudOptions,
15
+ CrudReadApi,
16
+ CrudResult,
17
+ DbLike,
18
+ DbReadLike,
19
+ DocBase,
20
+ EnrichedDoc,
21
+ ErrorCode,
22
+ FID,
23
+ Mb,
24
+ MutationCtxLike,
25
+ OrgCrudResult,
26
+ OrgEnrichedDoc,
27
+ OrgRole,
28
+ PaginatedResult,
29
+ PaginationOptsShape,
30
+ Qb,
31
+ QueryCtxLike,
32
+ QueryLike,
33
+ ReadCtx,
34
+ SetupConfig,
35
+ StorageLike,
36
+ WhereGroupOf,
37
+ WhereOf,
38
+ WithUrls
39
+ } from './server/types'
40
+ export type { CvMeta, DefType, ZodSchema } from './zod'
@@ -0,0 +1,33 @@
1
+ 'use server'
2
+ import type { FunctionReference } from 'convex/server'
3
+
4
+ import { fetchQuery } from 'convex/nextjs'
5
+ import { cookies } from 'next/headers'
6
+
7
+ const setActiveOrgCookie = async ({ orgId, slug }: { orgId: string; slug: string }) => {
8
+ const cookieStore = await cookies(),
9
+ opts = { httpOnly: false, maxAge: 60 * 60 * 24 * 365, path: '/' } as const
10
+ cookieStore.set('activeOrgId', orgId, opts)
11
+ cookieStore.set('activeOrgSlug', slug, opts)
12
+ },
13
+ clearActiveOrgCookie = async () => {
14
+ const cookieStore = await cookies()
15
+ cookieStore.delete('activeOrgId')
16
+ cookieStore.delete('activeOrgSlug')
17
+ },
18
+ getActiveOrg = async ({ query, token }: { query: FunctionReference<'query'>; token: null | string }) => {
19
+ if (!token) return null
20
+ const cookieStore = await cookies(),
21
+ orgId = cookieStore.get('activeOrgId')?.value
22
+ if (!orgId) return null
23
+ try {
24
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-return
25
+ return await fetchQuery(query, { orgId }, { token })
26
+ } catch {
27
+ cookieStore.delete('activeOrgId')
28
+ cookieStore.delete('activeOrgSlug')
29
+ return null
30
+ }
31
+ }
32
+
33
+ export { clearActiveOrgCookie, getActiveOrg, setActiveOrgCookie }
@@ -0,0 +1,9 @@
1
+ 'use server'
2
+ import { convexAuthNextjsToken } from '@convex-dev/auth/nextjs/server'
3
+
4
+ // eslint-disable-next-line no-restricted-properties, @typescript-eslint/prefer-nullish-coalescing
5
+ const isTest = Boolean(process.env.PLAYWRIGHT || process.env.TEST_MODE),
6
+ getToken = async () => (isTest ? undefined : convexAuthNextjsToken()),
7
+ isAuthenticated = async () => isTest || Boolean(await convexAuthNextjsToken())
8
+
9
+ export { getToken, isAuthenticated }