create-nextjs-cms 0.8.10 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (193) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +71 -71
  3. package/dist/helpers/utils.js +16 -16
  4. package/dist/lib/section-creators.js +166 -166
  5. package/package.json +2 -2
  6. package/templates/default/.eslintrc.json +5 -5
  7. package/templates/default/.prettierignore +7 -7
  8. package/templates/default/.prettierrc.json +27 -27
  9. package/templates/default/CHANGELOG.md +140 -140
  10. package/templates/default/_gitignore +57 -57
  11. package/templates/default/app/(auth)/auth/login/LoginPage.tsx +192 -192
  12. package/templates/default/app/(auth)/auth/login/page.tsx +11 -11
  13. package/templates/default/app/(auth)/auth-language-provider.tsx +34 -0
  14. package/templates/default/app/(auth)/layout.tsx +81 -81
  15. package/templates/default/app/(rootLayout)/(plugins)/[...slug]/page.tsx +40 -40
  16. package/templates/default/app/(rootLayout)/(plugins)/[...slug]/plugin-server-registry.ts +16 -16
  17. package/templates/default/app/(rootLayout)/admins/page.tsx +10 -10
  18. package/templates/default/app/(rootLayout)/browse/[section]/[page]/page.tsx +22 -22
  19. package/templates/default/app/(rootLayout)/categorized/[section]/page.tsx +15 -15
  20. package/templates/default/app/(rootLayout)/dashboard/page.tsx +63 -63
  21. package/templates/default/app/(rootLayout)/dashboard-new/page.tsx +7 -7
  22. package/templates/default/app/(rootLayout)/edit/[section]/[itemId]/page.tsx +20 -17
  23. package/templates/default/app/(rootLayout)/layout.tsx +81 -81
  24. package/templates/default/app/(rootLayout)/loading.tsx +10 -10
  25. package/templates/default/app/(rootLayout)/log/page.tsx +7 -7
  26. package/templates/default/app/(rootLayout)/new/[section]/page.tsx +15 -15
  27. package/templates/default/app/(rootLayout)/section/[section]/page.tsx +19 -16
  28. package/templates/default/app/(rootLayout)/settings/page.tsx +13 -13
  29. package/templates/default/app/_trpc/client.ts +3 -3
  30. package/templates/default/app/api/auth/csrf/route.ts +25 -25
  31. package/templates/default/app/api/auth/refresh/route.ts +10 -10
  32. package/templates/default/app/api/auth/route.ts +49 -49
  33. package/templates/default/app/api/auth/session/route.ts +20 -20
  34. package/templates/default/app/api/document/route.ts +165 -165
  35. package/templates/default/app/api/editor/photo/route.ts +49 -49
  36. package/templates/default/app/api/photo/route.ts +27 -27
  37. package/templates/default/app/api/submit/section/item/[slug]/route.ts +95 -66
  38. package/templates/default/app/api/submit/section/item/route.ts +56 -56
  39. package/templates/default/app/api/submit/section/simple/route.ts +86 -57
  40. package/templates/default/app/api/trpc/[trpc]/route.ts +33 -33
  41. package/templates/default/app/api/video/route.ts +174 -174
  42. package/templates/default/app/globals.css +228 -219
  43. package/templates/default/app/providers.tsx +152 -152
  44. package/templates/default/cms.config.ts +58 -57
  45. package/templates/default/components/AdminCard.tsx +166 -166
  46. package/templates/default/components/AdminEditPage.tsx +124 -124
  47. package/templates/default/components/AdminPrivilegeCard.tsx +185 -185
  48. package/templates/default/components/AdminsPage.tsx +43 -43
  49. package/templates/default/components/AnalyticsPage.tsx +128 -128
  50. package/templates/default/components/BarChartBox.tsx +42 -42
  51. package/templates/default/components/BrowsePage.tsx +106 -106
  52. package/templates/default/components/CategorizedSectionPage.tsx +31 -31
  53. package/templates/default/components/CategoryDeleteConfirmPage.tsx +130 -130
  54. package/templates/default/components/CategorySectionSelectInput.tsx +140 -140
  55. package/templates/default/components/ConditionalFields.tsx +49 -49
  56. package/templates/default/components/ContainerBox.tsx +24 -24
  57. package/templates/default/components/DashboardNewPage.tsx +253 -253
  58. package/templates/default/components/DashboardPage.tsx +188 -188
  59. package/templates/default/components/DashboardPageAlt.tsx +45 -45
  60. package/templates/default/components/DefaultNavItems.tsx +3 -3
  61. package/templates/default/components/Dropzone.tsx +154 -154
  62. package/templates/default/components/EmailCard.tsx +138 -138
  63. package/templates/default/components/EmailPasswordForm.tsx +85 -85
  64. package/templates/default/components/EmailQuotaForm.tsx +73 -73
  65. package/templates/default/components/EmailsPage.tsx +49 -49
  66. package/templates/default/components/ErrorComponent.tsx +16 -16
  67. package/templates/default/components/GalleryPhoto.tsx +93 -93
  68. package/templates/default/components/InfoCard.tsx +93 -93
  69. package/templates/default/components/ItemEditPage.tsx +294 -214
  70. package/templates/default/components/Layout.tsx +84 -84
  71. package/templates/default/components/LoadingSpinners.tsx +67 -67
  72. package/templates/default/components/LocaleSwitcher.tsx +89 -0
  73. package/templates/default/components/LogPage.tsx +107 -107
  74. package/templates/default/components/Modal.tsx +166 -166
  75. package/templates/default/components/Navbar.tsx +258 -258
  76. package/templates/default/components/NewAdminForm.tsx +173 -173
  77. package/templates/default/components/NewEmailForm.tsx +132 -132
  78. package/templates/default/components/NewPage.tsx +206 -205
  79. package/templates/default/components/NewVariantComponent.tsx +229 -229
  80. package/templates/default/components/PhotoGallery.tsx +35 -35
  81. package/templates/default/components/PieChartBox.tsx +101 -101
  82. package/templates/default/components/ProgressBar.tsx +48 -48
  83. package/templates/default/components/ProtectedDocument.tsx +44 -44
  84. package/templates/default/components/ProtectedImage.tsx +143 -143
  85. package/templates/default/components/ProtectedVideo.tsx +76 -76
  86. package/templates/default/components/SectionIcon.tsx +8 -8
  87. package/templates/default/components/SectionItemCard.tsx +144 -144
  88. package/templates/default/components/SectionItemStatusBadge.tsx +17 -17
  89. package/templates/default/components/SectionPage.tsx +205 -125
  90. package/templates/default/components/SelectBox.tsx +98 -98
  91. package/templates/default/components/SelectInputButtons.tsx +125 -125
  92. package/templates/default/components/SettingsPage.tsx +232 -232
  93. package/templates/default/components/Sidebar.tsx +204 -204
  94. package/templates/default/components/SidebarDropdownItem.tsx +83 -83
  95. package/templates/default/components/SidebarItem.tsx +24 -24
  96. package/templates/default/components/ThemeProvider.tsx +8 -8
  97. package/templates/default/components/TooltipComponent.tsx +27 -27
  98. package/templates/default/components/VariantCard.tsx +124 -124
  99. package/templates/default/components/VariantEditPage.tsx +230 -230
  100. package/templates/default/components/analytics/BounceRate.tsx +70 -70
  101. package/templates/default/components/analytics/LivePageViews.tsx +55 -55
  102. package/templates/default/components/analytics/LiveUsersCount.tsx +33 -33
  103. package/templates/default/components/analytics/MonthlyPageViews.tsx +42 -42
  104. package/templates/default/components/analytics/TopCountries.tsx +52 -52
  105. package/templates/default/components/analytics/TopDevices.tsx +46 -46
  106. package/templates/default/components/analytics/TopMediums.tsx +58 -58
  107. package/templates/default/components/analytics/TopSources.tsx +45 -45
  108. package/templates/default/components/analytics/TotalPageViews.tsx +41 -41
  109. package/templates/default/components/analytics/TotalSessions.tsx +41 -41
  110. package/templates/default/components/analytics/TotalUniqueUsers.tsx +41 -41
  111. package/templates/default/components/custom/RightHomeRoomVariantCard.tsx +138 -138
  112. package/templates/default/components/dndKit/Draggable.tsx +21 -21
  113. package/templates/default/components/dndKit/Droppable.tsx +20 -20
  114. package/templates/default/components/dndKit/SortableItem.tsx +18 -18
  115. package/templates/default/components/form/ContentLocaleContext.tsx +11 -0
  116. package/templates/default/components/form/DateRangeFormInput.tsx +57 -57
  117. package/templates/default/components/form/Form.tsx +360 -317
  118. package/templates/default/components/form/FormInputElement.tsx +70 -70
  119. package/templates/default/components/form/FormInputs.tsx +127 -118
  120. package/templates/default/components/form/helpers/_section-hot-reload.js +1 -1
  121. package/templates/default/components/form/helpers/util.ts +17 -17
  122. package/templates/default/components/form/inputs/CheckboxFormInput.tsx +46 -46
  123. package/templates/default/components/form/inputs/ColorFormInput.tsx +44 -44
  124. package/templates/default/components/form/inputs/DateFormInput.tsx +156 -156
  125. package/templates/default/components/form/inputs/DocumentFormInput.tsx +222 -222
  126. package/templates/default/components/form/inputs/MapFormInput.tsx +140 -140
  127. package/templates/default/components/form/inputs/MultipleSelectFormInput.tsx +85 -85
  128. package/templates/default/components/form/inputs/NumberFormInput.tsx +43 -42
  129. package/templates/default/components/form/inputs/PasswordFormInput.tsx +47 -47
  130. package/templates/default/components/form/inputs/PhotoFormInput.tsx +275 -219
  131. package/templates/default/components/form/inputs/RichTextFormInput.tsx +138 -135
  132. package/templates/default/components/form/inputs/SelectFormInput.tsx +175 -175
  133. package/templates/default/components/form/inputs/SlugFormInput.tsx +131 -131
  134. package/templates/default/components/form/inputs/TagsFormInput.tsx +264 -260
  135. package/templates/default/components/form/inputs/TextFormInput.tsx +51 -48
  136. package/templates/default/components/form/inputs/TextareaFormInput.tsx +50 -47
  137. package/templates/default/components/form/inputs/VideoFormInput.tsx +118 -118
  138. package/templates/default/components/{locale-dropdown.tsx → language-dropdown.tsx} +74 -74
  139. package/templates/default/components/{locale-picker.tsx → language-picker.tsx} +85 -85
  140. package/templates/default/components/login-language-dropdown.tsx +46 -0
  141. package/templates/default/components/multi-select.tsx +1146 -1146
  142. package/templates/default/components/pagination/Pagination.tsx +36 -36
  143. package/templates/default/components/pagination/PaginationButtons.tsx +147 -147
  144. package/templates/default/components/theme-toggle.tsx +39 -39
  145. package/templates/default/components/ui/accordion.tsx +53 -53
  146. package/templates/default/components/ui/alert-dialog.tsx +157 -157
  147. package/templates/default/components/ui/alert.tsx +47 -46
  148. package/templates/default/components/ui/badge.tsx +38 -38
  149. package/templates/default/components/ui/button.tsx +62 -62
  150. package/templates/default/components/ui/calendar.tsx +166 -166
  151. package/templates/default/components/ui/card.tsx +43 -43
  152. package/templates/default/components/ui/checkbox.tsx +29 -29
  153. package/templates/default/components/ui/command.tsx +137 -137
  154. package/templates/default/components/ui/custom-alert-dialog.tsx +113 -113
  155. package/templates/default/components/ui/custom-dialog.tsx +123 -123
  156. package/templates/default/components/ui/dialog.tsx +123 -123
  157. package/templates/default/components/ui/direction.tsx +22 -22
  158. package/templates/default/components/ui/dropdown-menu.tsx +182 -182
  159. package/templates/default/components/ui/input-group.tsx +54 -54
  160. package/templates/default/components/ui/input.tsx +22 -22
  161. package/templates/default/components/ui/label.tsx +19 -19
  162. package/templates/default/components/ui/popover.tsx +42 -42
  163. package/templates/default/components/ui/progress.tsx +31 -31
  164. package/templates/default/components/ui/scroll-area.tsx +42 -42
  165. package/templates/default/components/ui/select.tsx +165 -165
  166. package/templates/default/components/ui/separator.tsx +28 -28
  167. package/templates/default/components/ui/sheet.tsx +103 -103
  168. package/templates/default/components/ui/spinner.tsx +16 -16
  169. package/templates/default/components/ui/switch.tsx +29 -29
  170. package/templates/default/components/ui/table.tsx +83 -83
  171. package/templates/default/components/ui/tabs.tsx +55 -55
  172. package/templates/default/components/ui/toast.tsx +113 -113
  173. package/templates/default/components/ui/toaster.tsx +35 -35
  174. package/templates/default/components/ui/tooltip.tsx +30 -30
  175. package/templates/default/components/ui/use-toast.ts +188 -188
  176. package/templates/default/components.json +21 -21
  177. package/templates/default/context/ModalProvider.tsx +53 -53
  178. package/templates/default/drizzle.config.ts +4 -4
  179. package/templates/default/dynamic-schemas/schema.ts +28 -2
  180. package/templates/default/env/env.js +130 -130
  181. package/templates/default/envConfig.ts +4 -4
  182. package/templates/default/hooks/useModal.ts +8 -8
  183. package/templates/default/lib/apiHelpers.ts +92 -92
  184. package/templates/default/lib/postinstall.js +14 -14
  185. package/templates/default/lib/utils.ts +6 -6
  186. package/templates/default/next-env.d.ts +6 -6
  187. package/templates/default/next.config.ts +23 -23
  188. package/templates/default/package.json +1 -1
  189. package/templates/default/postcss.config.mjs +6 -6
  190. package/templates/default/proxy.ts +32 -32
  191. package/templates/default/tsconfig.json +48 -48
  192. package/templates/default/app/(auth)/auth-locale-provider.tsx +0 -34
  193. package/templates/default/components/login-locale-dropdown.tsx +0 -46
@@ -1,260 +1,264 @@
1
- import FormInputElement from '@/components/form/FormInputElement'
2
- import { useI18n } from 'nextjs-cms/translations/client'
3
- import { TagsFieldClientConfig } from 'nextjs-cms/core/fields'
4
- import { useController, useFormContext } from 'react-hook-form'
5
- import { Badge } from '@/components/ui/badge'
6
- import { Input } from '@/components/ui/input'
7
- import { X } from 'lucide-react'
8
- import { useState, useRef, useEffect, useCallback } from 'react'
9
- import { cn } from '@/lib/utils'
10
- import { trpc } from '@/app/_trpc/client'
11
-
12
- export default function TagsFormInput({
13
- input,
14
- sectionName,
15
- }: {
16
- input: TagsFieldClientConfig
17
- sectionName: string
18
- }) {
19
- const t = useI18n()
20
- const { control } = useFormContext()
21
- const {
22
- field,
23
- fieldState: { invalid, isTouched, isDirty, error },
24
- } = useController({
25
- name: input.name,
26
- control,
27
- defaultValue: input.value ?? undefined,
28
- })
29
-
30
- const [inputValue, setInputValue] = useState('')
31
- const [highlightedTag, setHighlightedTag] = useState<string | null>(null)
32
- const [highlightedIndex, setHighlightedIndex] = useState(-1)
33
- const [showDropdown, setShowDropdown] = useState(false)
34
- const inputRef = useRef<HTMLInputElement>(null)
35
- const dropdownRef = useRef<HTMLDivElement>(null)
36
- const [debouncedQuery, setDebouncedQuery] = useState('')
37
-
38
- // Debounce the query input
39
- useEffect(() => {
40
- if (!input.hasAutoCompletion) return
41
- const timer = setTimeout(() => {
42
- setDebouncedQuery(inputValue)
43
- }, 300)
44
- return () => clearTimeout(timer)
45
- }, [inputValue, input.hasAutoCompletion])
46
-
47
- const { data: suggestions } = trpc.fields.tagsAutoComplete.useQuery(
48
- { sectionName, fieldName: input.name, query: debouncedQuery },
49
- { enabled: input.hasAutoCompletion && debouncedQuery.length >= 1 },
50
- )
51
-
52
- // Parse tags from comma-separated string
53
- const getTags = useCallback((): string[] => {
54
- if (!field.value || typeof field.value !== 'string') return []
55
- return field.value.split(',').filter((tag) => tag.trim() !== '')
56
- }, [field.value])
57
-
58
- // Filter out already-added tags from suggestions
59
- const filteredSuggestions = suggestions?.filter((s) => !getTags().includes(s)) ?? []
60
-
61
- // Show/hide dropdown based on suggestions
62
- useEffect(() => {
63
- setShowDropdown(filteredSuggestions.length > 0 && inputValue.length >= 1)
64
- setHighlightedIndex(-1)
65
- }, [filteredSuggestions.length, inputValue.length])
66
-
67
- // Convert tags array to comma-separated string
68
- const setTags = (tags: string[]) => {
69
- const filteredTags = tags.filter((tag) => tag.trim() !== '')
70
- const value = filteredTags.length > 0 ? filteredTags.join(',') : ''
71
- field.onChange(value)
72
- }
73
-
74
- const addTag = (tag: string) => {
75
- const trimmedTag = tag.trim()
76
- if (trimmedTag) {
77
- const existingTags = getTags()
78
- if (existingTags.includes(trimmedTag)) {
79
- // Highlight the existing tag to show it already exists
80
- setHighlightedTag(trimmedTag)
81
- setTimeout(() => setHighlightedTag(null), 1000)
82
- } else {
83
- const newTags = [...existingTags, trimmedTag]
84
- setTags(newTags)
85
- setInputValue('')
86
- setShowDropdown(false)
87
- }
88
- }
89
- }
90
-
91
- const removeTag = (tagToRemove: string) => {
92
- const newTags = getTags().filter((tag) => tag !== tagToRemove)
93
- setTags(newTags)
94
- }
95
-
96
- const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
97
- if (showDropdown && filteredSuggestions.length > 0) {
98
- if (event.key === 'ArrowDown') {
99
- event.preventDefault()
100
- setHighlightedIndex((prev) =>
101
- prev < filteredSuggestions.length - 1 ? prev + 1 : 0,
102
- )
103
- return
104
- }
105
- if (event.key === 'ArrowUp') {
106
- event.preventDefault()
107
- setHighlightedIndex((prev) =>
108
- prev > 0 ? prev - 1 : filteredSuggestions.length - 1,
109
- )
110
- return
111
- }
112
- if (event.key === 'Enter' && highlightedIndex >= 0) {
113
- event.preventDefault()
114
- addTag(filteredSuggestions[highlightedIndex])
115
- return
116
- }
117
- if (event.key === 'Escape') {
118
- event.preventDefault()
119
- setShowDropdown(false)
120
- setHighlightedIndex(-1)
121
- return
122
- }
123
- }
124
-
125
- if (event.key === 'Enter' || event.key === ',') {
126
- event.preventDefault()
127
- if (inputValue.trim()) {
128
- addTag(inputValue)
129
- }
130
- } else if (event.key === 'Backspace' && !inputValue && getTags().length > 0) {
131
- // Remove last tag when backspace is pressed on empty input
132
- const lastTag = getTags()[getTags().length - 1]
133
- if (lastTag) {
134
- removeTag(lastTag)
135
- }
136
- }
137
- }
138
-
139
- const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
140
- setInputValue(event.target.value)
141
- }
142
-
143
- const handleContainerClick = () => {
144
- inputRef.current?.focus()
145
- }
146
-
147
- const handleBlur = () => {
148
- // Small delay to allow click events on dropdown items to fire first
149
- setTimeout(() => {
150
- setShowDropdown(false)
151
- setHighlightedIndex(-1)
152
- }, 200)
153
- }
154
-
155
- const tags = getTags()
156
-
157
- return (
158
- <FormInputElement
159
- validationError={error}
160
- value={field.value}
161
- readonly={input.readonly}
162
- label={input.label}
163
- required={input.required}
164
- >
165
- <input
166
- type='hidden'
167
- className='hidden'
168
- disabled={field.disabled}
169
- name={field.name}
170
- onBlur={field.onBlur}
171
- value={field.value ?? ''}
172
- ref={field.ref}
173
- />
174
-
175
- <div className='relative'>
176
- <div
177
- className={cn(
178
- 'bg-input text-foreground w-full rounded px-3 py-1 shadow-xs ring-2 ring-gray-300 outline-0 transition-colors hover:ring-gray-400 focus-within:ring-blue-500',
179
- 'flex min-h-9 flex-wrap items-center gap-1 text-sm',
180
- 'cursor-text',
181
- field.disabled && 'cursor-not-allowed opacity-50',
182
- )}
183
- onClick={handleContainerClick}
184
- >
185
- {tags.map((tag, index) => (
186
- <Badge
187
- key={`${tag}-${index}`}
188
- variant='secondary'
189
- className={cn(
190
- 'flex items-center gap-1 px-2 py-1 text-xs transition-all duration-200 border-foreground/40',
191
- highlightedTag === tag ? 'bg-success/20 border-success/50 animate-pulse' : '',
192
- )}
193
- >
194
- <span>{tag}</span>
195
- {!field.disabled ? (
196
- <button
197
- type='button'
198
- onClick={(e) => {
199
- e.stopPropagation()
200
- removeTag(tag)
201
- }}
202
- className='hover:bg-muted-foreground/20 focus:ring-ring ml-1 rounded-sm focus:ring-1 focus:outline-none'
203
- aria-label={`Remove ${tag} tag`}
204
- >
205
- <X className='h-3 w-3' />
206
- </button>
207
- ) : null}
208
- </Badge>
209
- ))}
210
-
211
- <Input
212
- ref={inputRef}
213
- type='text'
214
- value={inputValue}
215
- onChange={handleInputChange}
216
- onKeyDown={handleKeyDown}
217
- onBlur={handleBlur}
218
- placeholder={
219
- tags.length === 0
220
- ? input.placeholder
221
- ? input.placeholder
222
- : input.hasAutoCompletion
223
- ? t('startTypingForSuggestions')
224
- : t('startTyping')
225
- : ''
226
- }
227
- className='min-w-20 flex-1 border-0 p-0 shadow-none focus-visible:ring-0 focus-visible:ring-offset-0'
228
- disabled={field.disabled}
229
- />
230
- </div>
231
-
232
- {showDropdown && filteredSuggestions.length > 0 && (
233
- <div
234
- ref={dropdownRef}
235
- className='bg-popover border-border absolute z-50 mt-1 max-h-48 w-full overflow-auto rounded-md border shadow-md'
236
- >
237
- {filteredSuggestions.map((suggestion, index) => (
238
- <button
239
- key={suggestion}
240
- type='button'
241
- className={cn(
242
- 'text-popover-foreground w-full px-3 py-2 text-left text-sm',
243
- 'hover:bg-accent hover:text-accent-foreground',
244
- highlightedIndex === index && 'bg-accent text-accent-foreground',
245
- )}
246
- onMouseDown={(e) => {
247
- e.preventDefault()
248
- addTag(suggestion)
249
- }}
250
- onMouseEnter={() => setHighlightedIndex(index)}
251
- >
252
- {suggestion}
253
- </button>
254
- ))}
255
- </div>
256
- )}
257
- </div>
258
- </FormInputElement>
259
- )
260
- }
1
+ import FormInputElement from '@/components/form/FormInputElement'
2
+ import { useI18n } from 'nextjs-cms/translations/client'
3
+ import { TagsFieldClientConfig } from 'nextjs-cms/core/fields'
4
+ import { useController, useFormContext } from 'react-hook-form'
5
+ import { Badge } from '@/components/ui/badge'
6
+ import { Input } from '@/components/ui/input'
7
+ import { X } from 'lucide-react'
8
+ import { useState, useRef, useEffect, useCallback } from 'react'
9
+ import { useSearchParams } from 'next/navigation'
10
+ import { cn } from '@/lib/utils'
11
+ import { trpc } from '@/app/_trpc/client'
12
+
13
+ export default function TagsFormInput({
14
+ input,
15
+ sectionName,
16
+ }: {
17
+ input: TagsFieldClientConfig
18
+ sectionName: string
19
+ }) {
20
+ const t = useI18n()
21
+ const searchParams = useSearchParams()
22
+ const localeParam = searchParams.get('locale')
23
+ const locale = input.localized ? (localeParam?.trim() ? localeParam : undefined) : undefined
24
+ const { control } = useFormContext()
25
+ const {
26
+ field,
27
+ fieldState: { invalid, isTouched, isDirty, error },
28
+ } = useController({
29
+ name: input.name,
30
+ control,
31
+ defaultValue: input.value ?? undefined,
32
+ })
33
+
34
+ const [inputValue, setInputValue] = useState('')
35
+ const [highlightedTag, setHighlightedTag] = useState<string | null>(null)
36
+ const [highlightedIndex, setHighlightedIndex] = useState(-1)
37
+ const [showDropdown, setShowDropdown] = useState(false)
38
+ const inputRef = useRef<HTMLInputElement>(null)
39
+ const dropdownRef = useRef<HTMLDivElement>(null)
40
+ const [debouncedQuery, setDebouncedQuery] = useState('')
41
+
42
+ // Debounce the query input
43
+ useEffect(() => {
44
+ if (!input.hasAutoCompletion) return
45
+ const timer = setTimeout(() => {
46
+ setDebouncedQuery(inputValue)
47
+ }, 300)
48
+ return () => clearTimeout(timer)
49
+ }, [inputValue, input.hasAutoCompletion])
50
+
51
+ const { data: suggestions } = trpc.fields.tagsAutoComplete.useQuery(
52
+ { sectionName, fieldName: input.name, query: debouncedQuery, locale },
53
+ { enabled: input.hasAutoCompletion && debouncedQuery.length >= 1 },
54
+ )
55
+
56
+ // Parse tags from comma-separated string
57
+ const getTags = useCallback((): string[] => {
58
+ if (!field.value || typeof field.value !== 'string') return []
59
+ return field.value.split(',').filter((tag) => tag.trim() !== '')
60
+ }, [field.value])
61
+
62
+ // Filter out already-added tags from suggestions
63
+ const filteredSuggestions = suggestions?.filter((s) => !getTags().includes(s)) ?? []
64
+
65
+ // Show/hide dropdown based on suggestions
66
+ useEffect(() => {
67
+ setShowDropdown(filteredSuggestions.length > 0 && inputValue.length >= 1)
68
+ setHighlightedIndex(-1)
69
+ }, [filteredSuggestions.length, inputValue.length])
70
+
71
+ // Convert tags array to comma-separated string
72
+ const setTags = (tags: string[]) => {
73
+ const filteredTags = tags.filter((tag) => tag.trim() !== '')
74
+ const value = filteredTags.length > 0 ? filteredTags.join(',') : ''
75
+ field.onChange(value)
76
+ }
77
+
78
+ const addTag = (tag: string) => {
79
+ const trimmedTag = tag.trim()
80
+ if (trimmedTag) {
81
+ const existingTags = getTags()
82
+ if (existingTags.includes(trimmedTag)) {
83
+ // Highlight the existing tag to show it already exists
84
+ setHighlightedTag(trimmedTag)
85
+ setTimeout(() => setHighlightedTag(null), 1000)
86
+ } else {
87
+ const newTags = [...existingTags, trimmedTag]
88
+ setTags(newTags)
89
+ setInputValue('')
90
+ setShowDropdown(false)
91
+ }
92
+ }
93
+ }
94
+
95
+ const removeTag = (tagToRemove: string) => {
96
+ const newTags = getTags().filter((tag) => tag !== tagToRemove)
97
+ setTags(newTags)
98
+ }
99
+
100
+ const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
101
+ if (showDropdown && filteredSuggestions.length > 0) {
102
+ if (event.key === 'ArrowDown') {
103
+ event.preventDefault()
104
+ setHighlightedIndex((prev) =>
105
+ prev < filteredSuggestions.length - 1 ? prev + 1 : 0,
106
+ )
107
+ return
108
+ }
109
+ if (event.key === 'ArrowUp') {
110
+ event.preventDefault()
111
+ setHighlightedIndex((prev) =>
112
+ prev > 0 ? prev - 1 : filteredSuggestions.length - 1,
113
+ )
114
+ return
115
+ }
116
+ if (event.key === 'Enter' && highlightedIndex >= 0) {
117
+ event.preventDefault()
118
+ addTag(filteredSuggestions[highlightedIndex])
119
+ return
120
+ }
121
+ if (event.key === 'Escape') {
122
+ event.preventDefault()
123
+ setShowDropdown(false)
124
+ setHighlightedIndex(-1)
125
+ return
126
+ }
127
+ }
128
+
129
+ if (event.key === 'Enter' || event.key === ',') {
130
+ event.preventDefault()
131
+ if (inputValue.trim()) {
132
+ addTag(inputValue)
133
+ }
134
+ } else if (event.key === 'Backspace' && !inputValue && getTags().length > 0) {
135
+ // Remove last tag when backspace is pressed on empty input
136
+ const lastTag = getTags()[getTags().length - 1]
137
+ if (lastTag) {
138
+ removeTag(lastTag)
139
+ }
140
+ }
141
+ }
142
+
143
+ const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
144
+ setInputValue(event.target.value)
145
+ }
146
+
147
+ const handleContainerClick = () => {
148
+ inputRef.current?.focus()
149
+ }
150
+
151
+ const handleBlur = () => {
152
+ // Small delay to allow click events on dropdown items to fire first
153
+ setTimeout(() => {
154
+ setShowDropdown(false)
155
+ setHighlightedIndex(-1)
156
+ }, 200)
157
+ }
158
+
159
+ const tags = getTags()
160
+
161
+ return (
162
+ <FormInputElement
163
+ validationError={error}
164
+ value={field.value}
165
+ readonly={input.readonly}
166
+ label={input.label}
167
+ required={input.required}
168
+ >
169
+ <input
170
+ type='hidden'
171
+ className='hidden'
172
+ disabled={field.disabled}
173
+ name={field.name}
174
+ onBlur={field.onBlur}
175
+ value={field.value ?? ''}
176
+ ref={field.ref}
177
+ />
178
+
179
+ <div className='relative'>
180
+ <div
181
+ className={cn(
182
+ 'bg-input text-foreground w-full rounded px-3 py-1 shadow-xs ring-2 ring-gray-300 outline-0 transition-colors hover:ring-gray-400 focus-within:ring-blue-500',
183
+ 'flex min-h-9 flex-wrap items-center gap-1 text-sm',
184
+ 'cursor-text',
185
+ field.disabled && 'cursor-not-allowed opacity-50',
186
+ )}
187
+ onClick={handleContainerClick}
188
+ >
189
+ {tags.map((tag, index) => (
190
+ <Badge
191
+ key={`${tag}-${index}`}
192
+ variant='secondary'
193
+ className={cn(
194
+ 'flex items-center gap-1 px-2 py-1 text-xs transition-all duration-200 border-foreground/40',
195
+ highlightedTag === tag ? 'bg-success/20 border-success/50 animate-pulse' : '',
196
+ )}
197
+ >
198
+ <span>{tag}</span>
199
+ {!field.disabled ? (
200
+ <button
201
+ type='button'
202
+ onClick={(e) => {
203
+ e.stopPropagation()
204
+ removeTag(tag)
205
+ }}
206
+ className='hover:bg-muted-foreground/20 focus:ring-ring ml-1 rounded-sm focus:ring-1 focus:outline-none'
207
+ aria-label={`Remove ${tag} tag`}
208
+ >
209
+ <X className='h-3 w-3' />
210
+ </button>
211
+ ) : null}
212
+ </Badge>
213
+ ))}
214
+
215
+ <Input
216
+ ref={inputRef}
217
+ type='text'
218
+ value={inputValue}
219
+ onChange={handleInputChange}
220
+ onKeyDown={handleKeyDown}
221
+ onBlur={handleBlur}
222
+ placeholder={
223
+ tags.length === 0
224
+ ? input.placeholder
225
+ ? input.placeholder
226
+ : input.hasAutoCompletion
227
+ ? t('startTypingForSuggestions')
228
+ : t('startTyping')
229
+ : ''
230
+ }
231
+ className='min-w-20 flex-1 border-0 p-0 shadow-none focus-visible:ring-0 focus-visible:ring-offset-0'
232
+ disabled={field.disabled}
233
+ />
234
+ </div>
235
+
236
+ {showDropdown && filteredSuggestions.length > 0 && (
237
+ <div
238
+ ref={dropdownRef}
239
+ className='bg-popover border-border absolute z-50 mt-1 max-h-48 w-full overflow-auto rounded-md border shadow-md'
240
+ >
241
+ {filteredSuggestions.map((suggestion, index) => (
242
+ <button
243
+ key={suggestion}
244
+ type='button'
245
+ className={cn(
246
+ 'text-popover-foreground w-full px-3 py-2 text-left text-sm',
247
+ 'hover:bg-accent hover:text-accent-foreground',
248
+ highlightedIndex === index && 'bg-accent text-accent-foreground',
249
+ )}
250
+ onMouseDown={(e) => {
251
+ e.preventDefault()
252
+ addTag(suggestion)
253
+ }}
254
+ onMouseEnter={() => setHighlightedIndex(index)}
255
+ >
256
+ {suggestion}
257
+ </button>
258
+ ))}
259
+ </div>
260
+ )}
261
+ </div>
262
+ </FormInputElement>
263
+ )
264
+ }
@@ -1,48 +1,51 @@
1
- import React from 'react'
2
- import FormInputElement from '@/components/form/FormInputElement'
3
- import type { TextFieldClientConfig } from 'nextjs-cms/core/fields'
4
- import { useFormContext, useController } from 'react-hook-form'
5
-
6
- export default function TextFormInput({
7
- input,
8
- direction,
9
- disabled = false,
10
- }: {
11
- input: TextFieldClientConfig
12
- direction?: 'row' | 'col'
13
- disabled?: boolean
14
- }) {
15
- const { control } = useFormContext()
16
- const {
17
- field,
18
- fieldState: { invalid, isTouched, isDirty, error },
19
- } = useController({
20
- name: input.name,
21
- control,
22
- defaultValue: input.value ?? undefined,
23
- disabled: disabled,
24
- })
25
-
26
- return (
27
- <FormInputElement
28
- validationError={error}
29
- value={input.value}
30
- readonly={input.readonly}
31
- label={input.label}
32
- required={input.required}
33
- >
34
- <input
35
- placeholder={input.placeholder ? input.placeholder : input.label}
36
- type='text'
37
- readOnly={disabled}
38
- disabled={field.disabled}
39
- name={field.name}
40
- onChange={field.onChange}
41
- onBlur={field.onBlur}
42
- value={field.value}
43
- ref={field.ref}
44
- className='bg-input text-foreground w-full rounded p-3 shadow-xs ring-2 ring-gray-300 outline-0 hover:ring-gray-400 focus:ring-blue-500'
45
- />
46
- </FormInputElement>
47
- )
48
- }
1
+ import React from 'react'
2
+ import FormInputElement from '@/components/form/FormInputElement'
3
+ import type { TextFieldClientConfig } from 'nextjs-cms/core/fields'
4
+ import { useFormContext, useController } from 'react-hook-form'
5
+ import { useLocale } from '@/components/form/ContentLocaleContext'
6
+
7
+ export default function TextFormInput({
8
+ input,
9
+ direction,
10
+ disabled = false,
11
+ }: {
12
+ input: TextFieldClientConfig
13
+ direction?: 'row' | 'col'
14
+ disabled?: boolean
15
+ }) {
16
+ const { control } = useFormContext()
17
+ const {
18
+ field,
19
+ fieldState: { invalid, isTouched, isDirty, error },
20
+ } = useController({
21
+ name: input.name,
22
+ control,
23
+ defaultValue: input.value ?? undefined,
24
+ disabled: disabled,
25
+ })
26
+ const locale = useLocale()
27
+
28
+ return (
29
+ <FormInputElement
30
+ validationError={error}
31
+ value={input.value}
32
+ readonly={input.readonly}
33
+ label={input.label}
34
+ required={input.required}
35
+ >
36
+ <input
37
+ placeholder={input.placeholder ? input.placeholder : input.label}
38
+ type='text'
39
+ dir={input.rtl !== undefined ? (input.rtl ? 'rtl' : 'ltr') : (locale?.rtl ? 'rtl' : 'ltr')}
40
+ readOnly={disabled}
41
+ disabled={field.disabled}
42
+ name={field.name}
43
+ onChange={field.onChange}
44
+ onBlur={field.onBlur}
45
+ value={field.value}
46
+ ref={field.ref}
47
+ className='bg-input text-foreground w-full rounded p-3 shadow-xs ring-2 ring-gray-300 outline-0 hover:ring-gray-400 focus:ring-blue-500'
48
+ />
49
+ </FormInputElement>
50
+ )
51
+ }