create-nextjs-cms 0.9.4 → 0.9.6

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