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,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'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 }
|
package/src/next/auth.ts
ADDED
|
@@ -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 }
|