create-nextjs-cms 0.9.20 → 0.9.21

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-nextjs-cms",
3
- "version": "0.9.20",
3
+ "version": "0.9.21",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
@@ -29,8 +29,8 @@
29
29
  "tsx": "^4.20.6",
30
30
  "typescript": "^5.9.2",
31
31
  "@lzcms/eslint-config": "0.3.0",
32
- "@lzcms/tsconfig": "0.1.0",
33
- "@lzcms/prettier-config": "0.1.0"
32
+ "@lzcms/prettier-config": "0.1.0",
33
+ "@lzcms/tsconfig": "0.1.0"
34
34
  },
35
35
  "prettier": "@lzcms/prettier-config",
36
36
  "scripts": {
@@ -1,93 +1,109 @@
1
- import { PhotoGalleryItem } from 'nextjs-cms/core/types'
2
- import { useI18n } from 'nextjs-cms/translations/client'
3
- import { MinusIcon } from '@radix-ui/react-icons'
4
- import ProtectedImage from '@/components/ProtectedImage'
5
- import useModal from '@/hooks/useModal'
6
- import { useToast } from '@/components/ui/use-toast'
7
- import { trpc } from '@/app/_trpc/client'
8
-
9
- const GalleryPhoto = ({ item, sectionName, action }: { item: PhotoGalleryItem; sectionName: string; action?: any }) => {
10
- const t = useI18n()
11
- const { setModal, setModalResponse } = useModal()
12
- const deleteMutation = trpc.gallery.deletePhoto.useMutation({
13
- onError: (error) => {
14
- toast({
15
- variant: 'destructive',
16
- title: t('deleteGalleryPhoto'),
17
- description: error.message,
18
- })
19
- },
20
-
21
- onSuccess: (data) => {
22
- setModal(null)
23
- setModalResponse(null)
24
- toast({
25
- variant: 'success',
26
- title: t('galleryPhotoDeleted'),
27
- })
28
- action()
29
- },
30
- })
31
- const { toast } = useToast()
32
- const handlePhotoDelete = async () => {
33
- deleteMutation.mutate({
34
- sectionName: sectionName,
35
- photoName: item.photo,
36
- referenceId: item.referenceId,
37
- })
38
- }
39
- return (
40
- <div className='relative'>
41
- {/* Delete Button */}
42
- <button
43
- type='button'
44
- className='absolute -end-2 -top-2 z-10 h-6 w-6 rounded-full bg-red-500 p-1'
45
- onClick={() => {
46
- setModal({
47
- title: t('deleteGalleryPhoto'),
48
- body: (
49
- <div className='p-4'>
50
- <div className='flex flex-col gap-4'>
51
- <div>{t('deleteGalleryPhotoText')}</div>
52
- <div className='flex gap-2'>
53
- <button
54
- className='rounded bg-green-600 px-2 py-1 text-white'
55
- onClick={handlePhotoDelete}
56
- >
57
- Yes
58
- </button>
59
- <button
60
- className='rounded bg-red-800 px-2 py-1 text-white'
61
- onClick={() => {
62
- setModal(null)
63
- }}
64
- >
65
- No
66
- </button>
67
- </div>
68
- </div>
69
- </div>
70
- ),
71
- headerColor: 'bg-red-700',
72
- titleColor: 'text-white',
73
- lang: 'en',
74
- })
75
- }}
76
- >
77
- <MinusIcon className='text-white' />
78
- </button>
79
- <ProtectedImage
80
- section={sectionName}
81
- photo={item.photo}
82
- isThumb={true}
83
- alt={item.photo}
84
- height={150}
85
- width={150}
86
- // fill={true}
87
- className='mb-4 rounded p-1 ring-3 ring-gray-400'
88
- />
89
- </div>
90
- )
91
- }
92
-
93
- export default GalleryPhoto
1
+ import { trpc } from '@/app/_trpc/client'
2
+ import ProtectedImage from '@/components/ProtectedImage'
3
+ import { useToast } from '@/components/ui/use-toast'
4
+ import useModal from '@/hooks/useModal'
5
+ import { MinusIcon } from '@radix-ui/react-icons'
6
+ import { PhotoGalleryItem } from 'nextjs-cms/core/types'
7
+ import { useI18n } from 'nextjs-cms/translations/client'
8
+
9
+ type GalleryPhotoItem = PhotoGalleryItem & { locale?: string }
10
+
11
+ const GalleryPhoto = ({
12
+ item,
13
+ sectionName,
14
+ localized,
15
+ locale,
16
+ action,
17
+ }: {
18
+ item: GalleryPhotoItem
19
+ sectionName: string
20
+ localized?: boolean
21
+ locale?: string
22
+ action?: any
23
+ }) => {
24
+ const t = useI18n()
25
+ const { setModal, setModalResponse } = useModal()
26
+ const deleteMutation = trpc.gallery.deletePhoto.useMutation({
27
+ onError: (error) => {
28
+ toast({
29
+ variant: 'destructive',
30
+ title: t('deleteGalleryPhoto'),
31
+ description: error.message,
32
+ })
33
+ },
34
+
35
+ onSuccess: (data) => {
36
+ setModal(null)
37
+ setModalResponse(null)
38
+ toast({
39
+ variant: 'success',
40
+ title: t('galleryPhotoDeleted'),
41
+ })
42
+ action()
43
+ },
44
+ })
45
+ const { toast } = useToast()
46
+ const handlePhotoDelete = async () => {
47
+ const targetLocale = locale ?? item.locale
48
+ deleteMutation.mutate({
49
+ sectionName: sectionName,
50
+ photoName: item.photo,
51
+ referenceId: item.referenceId,
52
+ ...(localized && targetLocale ? { locale: targetLocale } : {}),
53
+ })
54
+ }
55
+ return (
56
+ <div className='relative'>
57
+ {/* Delete Button */}
58
+ <button
59
+ type='button'
60
+ className='absolute -end-2 -top-2 z-10 h-6 w-6 rounded-full bg-red-500 p-1'
61
+ onClick={() => {
62
+ setModal({
63
+ title: t('deleteGalleryPhoto'),
64
+ body: (
65
+ <div className='p-4'>
66
+ <div className='flex flex-col gap-4'>
67
+ <div>{t('deleteGalleryPhotoText')}</div>
68
+ <div className='flex gap-2'>
69
+ <button
70
+ className='rounded bg-green-600 px-2 py-1 text-white'
71
+ onClick={handlePhotoDelete}
72
+ >
73
+ Yes
74
+ </button>
75
+ <button
76
+ className='rounded bg-red-800 px-2 py-1 text-white'
77
+ onClick={() => {
78
+ setModal(null)
79
+ }}
80
+ >
81
+ No
82
+ </button>
83
+ </div>
84
+ </div>
85
+ </div>
86
+ ),
87
+ headerColor: 'bg-red-700',
88
+ titleColor: 'text-white',
89
+ lang: 'en',
90
+ })
91
+ }}
92
+ >
93
+ <MinusIcon className='text-white' />
94
+ </button>
95
+ <ProtectedImage
96
+ section={sectionName}
97
+ photo={item.photo}
98
+ isThumb={true}
99
+ alt={item.photo}
100
+ height={150}
101
+ width={150}
102
+ // fill={true}
103
+ className='ring-3 mb-4 rounded p-1 ring-gray-400'
104
+ />
105
+ </div>
106
+ )
107
+ }
108
+
109
+ export default GalleryPhoto
@@ -1,35 +1,47 @@
1
- import { useI18n } from 'nextjs-cms/translations/client'
2
- import { PhotoGalleryItem } from 'nextjs-cms/core/types'
3
- import GalleryPhoto from '@/components/GalleryPhoto'
4
- import { Alert, AlertDescription } from '@/components/ui/alert'
5
- import ContainerBox from '@/components/ContainerBox'
6
-
7
- const PhotoGallery = ({ sectionName, gallery }: { sectionName: string; gallery: PhotoGalleryItem[] }) => {
8
- const t = useI18n()
9
- return (
10
- <ContainerBox title={t('gallery')}>
11
- {gallery && gallery.length > 0 ? (
12
- <div className='flex flex-wrap gap-4'>
13
- {gallery.map((photo: PhotoGalleryItem, index: number) => (
14
- <GalleryPhoto
15
- item={photo}
16
- sectionName={sectionName}
17
- key={photo.photo}
18
- action={() => {
19
- // This is the action that will be executed when the user removes a photo from the gallery
20
- // Remove the removed photo from the gallery
21
- gallery.splice(index, 1)
22
- }}
23
- />
24
- ))}
25
- </div>
26
- ) : (
27
- <Alert variant='light' className='mt-4'>
28
- <AlertDescription className='font-bold'>{t('noItems')}</AlertDescription>
29
- </Alert>
30
- )}
31
- </ContainerBox>
32
- )
33
- }
34
-
35
- export default PhotoGallery
1
+ import ContainerBox from '@/components/ContainerBox'
2
+ import GalleryPhoto from '@/components/GalleryPhoto'
3
+ import { Alert, AlertDescription } from '@/components/ui/alert'
4
+ import { PhotoGalleryItem } from 'nextjs-cms/core/types'
5
+ import { useI18n } from 'nextjs-cms/translations/client'
6
+
7
+ const PhotoGallery = ({
8
+ sectionName,
9
+ gallery,
10
+ localized,
11
+ locale,
12
+ }: {
13
+ sectionName: string
14
+ gallery: PhotoGalleryItem[]
15
+ localized?: boolean
16
+ locale?: string
17
+ }) => {
18
+ const t = useI18n()
19
+ return (
20
+ <ContainerBox title={t('gallery')}>
21
+ {gallery && gallery.length > 0 ? (
22
+ <div className='flex flex-wrap gap-4'>
23
+ {gallery.map((photo: PhotoGalleryItem, index: number) => (
24
+ <GalleryPhoto
25
+ item={photo}
26
+ sectionName={sectionName}
27
+ localized={localized}
28
+ locale={locale}
29
+ key={photo.photo}
30
+ action={() => {
31
+ // This is the action that will be executed when the user removes a photo from the gallery
32
+ // Remove the removed photo from the gallery
33
+ gallery.splice(index, 1)
34
+ }}
35
+ />
36
+ ))}
37
+ </div>
38
+ ) : (
39
+ <Alert variant='light' className='mt-4'>
40
+ <AlertDescription className='font-bold'>{t('noItems')}</AlertDescription>
41
+ </Alert>
42
+ )}
43
+ </ContainerBox>
44
+ )
45
+ }
46
+
47
+ export default PhotoGallery
@@ -1,370 +1,385 @@
1
- export const revalidate = 1
2
-
3
- import ContainerBox from '@/components/ContainerBox'
4
- import { useI18n } from 'nextjs-cms/translations/client'
5
- import FormInputs from '@/components/form/FormInputs'
6
- import Dropzone, { DropzoneHandles } from '@/components/Dropzone'
7
- import NewVariantComponent, { VariantHandles } from '@/components/NewVariantComponent'
8
- import classNames from 'classnames'
9
- import ProgressBar from '@/components/ProgressBar'
10
- import React, { RefObject, useCallback, useEffect } from 'react'
11
- import type { RouterOutputs } from 'nextjs-cms/api'
12
- import * as z from 'zod'
13
- import { zodResolver } from '@hookform/resolvers/zod'
14
- import { useForm, FormProvider } from 'react-hook-form'
15
- import {
16
- CheckboxFieldClientConfig,
17
- ColorFieldClientConfig,
18
- DateFieldClientConfig,
19
- DateRangeFieldClientConfig,
20
- DocumentFieldClientConfig,
21
- MapFieldClientConfig,
22
- NumberFieldClientConfig,
23
- PasswordFieldClientConfig,
24
- PhotoFieldClientConfig,
25
- RichTextFieldClientConfig,
26
- SelectFieldClientConfig,
27
- SelectMultipleFieldClientConfig,
28
- SlugFieldClientConfig,
29
- TextAreaFieldClientConfig,
30
- TextFieldClientConfig,
31
- VideoFieldClientConfig,
32
- } from 'nextjs-cms/core/fields'
33
-
34
- import {
35
- numberFieldSchema,
36
- textFieldSchema,
37
- selectFieldSchema,
38
- selectMultipleFieldSchema,
39
- dateFieldSchema,
40
- dateRangeFieldSchema,
41
- checkboxFieldSchema,
42
- textareaFieldSchema,
43
- richTextFieldSchema,
44
- photoFieldSchema,
45
- documentFieldSchema,
46
- videoFieldSchema,
47
- colorFieldSchema,
48
- mapFieldSchema,
49
- passwordFieldSchema,
50
- slugFieldSchema,
51
- } from 'nextjs-cms/validators'
52
-
53
- import { ConditionalField, FieldType } from 'nextjs-cms/core/types'
54
- import { configLastUpdated } from '@/components/form/helpers/util'
55
- import PhotoGallery from '../PhotoGallery'
56
- import { useSession } from 'nextjs-cms/auth/react'
57
- import { LocalizationProvider } from '@/components/form/ContentLocaleContext'
58
-
59
- export default function Form({
60
- formType,
61
- data,
62
- dropzoneRef,
63
- variantRef,
64
- handleSubmit,
65
- isSubmitting,
66
- response,
67
- progress,
68
- progressVariant,
69
- buttonType = 'big',
70
- submitSuccessCount = 0,
71
- contentLocale,
72
- defaultLocale,
73
- onDirtyChange,
74
- }: {
75
- formType?: 'new' | 'edit'
76
- data:
77
- | RouterOutputs['hasItemsSections']['newItem']
78
- | RouterOutputs['hasItemsSections']['editItem']
79
- | RouterOutputs['simpleSections']['create']
80
- | {
81
- section: {
82
- name: string
83
- gallery?: boolean
84
- title?: { section?: string; singular?: string; plural?: string }
85
- configFile?: string
86
- }
87
- inputGroups:
88
- | {
89
- groupId: number | undefined
90
- groupTitle: string
91
- groupOrder: number
92
- inputs: {
93
- type: FieldType
94
- name: string
95
- label: string
96
- required: boolean
97
- conditionalFields: ConditionalField[]
98
- placeholder?: string
99
- readonly: boolean
100
- value: any
101
- }[]
102
- }[]
103
- | undefined
104
- }
105
- dropzoneRef?: RefObject<DropzoneHandles | null>
106
- variantRef?: RefObject<VariantHandles[]>
107
- handleSubmit: any
108
- isSubmitting: boolean
109
- response?: any
110
- progress?: number
111
- progressVariant?: 'determinate' | 'query'
112
- buttonType?: 'big' | 'small'
113
- submitSuccessCount?: number
114
- contentLocale?: { code: string; label: string; rtl?: boolean }
115
- defaultLocale?: { code: string; label: string; rtl?: boolean }
116
- onDirtyChange?: (isDirty: boolean) => void
117
- }) {
118
- const t = useI18n()
119
- const session = useSession()
120
- const language = session?.data?.user?.language
121
-
122
- // When editing a non-default locale, only show fields marked as localized
123
- const isTranslationMode = !!(contentLocale && defaultLocale && contentLocale.code !== defaultLocale.code)
124
-
125
- const filterInputsForLocale = <T,>(inputs: T[]): T[] => {
126
- if (!isTranslationMode) return inputs
127
- return inputs.filter((input) => (input as any).localized)
128
- }
129
-
130
- const hasNoLocalizedFields =
131
- isTranslationMode &&
132
- (data.inputGroups?.every((g) => filterInputsForLocale(g.inputs as any[]).length === 0) ?? true)
133
-
134
- let schema = z.object({})
135
- /**
136
- * Construct the schema for the form
137
- */
138
- data.inputGroups?.forEach((inputGroup) => {
139
- filterInputsForLocale(inputGroup.inputs as any[]).forEach((input: any) => {
140
- if (input.readonly) return
141
- switch (input.type) {
142
- case 'select_multiple':
143
- schema = schema.extend({
144
- [input.name]: selectMultipleFieldSchema(input as SelectMultipleFieldClientConfig, language),
145
- })
146
- break
147
- case 'select':
148
- schema = schema.extend({
149
- [input.name]: selectFieldSchema(input as SelectFieldClientConfig, language),
150
- })
151
- break
152
-
153
- case 'date':
154
- schema = schema.extend({
155
- [input.name]: dateFieldSchema(input as DateFieldClientConfig, language),
156
- })
157
- break
158
-
159
- case 'date_range':
160
- schema = schema.extend(
161
- dateRangeFieldSchema(input as DateRangeFieldClientConfig, language),
162
- )
163
- break
164
-
165
- case 'checkbox':
166
- schema = schema.extend({
167
- [input.name]: checkboxFieldSchema(input as CheckboxFieldClientConfig, language),
168
- })
169
- break
170
-
171
- case 'text':
172
- schema = schema.extend({
173
- [input.name]: textFieldSchema(input as TextFieldClientConfig, language),
174
- })
175
- break
176
-
177
- case 'textarea':
178
- schema = schema.extend({
179
- [input.name]: textareaFieldSchema(input as TextAreaFieldClientConfig, language),
180
- })
181
- break
182
-
183
- case 'rich_text':
184
- schema = schema.extend({
185
- [input.name]: richTextFieldSchema(input as RichTextFieldClientConfig, language),
186
- })
187
- break
188
-
189
- case 'photo':
190
- if (formType === 'edit' && !!input.value) break
191
- schema = schema.extend({
192
- [input.name]: photoFieldSchema(input as PhotoFieldClientConfig, language),
193
- })
194
- break
195
-
196
- case 'document':
197
- if (formType === 'edit' && !!input.value) break
198
- schema = schema.extend({
199
- [input.name]: documentFieldSchema(input as DocumentFieldClientConfig, language),
200
- })
201
- break
202
-
203
- case 'video':
204
- if (formType === 'edit' && !!input.value) break
205
- schema = schema.extend({
206
- [input.name]: videoFieldSchema(input as VideoFieldClientConfig, language),
207
- })
208
- break
209
-
210
- case 'number':
211
- schema = schema.extend({
212
- [input.name]: numberFieldSchema(input as NumberFieldClientConfig, language),
213
- })
214
- break
215
- case 'color':
216
- schema = schema.extend({
217
- [input.name]: colorFieldSchema(input as ColorFieldClientConfig, language),
218
- })
219
- break
220
-
221
- case 'map':
222
- schema = schema.extend({
223
- [input.name]: mapFieldSchema(input as MapFieldClientConfig, language),
224
- })
225
- break
226
-
227
- case 'password':
228
- schema = schema.extend({
229
- [input.name]: passwordFieldSchema(input as PasswordFieldClientConfig, language),
230
- })
231
- break
232
-
233
- case 'slug':
234
- schema = schema.extend({
235
- [input.name]: slugFieldSchema(input as SlugFieldClientConfig, language),
236
- })
237
- break
238
- }
239
- })
240
- })
241
-
242
- const methods = useForm({
243
- resolver: zodResolver(schema),
244
- })
245
-
246
- const { dirtyFields } = methods.formState
247
- const hasDirtyFields = Object.keys(dirtyFields).length > 0
248
- useEffect(() => {
249
- onDirtyChange?.(hasDirtyFields)
250
- }, [hasDirtyFields, onDirtyChange])
251
-
252
- return (
253
- <LocalizationProvider value={contentLocale ?? null}>
254
- <FormProvider {...methods}>
255
- <form
256
- method='post'
257
- onSubmit={(e) => {
258
- e.preventDefault()
259
- // e.stopPropagation()
260
- methods.handleSubmit((data) => {
261
- handleSubmit(new FormData(e.target as HTMLFormElement))
262
- })(e)
263
- }}
264
- encType='multipart/form-data'
265
- >
266
- <div className='w-full' data-section-schema-version={configLastUpdated}>
267
- {/*<ContainerBox title={formType ? t(formType === 'new' ? 'add_new' : 'edit') : undefined}>*/}
268
- <div className='p-4'>
269
- <div className=''>
270
- {data.inputGroups ? (
271
- <div className='flex flex-col gap-4'>
272
- {hasNoLocalizedFields ? (
273
- <div className='rounded border border-amber-300 bg-amber-50 p-4 text-amber-800 dark:border-amber-700 dark:bg-amber-950 dark:text-amber-300'>
274
- <h3 className='font-semibold'>{t('noLocalizedFields')}</h3>
275
- <p className='mt-1'>
276
- {t('noLocalizedFieldsHint', { section: typeof data.section.title === 'object' ? (data.section.title?.section ?? '') : '', file: data.section.configFile })}
277
- </p>
278
- </div>
279
- ) : (
280
- <>
281
- {data.inputGroups.length > 0
282
- ? data.inputGroups.map((inputGroup, index: number) => {
283
- const filteredInputs = filterInputsForLocale(inputGroup.inputs as any[])
284
- if (filteredInputs.length === 0) return null
285
- return (
286
- <ContainerBox title={inputGroup.groupTitle} key={index}>
287
- <FormInputs
288
- inputs={filteredInputs}
289
- sectionName={data.section.name}
290
- submitSuccessCount={submitSuccessCount}
291
- />
292
- </ContainerBox>
293
- )
294
- })
295
- : null}
296
- {data.section.gallery && !isTranslationMode ? (
297
- <>
298
- <div className='w-full'>
299
- <PhotoGallery sectionName={data.section.name} gallery={'gallery' in data ? data.gallery : []} />
300
- </div>
301
- <div className='w-full'>
302
- <Dropzone ref={dropzoneRef} />
303
- </div>
304
- </>
305
- ) : null}
306
-
307
- {/*{data.section.variants && data.section.variants.length > 0 ? (
308
- <div className='w-full'>
309
- <div className='flex flex-col gap-4'>
310
- {data.section.variants.map((variant, index) => {
311
- // Only one variant is allowed for now
312
- // I have to find a way to make multiple variantRef in order to handle multiple variants
313
- if (index > 0) return
314
-
315
- return (
316
- <NewVariantComponent
317
- ref={(el) => (variantRef.current[index] = el)}
318
- key={index}
319
- section={section}
320
- variantInfo={variant.info}
321
- xsrfToken={xsrfToken}
322
- />
323
- )
324
- })}
325
- </div>
326
- </div>
327
- ) : null}*/}
328
-
329
- <div className='flex flex-col gap-3 pb-4'>
330
- <div className=''>
331
- <button
332
- className={classNames({
333
- 'w-full': buttonType === 'big',
334
- 'float-end': buttonType === 'small',
335
- 'rounded bg-linear-to-r px-4 py-2 font-bold text-white drop-shadow-sm':
336
- true,
337
- 'from-emerald-700 via-green-700 to-green-500 dark:from-blue-800 dark:via-sky-800 dark:to-slate-500':
338
- !isSubmitting,
339
- 'from-gray-600 via-gray-500 to-gray-400': isSubmitting,
340
- })}
341
- type='submit'
342
- disabled={isSubmitting}
343
- >
344
- {isSubmitting
345
- ? t('loading')
346
- : t(formType === 'new' ? 'create' : 'save')}
347
- </button>
348
- {progressVariant && progress ? (
349
- isSubmitting ? (
350
- <div className='mt-0.5'>
351
- <ProgressBar variant={progressVariant} value={progress} />
352
- </div>
353
- ) : null
354
- ) : null}
355
- </div>
356
- {response ? <div className='w-full'>{response}</div> : null}
357
- </div>
358
- </>
359
- )}
360
- </div>
361
- ) : null}
362
- </div>
363
- </div>
364
- {/*</ContainerBox>*/}
365
- </div>
366
- </form>
367
- </FormProvider>
368
- </LocalizationProvider>
369
- )
370
- }
1
+ import type { RouterOutputs } from 'nextjs-cms/api'
2
+ import React, { RefObject, useCallback, useEffect } from 'react'
3
+ import ContainerBox from '@/components/ContainerBox'
4
+ import Dropzone, { DropzoneHandles } from '@/components/Dropzone'
5
+ import { LocalizationProvider } from '@/components/form/ContentLocaleContext'
6
+ import FormInputs from '@/components/form/FormInputs'
7
+ import { configLastUpdated } from '@/components/form/helpers/util'
8
+ import NewVariantComponent, { VariantHandles } from '@/components/NewVariantComponent'
9
+ import ProgressBar from '@/components/ProgressBar'
10
+ import { zodResolver } from '@hookform/resolvers/zod'
11
+ import classNames from 'classnames'
12
+ import { useSession } from 'nextjs-cms/auth/react'
13
+ import {
14
+ CheckboxFieldClientConfig,
15
+ ColorFieldClientConfig,
16
+ DateFieldClientConfig,
17
+ DateRangeFieldClientConfig,
18
+ DocumentFieldClientConfig,
19
+ MapFieldClientConfig,
20
+ NumberFieldClientConfig,
21
+ PasswordFieldClientConfig,
22
+ PhotoFieldClientConfig,
23
+ RichTextFieldClientConfig,
24
+ SelectFieldClientConfig,
25
+ SelectMultipleFieldClientConfig,
26
+ SlugFieldClientConfig,
27
+ TextAreaFieldClientConfig,
28
+ TextFieldClientConfig,
29
+ VideoFieldClientConfig,
30
+ } from 'nextjs-cms/core/fields'
31
+ import { ConditionalField, FieldType } from 'nextjs-cms/core/types'
32
+ import { useI18n } from 'nextjs-cms/translations/client'
33
+ import {
34
+ checkboxFieldSchema,
35
+ colorFieldSchema,
36
+ dateFieldSchema,
37
+ dateRangeFieldSchema,
38
+ documentFieldSchema,
39
+ mapFieldSchema,
40
+ numberFieldSchema,
41
+ passwordFieldSchema,
42
+ photoFieldSchema,
43
+ richTextFieldSchema,
44
+ selectFieldSchema,
45
+ selectMultipleFieldSchema,
46
+ slugFieldSchema,
47
+ textareaFieldSchema,
48
+ textFieldSchema,
49
+ videoFieldSchema,
50
+ } from 'nextjs-cms/validators'
51
+ import { FormProvider, useForm } from 'react-hook-form'
52
+ import * as z from 'zod'
53
+
54
+ import PhotoGallery from '../PhotoGallery'
55
+
56
+ export const revalidate = 1
57
+
58
+ export default function Form({
59
+ formType,
60
+ data,
61
+ dropzoneRef,
62
+ variantRef,
63
+ handleSubmit,
64
+ isSubmitting,
65
+ response,
66
+ progress,
67
+ progressVariant,
68
+ buttonType = 'big',
69
+ submitSuccessCount = 0,
70
+ contentLocale,
71
+ defaultLocale,
72
+ onDirtyChange,
73
+ }: {
74
+ formType?: 'new' | 'edit'
75
+ data:
76
+ | RouterOutputs['hasItemsSections']['newItem']
77
+ | RouterOutputs['hasItemsSections']['editItem']
78
+ | RouterOutputs['simpleSections']['create']
79
+ | {
80
+ section: {
81
+ name: string
82
+ gallery?: { localized?: boolean } | null
83
+ title?: { section?: string; singular?: string; plural?: string }
84
+ configFile?: string
85
+ }
86
+ inputGroups:
87
+ | {
88
+ groupId: number | undefined
89
+ groupTitle: string
90
+ groupOrder: number
91
+ inputs: {
92
+ type: FieldType
93
+ name: string
94
+ label: string
95
+ required: boolean
96
+ conditionalFields: ConditionalField[]
97
+ placeholder?: string
98
+ readonly: boolean
99
+ value: any
100
+ }[]
101
+ }[]
102
+ | undefined
103
+ }
104
+ dropzoneRef?: RefObject<DropzoneHandles | null>
105
+ variantRef?: RefObject<VariantHandles[]>
106
+ handleSubmit: any
107
+ isSubmitting: boolean
108
+ response?: any
109
+ progress?: number
110
+ progressVariant?: 'determinate' | 'query'
111
+ buttonType?: 'big' | 'small'
112
+ submitSuccessCount?: number
113
+ contentLocale?: { code: string; label: string; rtl?: boolean }
114
+ defaultLocale?: { code: string; label: string; rtl?: boolean }
115
+ onDirtyChange?: (isDirty: boolean) => void
116
+ }) {
117
+ const t = useI18n()
118
+ const session = useSession()
119
+ const language = session?.data?.user?.language
120
+
121
+ // When editing a non-default locale, only show fields marked as localized
122
+ const isTranslationMode = !!(contentLocale && defaultLocale && contentLocale.code !== defaultLocale.code)
123
+ const sectionGallery = data.section?.gallery as { localized?: boolean } | null | undefined
124
+ const showGallery = !!sectionGallery && (!isTranslationMode || sectionGallery.localized === true)
125
+
126
+ const filterInputsForLocale = <T,>(inputs: T[]): T[] => {
127
+ if (!isTranslationMode) return inputs
128
+ return inputs.filter((input) => (input as any).localized)
129
+ }
130
+
131
+ const hasNoLocalizedContent =
132
+ isTranslationMode &&
133
+ (data.inputGroups?.every((g) => filterInputsForLocale(g.inputs as any[]).length === 0) ?? true) &&
134
+ sectionGallery?.localized !== true
135
+
136
+ let schema = z.object({})
137
+ /**
138
+ * Construct the schema for the form
139
+ */
140
+ data.inputGroups?.forEach((inputGroup) => {
141
+ filterInputsForLocale(inputGroup.inputs as any[]).forEach((input: any) => {
142
+ if (input.readonly) return
143
+ switch (input.type) {
144
+ case 'select_multiple':
145
+ schema = schema.extend({
146
+ [input.name]: selectMultipleFieldSchema(input as SelectMultipleFieldClientConfig, language),
147
+ })
148
+ break
149
+ case 'select':
150
+ schema = schema.extend({
151
+ [input.name]: selectFieldSchema(input as SelectFieldClientConfig, language),
152
+ })
153
+ break
154
+
155
+ case 'date':
156
+ schema = schema.extend({
157
+ [input.name]: dateFieldSchema(input as DateFieldClientConfig, language),
158
+ })
159
+ break
160
+
161
+ case 'date_range':
162
+ schema = schema.extend(dateRangeFieldSchema(input as DateRangeFieldClientConfig, language))
163
+ break
164
+
165
+ case 'checkbox':
166
+ schema = schema.extend({
167
+ [input.name]: checkboxFieldSchema(input as CheckboxFieldClientConfig, language),
168
+ })
169
+ break
170
+
171
+ case 'text':
172
+ schema = schema.extend({
173
+ [input.name]: textFieldSchema(input as TextFieldClientConfig, language),
174
+ })
175
+ break
176
+
177
+ case 'textarea':
178
+ schema = schema.extend({
179
+ [input.name]: textareaFieldSchema(input as TextAreaFieldClientConfig, language),
180
+ })
181
+ break
182
+
183
+ case 'rich_text':
184
+ schema = schema.extend({
185
+ [input.name]: richTextFieldSchema(input as RichTextFieldClientConfig, language),
186
+ })
187
+ break
188
+
189
+ case 'photo':
190
+ if (formType === 'edit' && !!input.value) break
191
+ schema = schema.extend({
192
+ [input.name]: photoFieldSchema(input as PhotoFieldClientConfig, language),
193
+ })
194
+ break
195
+
196
+ case 'document':
197
+ if (formType === 'edit' && !!input.value) break
198
+ schema = schema.extend({
199
+ [input.name]: documentFieldSchema(input as DocumentFieldClientConfig, language),
200
+ })
201
+ break
202
+
203
+ case 'video':
204
+ if (formType === 'edit' && !!input.value) break
205
+ schema = schema.extend({
206
+ [input.name]: videoFieldSchema(input as VideoFieldClientConfig, language),
207
+ })
208
+ break
209
+
210
+ case 'number':
211
+ schema = schema.extend({
212
+ [input.name]: numberFieldSchema(input as NumberFieldClientConfig, language),
213
+ })
214
+ break
215
+ case 'color':
216
+ schema = schema.extend({
217
+ [input.name]: colorFieldSchema(input as ColorFieldClientConfig, language),
218
+ })
219
+ break
220
+
221
+ case 'map':
222
+ schema = schema.extend({
223
+ [input.name]: mapFieldSchema(input as MapFieldClientConfig, language),
224
+ })
225
+ break
226
+
227
+ case 'password':
228
+ schema = schema.extend({
229
+ [input.name]: passwordFieldSchema(input as PasswordFieldClientConfig, language),
230
+ })
231
+ break
232
+
233
+ case 'slug':
234
+ schema = schema.extend({
235
+ [input.name]: slugFieldSchema(input as SlugFieldClientConfig, language),
236
+ })
237
+ break
238
+ }
239
+ })
240
+ })
241
+
242
+ const methods = useForm({
243
+ resolver: zodResolver(schema),
244
+ })
245
+
246
+ const { dirtyFields } = methods.formState
247
+ const hasDirtyFields = Object.keys(dirtyFields).length > 0
248
+ useEffect(() => {
249
+ onDirtyChange?.(hasDirtyFields)
250
+ }, [hasDirtyFields, onDirtyChange])
251
+
252
+ return (
253
+ <LocalizationProvider value={contentLocale ?? null}>
254
+ <FormProvider {...methods}>
255
+ <form
256
+ method='post'
257
+ onSubmit={(e) => {
258
+ e.preventDefault()
259
+ // e.stopPropagation()
260
+ methods.handleSubmit((data) => {
261
+ handleSubmit(new FormData(e.target as HTMLFormElement))
262
+ })(e)
263
+ }}
264
+ encType='multipart/form-data'
265
+ >
266
+ <div className='w-full' data-section-schema-version={configLastUpdated}>
267
+ {/*<ContainerBox title={formType ? t(formType === 'new' ? 'add_new' : 'edit') : undefined}>*/}
268
+ <div className='p-4'>
269
+ <div className=''>
270
+ {data.inputGroups ? (
271
+ <div className='flex flex-col gap-4'>
272
+ {hasNoLocalizedContent ? (
273
+ <div className='rounded border border-amber-300 bg-amber-50 p-4 text-amber-800 dark:border-amber-700 dark:bg-amber-950 dark:text-amber-300'>
274
+ <h3 className='font-semibold'>{t('noLocalizedFields')}</h3>
275
+ <p className='mt-1'>
276
+ {t('noLocalizedFieldsHint', {
277
+ section:
278
+ typeof data.section.title === 'object'
279
+ ? (data.section.title?.section ?? '')
280
+ : '',
281
+ file: data.section.configFile,
282
+ })}
283
+ </p>
284
+ </div>
285
+ ) : (
286
+ <>
287
+ {data.inputGroups.length > 0
288
+ ? data.inputGroups.map((inputGroup, index: number) => {
289
+ const filteredInputs = filterInputsForLocale(
290
+ inputGroup.inputs as any[],
291
+ )
292
+ if (filteredInputs.length === 0) return null
293
+ return (
294
+ <ContainerBox title={inputGroup.groupTitle} key={index}>
295
+ <FormInputs
296
+ inputs={filteredInputs}
297
+ sectionName={data.section.name}
298
+ submitSuccessCount={submitSuccessCount}
299
+ />
300
+ </ContainerBox>
301
+ )
302
+ })
303
+ : null}
304
+ {showGallery ? (
305
+ <>
306
+ <div className='w-full'>
307
+ <PhotoGallery
308
+ sectionName={data.section.name}
309
+ gallery={'gallery' in data ? data.gallery : []}
310
+ localized={sectionGallery?.localized === true}
311
+ locale={contentLocale?.code}
312
+ />
313
+ </div>
314
+ <div className='w-full'>
315
+ <Dropzone ref={dropzoneRef} />
316
+ </div>
317
+ </>
318
+ ) : null}
319
+
320
+ {/*{data.section.variants && data.section.variants.length > 0 ? (
321
+ <div className='w-full'>
322
+ <div className='flex flex-col gap-4'>
323
+ {data.section.variants.map((variant, index) => {
324
+ // Only one variant is allowed for now
325
+ // I have to find a way to make multiple variantRef in order to handle multiple variants
326
+ if (index > 0) return
327
+
328
+ return (
329
+ <NewVariantComponent
330
+ ref={(el) => (variantRef.current[index] = el)}
331
+ key={index}
332
+ section={section}
333
+ variantInfo={variant.info}
334
+ xsrfToken={xsrfToken}
335
+ />
336
+ )
337
+ })}
338
+ </div>
339
+ </div>
340
+ ) : null}*/}
341
+
342
+ <div className='flex flex-col gap-3 pb-4'>
343
+ <div className=''>
344
+ <button
345
+ className={classNames({
346
+ 'w-full': buttonType === 'big',
347
+ 'float-end': buttonType === 'small',
348
+ 'bg-linear-to-r rounded px-4 py-2 font-bold text-white drop-shadow-sm': true,
349
+ 'from-emerald-700 via-green-700 to-green-500 dark:from-blue-800 dark:via-sky-800 dark:to-slate-500':
350
+ !isSubmitting,
351
+ 'from-gray-600 via-gray-500 to-gray-400': isSubmitting,
352
+ })}
353
+ type='submit'
354
+ disabled={isSubmitting}
355
+ >
356
+ {isSubmitting
357
+ ? t('loading')
358
+ : t(formType === 'new' ? 'create' : 'save')}
359
+ </button>
360
+ {progressVariant && progress ? (
361
+ isSubmitting ? (
362
+ <div className='mt-0.5'>
363
+ <ProgressBar
364
+ variant={progressVariant}
365
+ value={progress}
366
+ />
367
+ </div>
368
+ ) : null
369
+ ) : null}
370
+ </div>
371
+ {response ? <div className='w-full'>{response}</div> : null}
372
+ </div>
373
+ </>
374
+ )}
375
+ </div>
376
+ ) : null}
377
+ </div>
378
+ </div>
379
+ {/*</ContainerBox>*/}
380
+ </div>
381
+ </form>
382
+ </FormProvider>
383
+ </LocalizationProvider>
384
+ )
385
+ }
@@ -70,6 +70,11 @@ function getTimeBoundForSelectedDate(selectedDate: Date | undefined, bound: Date
70
70
  return dayjs(bound).format('HH:mm:ss')
71
71
  }
72
72
 
73
+ function formatHiddenValue(date: Date | undefined, format: DateFieldClientConfig['format']): string {
74
+ if (!date) return ''
75
+ return dayjs(date).format(format !== 'date' ? 'YYYY-MM-DD HH:mm:ss' : 'YYYY-MM-DD')
76
+ }
77
+
73
78
  export default function DateFormInput({ input }: { input: DateFieldClientConfig }) {
74
79
  const t = useI18n()
75
80
  const { control } = useFormContext()
@@ -108,16 +113,12 @@ export default function DateFormInput({ input }: { input: DateFieldClientConfig
108
113
  <input
109
114
  type='hidden'
110
115
  disabled={field.disabled}
111
- name={field.name}
112
- onBlur={field.onBlur}
113
- value={
114
- date
115
- ? dayjs(date).format(input.format !== 'date' ? 'YYYY-MM-DD HH:mm:ss' : 'YYYY-MM-DD')
116
- : undefined
117
- }
118
- ref={field.ref}
119
- className='rounded border p-2'
120
- />
116
+ name={field.name}
117
+ onBlur={field.onBlur}
118
+ value={formatHiddenValue(date, input.format)}
119
+ ref={field.ref}
120
+ className='rounded border p-2'
121
+ />
121
122
 
122
123
  <div className='flex flex-row items-start gap-2'>
123
124
  <div>
@@ -88,7 +88,7 @@ export default function DocumentFormInput({
88
88
  <CardContent className='flex flex-col gap-1'>
89
89
  <div className='mb-2 flex flex-col text-sm'>
90
90
  {input.maxFileSize ? (
91
- <div className='flex flex-wrap items-center gap-2'>
91
+ <div dir='ltr' className='flex flex-wrap items-center gap-2'>
92
92
  <InfoIcon size={14} />
93
93
  <span>{t('maxFileSize')}:</span>
94
94
  <Badge
@@ -97,7 +97,7 @@ export default function DocumentFormInput({
97
97
  </div>
98
98
  ) : null}
99
99
  {input.extensions ? (
100
- <div className='flex flex-wrap items-center gap-2'>
100
+ <div dir='ltr' className='flex flex-wrap items-center gap-2'>
101
101
  <InfoIcon size={14} />
102
102
  <span>{t('allowedExtensions')}:</span>
103
103
  <Badge variant={'secondary'}>{input.extensions.join(', ')}</Badge>
@@ -122,19 +122,17 @@ export default function PhotoFormInput({
122
122
  <CardContent className='flex flex-col gap-1'>
123
123
  <div className='mb-2 flex flex-col text-sm'>
124
124
  {input.size ? (
125
- <div className='flex flex-wrap items-center gap-2'>
125
+ <div dir='ltr' className='flex flex-wrap items-center gap-2'>
126
126
  <InfoIcon size={14} />
127
127
  <span>
128
- {t(
129
- 'strict' in input.size ? 'imageDimensionsMustBe' : 'imageRecommendedDimensions',
130
- )}
128
+ {t('strict' in input.size ? 'imageDimensionsMustBe' : 'imageRecommendedDimensions')}
131
129
  :
132
130
  </span>
133
131
  <Badge variant={'secondary'}>{`${input.size.width} x ${input.size.height}`}</Badge>
134
132
  </div>
135
133
  ) : null}
136
134
  {input.maxFileSize ? (
137
- <div className='flex flex-wrap items-center gap-2'>
135
+ <div dir='ltr' className='flex flex-wrap items-center gap-2'>
138
136
  <InfoIcon size={14} />
139
137
  <span>{t('maxFileSize')}:</span>
140
138
  <Badge
@@ -143,7 +141,7 @@ export default function PhotoFormInput({
143
141
  </div>
144
142
  ) : null}
145
143
  {input.extensions ? (
146
- <div className='flex flex-wrap items-center gap-2'>
144
+ <div dir='ltr' className='flex flex-wrap items-center gap-2'>
147
145
  <InfoIcon size={14} />
148
146
  <span>{t('allowedExtensions')}:</span>
149
147
  <Badge variant={'secondary'}>{input.extensions.join(', ')}</Badge>
@@ -172,7 +170,7 @@ export default function PhotoFormInput({
172
170
  <ChevronRight fontSize='large' />
173
171
  <div className='relative h-[150px] w-[150px]'>
174
172
  <X
175
- className='absolute -right-3 -top-3 z-10 cursor-pointer rounded-full border-2 border-gray-500 bg-white p-1'
173
+ className='absolute -top-3 -right-3 z-10 cursor-pointer rounded-full border-2 border-gray-500 bg-white p-1'
176
174
  onClick={clearSelectedFile}
177
175
  />
178
176
  <Image
@@ -209,7 +207,7 @@ export default function PhotoFormInput({
209
207
  <div className='w-[400px] max-w-full'>
210
208
  <button
211
209
  type='button'
212
- className='flex w-full flex-wrap items-center justify-center gap-1.5 rounded border bg-linear-to-r from-red-600 to-amber-500 p-2 text-center text-sm font-bold uppercase text-white drop-shadow-sm hover:from-red-400 hover:to-amber-400'
210
+ className='flex w-full flex-wrap items-center justify-center gap-1.5 rounded border bg-linear-to-r from-red-600 to-amber-500 p-2 text-center text-sm font-bold text-white uppercase drop-shadow-sm hover:from-red-400 hover:to-amber-400'
213
211
  onClick={() => {
214
212
  setModal({
215
213
  title: t('deletePhoto'),
@@ -249,7 +247,7 @@ export default function PhotoFormInput({
249
247
  <div className='dark:border-neutral my-2 flex w-[400px] max-w-full flex-col gap-1 rounded-lg border border-gray-400 p-2'>
250
248
  <button
251
249
  type='button'
252
- className='flex w-full flex-wrap items-center justify-center gap-1.5 rounded border bg-linear-to-r from-blue-700 to-sky-500 p-2 text-center text-sm font-bold uppercase text-white drop-shadow-sm hover:from-blue-500 hover:to-sky-400'
250
+ className='flex w-full flex-wrap items-center justify-center gap-1.5 rounded border bg-linear-to-r from-blue-700 to-sky-500 p-2 text-center text-sm font-bold text-white uppercase drop-shadow-sm hover:from-blue-500 hover:to-sky-400'
253
251
  onClick={() => {
254
252
  if (fileInputContainerRef.current?.firstChild) {
255
253
  ;(
@@ -58,7 +58,7 @@ export default function VideoFormInput({ input, sectionName }: { input: VideoFie
58
58
  <ChevronRight fontSize='large' />
59
59
  <div className='relative'>
60
60
  <X
61
- className='absolute -right-3 -top-3 cursor-pointer rounded-full border-2 border-gray-500 bg-white p-1'
61
+ className='absolute -top-3 -right-3 cursor-pointer rounded-full border-2 border-gray-500 bg-white p-1'
62
62
  onClick={() => {
63
63
  setImage(null)
64
64
  setFileName(t('noFileSelected'))
@@ -99,7 +99,7 @@ export default function VideoFormInput({ input, sectionName }: { input: VideoFie
99
99
  <div className='w-full flex-1 md:flex-[0.5]'>
100
100
  <button
101
101
  type='button'
102
- className='w-full rounded border bg-linear-to-r from-blue-700 to-sky-500 p-2 text-center text-sm font-bold uppercase text-white drop-shadow-sm'
102
+ className='w-full rounded border bg-linear-to-r from-blue-700 to-sky-500 p-2 text-center text-sm font-bold text-white uppercase drop-shadow-sm'
103
103
  onClick={() => {
104
104
  if (fileInputContainerRef.current?.firstChild) {
105
105
  ;(fileInputContainerRef.current.firstChild as HTMLInputElement).click()
@@ -70,7 +70,7 @@
70
70
  "nanoid": "^5.1.2",
71
71
  "next": "16.1.1",
72
72
  "next-themes": "^0.4.6",
73
- "nextjs-cms": "0.9.20",
73
+ "nextjs-cms": "0.9.21",
74
74
  "plaiceholder": "^3.0.0",
75
75
  "prettier-plugin-tailwindcss": "^0.7.2",
76
76
  "qrcode": "^1.5.4",