create-nextjs-cms 0.8.10 → 0.9.1

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 (36) hide show
  1. package/package.json +2 -2
  2. package/templates/default/app/(auth)/auth/login/LoginPage.tsx +9 -9
  3. package/templates/default/app/(auth)/auth-language-provider.tsx +34 -0
  4. package/templates/default/app/(auth)/layout.tsx +10 -10
  5. package/templates/default/app/(rootLayout)/edit/[section]/[itemId]/page.tsx +4 -1
  6. package/templates/default/app/(rootLayout)/layout.tsx +5 -5
  7. package/templates/default/app/(rootLayout)/section/[section]/page.tsx +4 -1
  8. package/templates/default/app/api/auth/route.ts +2 -2
  9. package/templates/default/app/api/submit/section/item/[slug]/route.ts +32 -3
  10. package/templates/default/app/api/submit/section/simple/route.ts +32 -3
  11. package/templates/default/app/globals.css +9 -0
  12. package/templates/default/cms.config.ts +4 -4
  13. package/templates/default/components/ItemEditPage.tsx +82 -2
  14. package/templates/default/components/LocaleSwitcher.tsx +89 -0
  15. package/templates/default/components/Navbar.tsx +5 -5
  16. package/templates/default/components/NewPage.tsx +1 -0
  17. package/templates/default/components/SectionPage.tsx +81 -1
  18. package/templates/default/components/form/ContentLocaleContext.tsx +11 -0
  19. package/templates/default/components/form/Form.tsx +48 -5
  20. package/templates/default/components/form/FormInputs.tsx +16 -7
  21. package/templates/default/components/form/helpers/_section-hot-reload.js +1 -1
  22. package/templates/default/components/form/inputs/NumberFormInput.tsx +2 -1
  23. package/templates/default/components/form/inputs/PhotoFormInput.tsx +168 -112
  24. package/templates/default/components/form/inputs/RichTextFormInput.tsx +3 -0
  25. package/templates/default/components/form/inputs/SelectFormInput.tsx +1 -1
  26. package/templates/default/components/form/inputs/TagsFormInput.tsx +6 -2
  27. package/templates/default/components/form/inputs/TextFormInput.tsx +3 -0
  28. package/templates/default/components/form/inputs/TextareaFormInput.tsx +3 -0
  29. package/templates/default/components/{locale-dropdown.tsx → language-dropdown.tsx} +74 -74
  30. package/templates/default/components/{locale-picker.tsx → language-picker.tsx} +85 -85
  31. package/templates/default/components/login-language-dropdown.tsx +46 -0
  32. package/templates/default/components/ui/alert.tsx +2 -1
  33. package/templates/default/dynamic-schemas/schema.ts +475 -448
  34. package/templates/default/package.json +1 -1
  35. package/templates/default/app/(auth)/auth-locale-provider.tsx +0 -34
  36. package/templates/default/components/login-locale-dropdown.tsx +0 -46
@@ -4,7 +4,7 @@ import { MoonIcon, SunIcon, BellIcon, HamburgerMenuIcon } from '@radix-ui/react-
4
4
  import { useTheme } from 'next-themes'
5
5
  import Link from 'next/link'
6
6
  import { useI18n } from 'nextjs-cms/translations/client'
7
- import { RTL_LOCALES } from 'nextjs-cms/translations'
7
+ import { RTL_LANGUAGES } from 'nextjs-cms/translations'
8
8
  import { trpc } from '@/app/_trpc/client'
9
9
  import ProtectedImage from '@/components/ProtectedImage'
10
10
  import { Spinner } from '@/components/ui/spinner'
@@ -24,7 +24,7 @@ import {
24
24
  import { useToast } from '@/components/ui/use-toast'
25
25
  import { logout, useSession } from 'nextjs-cms/auth/react'
26
26
  import ThemeToggle from './theme-toggle'
27
- import LocaleDropdown from './locale-dropdown'
27
+ import LanguageDropdown from './language-dropdown'
28
28
  type Props = {
29
29
  /**
30
30
  * Allows the parent component to modify the state when the
@@ -44,8 +44,8 @@ export default function Navbar(props: Props) {
44
44
  const t = useI18n()
45
45
  const session = useSession()
46
46
  const { toast } = useToast()
47
- const locale = session?.data?.user?.locale ?? 'en'
48
- const isRTL = RTL_LOCALES.has(locale)
47
+ const language = session?.data?.user?.language ?? 'en'
48
+ const isRTL = RTL_LANGUAGES.has(language)
49
49
  const logsQuery = trpc.logs.list.useQuery(
50
50
  {
51
51
  limit: 20,
@@ -185,7 +185,7 @@ export default function Navbar(props: Props) {
185
185
  </Link>
186
186
  </DropdownMenuContent>
187
187
  </DropdownMenu>
188
- <LocaleDropdown />
188
+ <LanguageDropdown />
189
189
  <ThemeToggle />
190
190
  {/* Profile dropdown */}
191
191
  <DropdownMenu>
@@ -197,6 +197,7 @@ export default function NewPage({
197
197
  variantRef={variantRef}
198
198
  handleSubmit={handleSubmit}
199
199
  isSubmitting={isSubmitting}
200
+ contentLocale={data.defaultLocale ?? undefined}
200
201
  />
201
202
  </div>
202
203
 
@@ -1,7 +1,7 @@
1
1
  'use client'
2
2
 
3
3
  import { useAxiosPrivate } from 'nextjs-cms/auth/hooks'
4
- import { RefObject, useEffect, useRef, useState } from 'react'
4
+ import { RefObject, useCallback, useEffect, useRef, useState } from 'react'
5
5
  import { useI18n } from 'nextjs-cms/translations/client'
6
6
  import { AxiosError } from 'axios'
7
7
  import InfoCard from '@/components/InfoCard'
@@ -10,21 +10,55 @@ import { useToast } from '@/components/ui/use-toast'
10
10
  import { trpc } from '@/app/_trpc/client'
11
11
  import Form from '@/components/form/Form'
12
12
  import ErrorComponent from './ErrorComponent'
13
+ import { useRouter, useSearchParams } from 'next/navigation'
14
+ import LocaleSwitcher from '@/components/LocaleSwitcher'
15
+ import { Trash2 } from 'lucide-react'
16
+ import { Alert, AlertDescription } from './ui/alert'
13
17
 
14
18
  export default function SectionPage({ section }: { section: string }) {
15
19
  const t = useI18n()
16
20
  const [progress, setProgress] = useState(0)
17
21
  const [progressVariant, setProgressVariant] = useState<'determinate' | 'query'>('determinate')
18
22
  const [isSubmitting, setIsSubmitting] = useState(false)
23
+ const [submitSuccessCount, setSubmitSuccessCount] = useState(0)
19
24
  const [response, setResponse] = useState<any>(null)
20
25
  const axiosPrivate = useAxiosPrivate()
21
26
  const controller = new AbortController()
22
27
  const { toast } = useToast()
28
+ const router = useRouter()
29
+ const searchParams = useSearchParams()
30
+ const locale = searchParams.get('locale') ?? undefined
31
+ const [formDirty, setFormDirty] = useState(false)
32
+ const handleDirtyChange = useCallback((isDirty: boolean) => setFormDirty(isDirty), [])
23
33
  const dropzoneRef: RefObject<DropzoneHandles | null> = useRef(null)
24
34
 
25
35
  const [data, {refetch, error, isError}] = trpc.simpleSections.create.useSuspenseQuery({
26
36
  sectionName: section,
37
+ locale,
27
38
  })
39
+ const deleteLocaleMutation = trpc.simpleSections.deleteLocaleTranslation.useMutation()
40
+
41
+ const handleDeleteLocale = async () => {
42
+ if (!locale) return
43
+ if (!window.confirm(t('deleteLocaleTranslationText'))) return
44
+
45
+ try {
46
+ await deleteLocaleMutation.mutateAsync({
47
+ sectionName: section,
48
+ locale,
49
+ })
50
+ toast({
51
+ variant: 'success',
52
+ description: t('localeTranslationDeleted'),
53
+ })
54
+ router.push(`/section/${section}`)
55
+ } catch (error: any) {
56
+ toast({
57
+ variant: 'destructive',
58
+ description: error?.message ?? t('somethingWentWrong'),
59
+ })
60
+ }
61
+ }
28
62
 
29
63
  const handleSubmit = async (formData: FormData) => {
30
64
  setIsSubmitting(true)
@@ -33,6 +67,11 @@ export default function SectionPage({ section }: { section: string }) {
33
67
  formData.append('sectionName', section)
34
68
  formData.append('formType', 'edit')
35
69
  formData.append('sectionType', 'has_items')
70
+
71
+ if (locale) {
72
+ formData.append('locale', locale)
73
+ }
74
+
36
75
  try {
37
76
  const res = await axiosPrivate.put(`/submit/section/simple`, formData, {
38
77
  signal: controller.signal,
@@ -66,6 +105,7 @@ export default function SectionPage({ section }: { section: string }) {
66
105
 
67
106
  // Refetch the page
68
107
  await refetch()
108
+ setSubmitSuccessCount((currentCount) => currentCount + 1)
69
109
  }
70
110
  }
71
111
  } catch (error: AxiosError | any) {
@@ -108,7 +148,43 @@ export default function SectionPage({ section }: { section: string }) {
108
148
  /{t('edit')} {data.section?.title}
109
149
  </span>
110
150
  </div>
151
+ {data.localization && (
152
+ <div className="px-8 pt-4">
153
+ <LocaleSwitcher
154
+ section={section}
155
+ currentLocale={data.localization.currentLocale}
156
+ defaultLocale={data.localization.defaultLocale}
157
+ existingTranslations={data.localization.existingTranslations}
158
+ locales={data.localization.locales}
159
+ hasUnsavedChanges={formDirty}
160
+ basePath={`/section/${section}`}
161
+ />
162
+ {locale && (
163
+ <Alert variant='info' className='mt-2 w-full'>
164
+ <AlertDescription className='text-md font-bold'>
165
+ <div className='flex items-center justify-between'>
166
+ <span>
167
+ {t('editingContentTranslation', { locale })}
168
+ </span>
169
+ {data.localization.existingTranslations.includes(locale) && (
170
+ <button
171
+ type='button'
172
+ onClick={handleDeleteLocale}
173
+ disabled={deleteLocaleMutation.isPending}
174
+ className='inline-flex items-center gap-1.5 rounded-md bg-red-600 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-red-700 disabled:opacity-50'
175
+ >
176
+ <Trash2 className='h-3.5 w-3.5' />
177
+ {deleteLocaleMutation.isPending ? t('loading') : t('deleteLocaleTranslation')}
178
+ </button>
179
+ )}
180
+ </div>
181
+ </AlertDescription>
182
+ </Alert>
183
+ )}
184
+ </div>
185
+ )}
111
186
  <Form
187
+ key={locale ?? 'default'}
112
188
  formType='edit'
113
189
  progressVariant={progressVariant}
114
190
  response={response}
@@ -117,6 +193,10 @@ export default function SectionPage({ section }: { section: string }) {
117
193
  dropzoneRef={dropzoneRef}
118
194
  handleSubmit={handleSubmit}
119
195
  isSubmitting={isSubmitting}
196
+ submitSuccessCount={submitSuccessCount}
197
+ contentLocale={data.localization?.currentLocale}
198
+ defaultLocale={data.localization?.defaultLocale}
199
+ onDirtyChange={handleDirtyChange}
120
200
  />
121
201
  </div>
122
202
  ) : null}
@@ -0,0 +1,11 @@
1
+ import { createContext, useContext } from 'react'
2
+
3
+ type LocaleConfig = { code: string; label: string; rtl?: boolean }
4
+
5
+ const LocalizationContext = createContext<LocaleConfig | null>(null)
6
+
7
+ export const LocalizationProvider = LocalizationContext.Provider
8
+
9
+ export function useLocale() {
10
+ return useContext(LocalizationContext)
11
+ }
@@ -7,7 +7,7 @@ import Dropzone, { DropzoneHandles } from '@/components/Dropzone'
7
7
  import NewVariantComponent, { VariantHandles } from '@/components/NewVariantComponent'
8
8
  import classNames from 'classnames'
9
9
  import ProgressBar from '@/components/ProgressBar'
10
- import React, { RefObject, useEffect } from 'react'
10
+ import React, { RefObject, useCallback, useEffect } from 'react'
11
11
  import type { RouterOutputs } from 'nextjs-cms/api'
12
12
  import * as z from 'zod'
13
13
  import { zodResolver } from '@hookform/resolvers/zod'
@@ -52,6 +52,7 @@ import { ConditionalField, FieldType } from 'nextjs-cms/core/types'
52
52
  import { configLastUpdated } from '@/components/form/helpers/util'
53
53
  import PhotoGallery from '../PhotoGallery'
54
54
  import { useSession } from 'nextjs-cms/auth/react'
55
+ import { LocalizationProvider } from '@/components/form/ContentLocaleContext'
55
56
 
56
57
  export default function Form({
57
58
  formType,
@@ -64,6 +65,10 @@ export default function Form({
64
65
  progress,
65
66
  progressVariant,
66
67
  buttonType = 'big',
68
+ submitSuccessCount = 0,
69
+ contentLocale,
70
+ defaultLocale,
71
+ onDirtyChange,
67
72
  }: {
68
73
  formType?: 'new' | 'edit'
69
74
  data:
@@ -101,17 +106,33 @@ export default function Form({
101
106
  progress?: number
102
107
  progressVariant?: 'determinate' | 'query'
103
108
  buttonType?: 'big' | 'small'
109
+ submitSuccessCount?: number
110
+ contentLocale?: { code: string; label: string; rtl?: boolean }
111
+ defaultLocale?: { code: string; label: string; rtl?: boolean }
112
+ onDirtyChange?: (isDirty: boolean) => void
104
113
  }) {
105
114
  const t = useI18n()
106
115
  const session = useSession()
107
- const locale = session?.data?.user?.locale
116
+ const locale = session?.data?.user?.language
117
+
118
+ // When editing a non-default locale, only show fields marked as localized
119
+ const isTranslationMode = !!(contentLocale && defaultLocale && contentLocale.code !== defaultLocale.code)
120
+
121
+ const filterInputsForLocale = <T,>(inputs: T[]): T[] => {
122
+ if (!isTranslationMode) return inputs
123
+ return inputs.filter((input) => (input as any).localized)
124
+ }
125
+
126
+ const hasNoLocalizedFields =
127
+ isTranslationMode &&
128
+ (data.inputGroups?.every((g) => filterInputsForLocale(g.inputs as any[]).length === 0) ?? true)
108
129
 
109
130
  let schema = z.object({})
110
131
  /**
111
132
  * Construct the schema for the form
112
133
  */
113
134
  data.inputGroups?.forEach((inputGroup) => {
114
- inputGroup.inputs.forEach((input) => {
135
+ filterInputsForLocale(inputGroup.inputs as any[]).forEach((input: any) => {
115
136
  if (input.readonly) return
116
137
  switch (input.type) {
117
138
  case 'select_multiple':
@@ -212,7 +233,14 @@ export default function Form({
212
233
  resolver: zodResolver(schema),
213
234
  })
214
235
 
236
+ const { dirtyFields } = methods.formState
237
+ const hasDirtyFields = Object.keys(dirtyFields).length > 0
238
+ useEffect(() => {
239
+ onDirtyChange?.(hasDirtyFields)
240
+ }, [hasDirtyFields, onDirtyChange])
241
+
215
242
  return (
243
+ <LocalizationProvider value={contentLocale ?? null}>
216
244
  <FormProvider {...methods}>
217
245
  <form
218
246
  method='post'
@@ -231,19 +259,31 @@ export default function Form({
231
259
  <div className=''>
232
260
  {data.inputGroups ? (
233
261
  <div className='flex flex-col gap-4'>
262
+ {hasNoLocalizedFields ? (
263
+ <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'>
264
+ <h3 className='font-semibold'>{t('noLocalizedFields')}</h3>
265
+ <p className='mt-1'>
266
+ {t('noLocalizedFieldsHint', { section: data.section.title.section, file: data.section.configFile })}
267
+ </p>
268
+ </div>
269
+ ) : (
270
+ <>
234
271
  {data.inputGroups.length > 0
235
272
  ? data.inputGroups.map((inputGroup, index: number) => {
273
+ const filteredInputs = filterInputsForLocale(inputGroup.inputs as any[])
274
+ if (filteredInputs.length === 0) return null
236
275
  return (
237
276
  <ContainerBox title={inputGroup.groupTitle} key={index}>
238
277
  <FormInputs
239
- inputs={inputGroup.inputs}
278
+ inputs={filteredInputs}
240
279
  sectionName={data.section.name}
280
+ submitSuccessCount={submitSuccessCount}
241
281
  />
242
282
  </ContainerBox>
243
283
  )
244
284
  })
245
285
  : null}
246
- {data.section.gallery ? (
286
+ {data.section.gallery && !isTranslationMode ? (
247
287
  <>
248
288
  <div className='w-full'>
249
289
  <PhotoGallery sectionName={data.section.name} gallery={data.gallery} />
@@ -305,6 +345,8 @@ export default function Form({
305
345
  </div>
306
346
  {response ? <div className='w-full'>{response}</div> : null}
307
347
  </div>
348
+ </>
349
+ )}
308
350
  </div>
309
351
  ) : null}
310
352
  </div>
@@ -313,5 +355,6 @@ export default function Form({
313
355
  </div>
314
356
  </form>
315
357
  </FormProvider>
358
+ </LocalizationProvider>
316
359
  )
317
360
  }
@@ -1,4 +1,4 @@
1
- import React from 'react'
1
+ import React from 'react'
2
2
  import ColorFormInput from '@/components/form/inputs/ColorFormInput'
3
3
  import NumberFormInput from '@/components/form/inputs/NumberFormInput'
4
4
  import TextFormInput from '@/components/form/inputs/TextFormInput'
@@ -35,7 +35,15 @@ import {
35
35
  SlugFieldClientConfig,
36
36
  } from 'nextjs-cms/core/fields'
37
37
 
38
- export default function FormInputs({ inputs, sectionName }: { inputs: FieldClientConfig[]; sectionName: string }) {
38
+ export default function FormInputs({
39
+ inputs,
40
+ sectionName,
41
+ submitSuccessCount = 0,
42
+ }: {
43
+ inputs: FieldClientConfig[]
44
+ sectionName: string
45
+ submitSuccessCount?: number
46
+ }) {
39
47
  return (
40
48
  <>
41
49
  {inputs?.length > 0 &&
@@ -70,11 +78,12 @@ export default function FormInputs({ inputs, sectionName }: { inputs: FieldClien
70
78
  )
71
79
  case 'photo':
72
80
  return (
73
- <PhotoFormInput
74
- sectionName={sectionName}
75
- input={input as PhotoFieldClientConfig}
76
- key={input.name}
77
- />
81
+ <PhotoFormInput
82
+ sectionName={sectionName}
83
+ input={input as PhotoFieldClientConfig}
84
+ submitSuccessCount={submitSuccessCount}
85
+ key={input.name}
86
+ />
78
87
  )
79
88
  case 'video':
80
89
  return (
@@ -8,4 +8,4 @@ export const revalidate = 0
8
8
 
9
9
  // @refresh reset
10
10
 
11
- export const configLastUpdated = 1773836696982
11
+ export const configLastUpdated = 1775405374274
@@ -26,6 +26,7 @@ export default function NumberFormInput({ input }: { input: NumberFieldClientCon
26
26
  <input
27
27
  type='number'
28
28
  placeholder={input.label}
29
+ dir={input.rtl !== undefined ? (input.rtl ? 'rtl' : 'ltr') : undefined}
29
30
  readOnly={field.disabled}
30
31
  disabled={field.disabled}
31
32
  name={field.name}
@@ -33,7 +34,7 @@ export default function NumberFormInput({ input }: { input: NumberFieldClientCon
33
34
  event.target.value ? field.onChange(toNumber(event.target.value)) : field.onChange(NaN)
34
35
  }
35
36
  onBlur={field.onBlur}
36
- value={field.value}
37
+ value={field.value ?? ''}
37
38
  ref={field.ref}
38
39
  className='w-full rounded bg-input p-3 text-foreground shadow-xs outline-0 ring-2 ring-gray-300 hover:ring-gray-400 focus:ring-blue-500'
39
40
  />