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.
- package/package.json +2 -2
- package/templates/default/app/(auth)/auth/login/LoginPage.tsx +9 -9
- package/templates/default/app/(auth)/auth-language-provider.tsx +34 -0
- package/templates/default/app/(auth)/layout.tsx +10 -10
- package/templates/default/app/(rootLayout)/edit/[section]/[itemId]/page.tsx +4 -1
- package/templates/default/app/(rootLayout)/layout.tsx +5 -5
- package/templates/default/app/(rootLayout)/section/[section]/page.tsx +4 -1
- package/templates/default/app/api/auth/route.ts +2 -2
- package/templates/default/app/api/submit/section/item/[slug]/route.ts +32 -3
- package/templates/default/app/api/submit/section/simple/route.ts +32 -3
- package/templates/default/app/globals.css +9 -0
- package/templates/default/cms.config.ts +4 -4
- package/templates/default/components/ItemEditPage.tsx +82 -2
- package/templates/default/components/LocaleSwitcher.tsx +89 -0
- package/templates/default/components/Navbar.tsx +5 -5
- package/templates/default/components/NewPage.tsx +1 -0
- package/templates/default/components/SectionPage.tsx +81 -1
- package/templates/default/components/form/ContentLocaleContext.tsx +11 -0
- package/templates/default/components/form/Form.tsx +48 -5
- package/templates/default/components/form/FormInputs.tsx +16 -7
- package/templates/default/components/form/helpers/_section-hot-reload.js +1 -1
- package/templates/default/components/form/inputs/NumberFormInput.tsx +2 -1
- package/templates/default/components/form/inputs/PhotoFormInput.tsx +168 -112
- package/templates/default/components/form/inputs/RichTextFormInput.tsx +3 -0
- package/templates/default/components/form/inputs/SelectFormInput.tsx +1 -1
- package/templates/default/components/form/inputs/TagsFormInput.tsx +6 -2
- package/templates/default/components/form/inputs/TextFormInput.tsx +3 -0
- package/templates/default/components/form/inputs/TextareaFormInput.tsx +3 -0
- package/templates/default/components/{locale-dropdown.tsx → language-dropdown.tsx} +74 -74
- package/templates/default/components/{locale-picker.tsx → language-picker.tsx} +85 -85
- package/templates/default/components/login-language-dropdown.tsx +46 -0
- package/templates/default/components/ui/alert.tsx +2 -1
- package/templates/default/dynamic-schemas/schema.ts +475 -448
- package/templates/default/package.json +1 -1
- package/templates/default/app/(auth)/auth-locale-provider.tsx +0 -34
- 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 {
|
|
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
|
|
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
|
|
48
|
-
const isRTL =
|
|
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
|
-
<
|
|
188
|
+
<LanguageDropdown />
|
|
189
189
|
<ThemeToggle />
|
|
190
190
|
{/* Profile dropdown */}
|
|
191
191
|
<DropdownMenu>
|
|
@@ -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?.
|
|
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={
|
|
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({
|
|
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
|
-
|
|
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 (
|
|
@@ -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
|
/>
|