create-nextjs-cms 0.9.30 → 0.9.32

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 (145) 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 +1 -1
  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 -34
  14. package/templates/default/app/(auth)/layout.tsx +81 -81
  15. package/templates/default/app/(rootLayout)/(plugins)/[...slug]/error.tsx +64 -0
  16. package/templates/default/app/(rootLayout)/(plugins)/[...slug]/page.tsx +59 -47
  17. package/templates/default/app/(rootLayout)/(plugins)/[...slug]/plugin-server-registry.ts +14 -16
  18. package/templates/default/app/(rootLayout)/admins/page.tsx +10 -10
  19. package/templates/default/app/(rootLayout)/browse/[section]/[page]/page.tsx +22 -22
  20. package/templates/default/app/(rootLayout)/categorized/[section]/page.tsx +15 -15
  21. package/templates/default/app/(rootLayout)/dashboard/page.tsx +68 -70
  22. package/templates/default/app/(rootLayout)/edit/[section]/[itemId]/page.tsx +20 -20
  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 +5 -7
  26. package/templates/default/app/(rootLayout)/new/[section]/page.tsx +15 -15
  27. package/templates/default/app/(rootLayout)/section/[section]/page.tsx +19 -19
  28. package/templates/default/app/(rootLayout)/settings/page.tsx +11 -13
  29. package/templates/default/app/api/auth/csrf/route.ts +25 -25
  30. package/templates/default/app/api/auth/refresh/route.ts +10 -10
  31. package/templates/default/app/api/auth/route.ts +49 -49
  32. package/templates/default/app/api/auth/session/route.ts +20 -20
  33. package/templates/default/app/api/document/route.ts +165 -165
  34. package/templates/default/app/api/editor/photo/route.ts +49 -49
  35. package/templates/default/app/api/photo/route.ts +27 -27
  36. package/templates/default/app/api/submit/section/item/[slug]/route.ts +95 -95
  37. package/templates/default/app/api/submit/section/item/route.ts +56 -56
  38. package/templates/default/app/api/submit/section/simple/route.ts +86 -86
  39. package/templates/default/app/api/video/route.ts +174 -174
  40. package/templates/default/app/globals.css +236 -236
  41. package/templates/default/cms.config.ts +56 -56
  42. package/templates/default/components/admin/admin-card.tsx +165 -165
  43. package/templates/default/components/admin/admin-edit-page.tsx +124 -124
  44. package/templates/default/components/admin/admin-privilege-card.tsx +184 -184
  45. package/templates/default/components/admin/new-admin-form.tsx +172 -172
  46. package/templates/default/components/container-box.tsx +24 -24
  47. package/templates/default/components/dnd-kit/draggable.tsx +21 -21
  48. package/templates/default/components/dnd-kit/droppable.tsx +20 -20
  49. package/templates/default/components/dnd-kit/sortable-item.tsx +18 -18
  50. package/templates/default/components/feedback/error-component.tsx +16 -16
  51. package/templates/default/components/feedback/info-card.tsx +93 -93
  52. package/templates/default/components/feedback/loading-spinners.tsx +67 -67
  53. package/templates/default/components/feedback/modal.tsx +166 -166
  54. package/templates/default/components/feedback/progress-bar.tsx +48 -48
  55. package/templates/default/components/feedback/tooltip-component.tsx +27 -27
  56. package/templates/default/components/form/form-input-element.tsx +70 -70
  57. package/templates/default/components/form/helpers/_section-hot-reload.js +1 -1
  58. package/templates/default/components/form/helpers/util.ts +17 -17
  59. package/templates/default/components/form/inputs/checkbox-form-input.tsx +46 -46
  60. package/templates/default/components/form/inputs/color-form-input.tsx +44 -44
  61. package/templates/default/components/form/inputs/date-form-input.tsx +93 -93
  62. package/templates/default/components/form/inputs/map-form-input.tsx +141 -141
  63. package/templates/default/components/form/inputs/multiple-select-form-input.tsx +85 -85
  64. package/templates/default/components/form/inputs/number-form-input.tsx +43 -43
  65. package/templates/default/components/form/inputs/password-form-input.tsx +47 -47
  66. package/templates/default/components/form/inputs/photo-form-input.tsx +279 -279
  67. package/templates/default/components/form/inputs/rich-text-form-input.tsx +148 -148
  68. package/templates/default/components/form/inputs/select-form-input.tsx +159 -159
  69. package/templates/default/components/form/inputs/slug-form-input.tsx +131 -131
  70. package/templates/default/components/form/inputs/tags-form-input.tsx +255 -255
  71. package/templates/default/components/form/inputs/text-form-input.tsx +61 -61
  72. package/templates/default/components/form/inputs/textarea-form-input.tsx +61 -61
  73. package/templates/default/components/layout/default-nav-items.tsx +3 -3
  74. package/templates/default/components/layout/layout.tsx +84 -84
  75. package/templates/default/components/layout/navbar.tsx +258 -258
  76. package/templates/default/components/layout/sidebar-dropdown-item.tsx +83 -83
  77. package/templates/default/components/layout/sidebar-item.tsx +24 -24
  78. package/templates/default/components/layout/sidebar.tsx +229 -229
  79. package/templates/default/components/layout/theme-provider.tsx +8 -8
  80. package/templates/default/components/layout/theme-toggle.tsx +39 -39
  81. package/templates/default/components/locale/locale-switcher.tsx +98 -98
  82. package/templates/default/components/media/dropzone.tsx +154 -154
  83. package/templates/default/components/media/protected-document.tsx +44 -44
  84. package/templates/default/components/media/protected-image.tsx +143 -143
  85. package/templates/default/components/media/protected-video.tsx +76 -76
  86. package/templates/default/components/multi-select.tsx +1150 -1150
  87. package/templates/default/components/pages/admins-page.tsx +43 -43
  88. package/templates/default/components/pages/browse-page.tsx +106 -106
  89. package/templates/default/components/pages/categorized-section-page.tsx +31 -31
  90. package/templates/default/components/pages/dashboard-page-alt.tsx +45 -45
  91. package/templates/default/components/pages/item-edit-page.tsx +267 -267
  92. package/templates/default/components/pages/log-page.tsx +107 -107
  93. package/templates/default/components/pages/new-page.tsx +183 -183
  94. package/templates/default/components/pages/section-page.tsx +203 -203
  95. package/templates/default/components/pages/settings-page.tsx +232 -232
  96. package/templates/default/components/pagination/pagination-buttons.tsx +147 -147
  97. package/templates/default/components/pagination/pagination.tsx +36 -36
  98. package/templates/default/components/sections/category-delete-confirm-page.tsx +130 -130
  99. package/templates/default/components/sections/category-section-select-input.tsx +139 -139
  100. package/templates/default/components/sections/conditional-fields.tsx +49 -49
  101. package/templates/default/components/sections/section-icon.tsx +8 -8
  102. package/templates/default/components/sections/section-item-card.tsx +143 -143
  103. package/templates/default/components/sections/section-item-status-badge.tsx +17 -17
  104. package/templates/default/components/sections/select-input-buttons.tsx +125 -125
  105. package/templates/default/components/select-box.tsx +98 -98
  106. package/templates/default/components/ui/accordion.tsx +53 -53
  107. package/templates/default/components/ui/alert-dialog.tsx +113 -113
  108. package/templates/default/components/ui/alert.tsx +47 -47
  109. package/templates/default/components/ui/badge.tsx +38 -38
  110. package/templates/default/components/ui/card.tsx +43 -43
  111. package/templates/default/components/ui/command.tsx +137 -137
  112. package/templates/default/components/ui/custom-alert-dialog.tsx +113 -113
  113. package/templates/default/components/ui/custom-dialog.tsx +123 -123
  114. package/templates/default/components/ui/dialog.tsx +123 -123
  115. package/templates/default/components/ui/direction.tsx +22 -22
  116. package/templates/default/components/ui/dropdown-menu.tsx +182 -182
  117. package/templates/default/components/ui/input-group.tsx +54 -54
  118. package/templates/default/components/ui/input.tsx +22 -22
  119. package/templates/default/components/ui/label.tsx +19 -19
  120. package/templates/default/components/ui/popover.tsx +42 -42
  121. package/templates/default/components/ui/progress.tsx +31 -31
  122. package/templates/default/components/ui/scroll-area.tsx +42 -42
  123. package/templates/default/components/ui/select.tsx +165 -165
  124. package/templates/default/components/ui/separator.tsx +28 -28
  125. package/templates/default/components/ui/sheet.tsx +103 -103
  126. package/templates/default/components/ui/spinner.tsx +16 -16
  127. package/templates/default/components/ui/switch.tsx +29 -29
  128. package/templates/default/components/ui/table.tsx +83 -83
  129. package/templates/default/components/ui/tabs.tsx +55 -55
  130. package/templates/default/components/ui/toast.tsx +113 -113
  131. package/templates/default/components/ui/toaster.tsx +35 -35
  132. package/templates/default/components/ui/tooltip.tsx +30 -30
  133. package/templates/default/components/ui/use-toast.ts +187 -187
  134. package/templates/default/drizzle.config.ts +4 -4
  135. package/templates/default/dynamic-schemas/schema.ts +225 -75
  136. package/templates/default/env/env.ts +46 -46
  137. package/templates/default/envConfig.ts +4 -4
  138. package/templates/default/lib/postinstall.js +14 -14
  139. package/templates/default/lib/utils.ts +6 -6
  140. package/templates/default/next-env.d.ts +6 -6
  141. package/templates/default/next.config.ts +24 -24
  142. package/templates/default/package.json +1 -1
  143. package/templates/default/postcss.config.mjs +6 -6
  144. package/templates/default/proxy.ts +32 -32
  145. package/templates/default/tsconfig.json +48 -48
@@ -1,255 +1,255 @@
1
- import FormInputElement from '@/components/form/form-input-element'
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({ input, sectionName }: { input: TagsFieldClientConfig; sectionName: string }) {
14
- const t = useI18n()
15
- const searchParams = useSearchParams()
16
- const localeParam = searchParams.get('locale')
17
- const locale = input.localized ? (localeParam?.trim() ? localeParam : undefined) : undefined
18
- const { control } = useFormContext()
19
- const {
20
- field,
21
- fieldState: { invalid, isTouched, isDirty, error },
22
- } = useController({
23
- name: input.name,
24
- control,
25
- defaultValue: input.value ?? undefined,
26
- })
27
-
28
- const [inputValue, setInputValue] = useState('')
29
- const [highlightedTag, setHighlightedTag] = useState<string | null>(null)
30
- const [highlightedIndex, setHighlightedIndex] = useState(-1)
31
- const [showDropdown, setShowDropdown] = useState(false)
32
- const inputRef = useRef<HTMLInputElement>(null)
33
- const dropdownRef = useRef<HTMLDivElement>(null)
34
- const [debouncedQuery, setDebouncedQuery] = useState('')
35
-
36
- // Debounce the query input
37
- useEffect(() => {
38
- if (!input.hasAutoCompletion) return
39
- const timer = setTimeout(() => {
40
- setDebouncedQuery(inputValue)
41
- }, 300)
42
- return () => clearTimeout(timer)
43
- }, [inputValue, input.hasAutoCompletion])
44
-
45
- const { data: suggestions } = trpc.fields.tagsAutoComplete.useQuery(
46
- { sectionName, fieldName: input.name, query: debouncedQuery, locale },
47
- { enabled: input.hasAutoCompletion && debouncedQuery.length >= 1 },
48
- )
49
-
50
- // Parse tags from comma-separated string
51
- const getTags = useCallback((): string[] => {
52
- if (!field.value || typeof field.value !== 'string') return []
53
- return field.value.split(',').filter((tag) => tag.trim() !== '')
54
- }, [field.value])
55
-
56
- // Filter out already-added tags from suggestions
57
- const filteredSuggestions = suggestions?.filter((s) => !getTags().includes(s)) ?? []
58
-
59
- // Show/hide dropdown based on suggestions
60
- useEffect(() => {
61
- setShowDropdown(filteredSuggestions.length > 0 && inputValue.length >= 1)
62
- setHighlightedIndex(-1)
63
- }, [filteredSuggestions.length, inputValue.length])
64
-
65
- // Convert tags array to comma-separated string
66
- const setTags = (tags: string[]) => {
67
- const filteredTags = tags.filter((tag) => tag.trim() !== '')
68
- const value = filteredTags.length > 0 ? filteredTags.join(',') : ''
69
- field.onChange(value)
70
- }
71
-
72
- const addTag = (tag: string) => {
73
- const trimmedTag = tag.trim()
74
- if (trimmedTag) {
75
- const existingTags = getTags()
76
- if (existingTags.includes(trimmedTag)) {
77
- // Highlight the existing tag to show it already exists
78
- setHighlightedTag(trimmedTag)
79
- setTimeout(() => setHighlightedTag(null), 1000)
80
- } else {
81
- const newTags = [...existingTags, trimmedTag]
82
- setTags(newTags)
83
- setInputValue('')
84
- setShowDropdown(false)
85
- }
86
- }
87
- }
88
-
89
- const removeTag = (tagToRemove: string) => {
90
- const newTags = getTags().filter((tag) => tag !== tagToRemove)
91
- setTags(newTags)
92
- }
93
-
94
- const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
95
- if (showDropdown && filteredSuggestions.length > 0) {
96
- if (event.key === 'ArrowDown') {
97
- event.preventDefault()
98
- setHighlightedIndex((prev) => (prev < filteredSuggestions.length - 1 ? prev + 1 : 0))
99
- return
100
- }
101
- if (event.key === 'ArrowUp') {
102
- event.preventDefault()
103
- setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : filteredSuggestions.length - 1))
104
- return
105
- }
106
- if (event.key === 'Enter' && highlightedIndex >= 0) {
107
- event.preventDefault()
108
- const suggestion = filteredSuggestions[highlightedIndex]
109
- if (suggestion !== undefined) addTag(suggestion)
110
- return
111
- }
112
- if (event.key === 'Escape') {
113
- event.preventDefault()
114
- setShowDropdown(false)
115
- setHighlightedIndex(-1)
116
- return
117
- }
118
- }
119
-
120
- if (event.key === 'Enter' || event.key === ',') {
121
- event.preventDefault()
122
- if (inputValue.trim()) {
123
- addTag(inputValue)
124
- }
125
- } else if (event.key === 'Backspace' && !inputValue && getTags().length > 0) {
126
- // Remove last tag when backspace is pressed on empty input
127
- const lastTag = getTags()[getTags().length - 1]
128
- if (lastTag) {
129
- removeTag(lastTag)
130
- }
131
- }
132
- }
133
-
134
- const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
135
- setInputValue(event.target.value)
136
- }
137
-
138
- const handleContainerClick = () => {
139
- inputRef.current?.focus()
140
- }
141
-
142
- const handleBlur = () => {
143
- // Small delay to allow click events on dropdown items to fire first
144
- setTimeout(() => {
145
- setShowDropdown(false)
146
- setHighlightedIndex(-1)
147
- }, 200)
148
- }
149
-
150
- const tags = getTags()
151
-
152
- return (
153
- <FormInputElement
154
- validationError={error}
155
- value={field.value}
156
- readonly={input.readonly}
157
- label={input.label}
158
- required={input.required}
159
- >
160
- <input
161
- type='hidden'
162
- className='hidden'
163
- disabled={field.disabled}
164
- name={field.name}
165
- onBlur={field.onBlur}
166
- value={field.value ?? ''}
167
- ref={field.ref}
168
- />
169
-
170
- <div className='relative'>
171
- <div
172
- className={cn(
173
- 'bg-input text-foreground w-full rounded px-3 py-1 shadow-xs ring-2 ring-gray-300 outline-0 transition-colors focus-within:ring-blue-500 hover:ring-gray-400',
174
- 'flex min-h-9 flex-wrap items-center gap-1 text-sm',
175
- 'cursor-text',
176
- field.disabled && 'cursor-not-allowed opacity-50',
177
- )}
178
- onClick={handleContainerClick}
179
- >
180
- {tags.map((tag, index) => (
181
- <Badge
182
- key={`${tag}-${index}`}
183
- variant='secondary'
184
- className={cn(
185
- 'border-foreground/40 flex items-center gap-1 px-2 py-1 text-xs transition-all duration-200',
186
- highlightedTag === tag ? 'bg-success/20 border-success/50 animate-pulse' : '',
187
- )}
188
- >
189
- <span>{tag}</span>
190
- {!field.disabled ? (
191
- <button
192
- type='button'
193
- onClick={(e) => {
194
- e.stopPropagation()
195
- removeTag(tag)
196
- }}
197
- className='hover:bg-muted-foreground/20 focus:ring-ring ml-1 rounded-sm focus:ring-1 focus:outline-none'
198
- aria-label={`Remove ${tag} tag`}
199
- >
200
- <X className='h-3 w-3' />
201
- </button>
202
- ) : null}
203
- </Badge>
204
- ))}
205
-
206
- <Input
207
- ref={inputRef}
208
- type='text'
209
- value={inputValue}
210
- onChange={handleInputChange}
211
- onKeyDown={handleKeyDown}
212
- onBlur={handleBlur}
213
- placeholder={
214
- tags.length === 0
215
- ? input.placeholder
216
- ? input.placeholder
217
- : input.hasAutoCompletion
218
- ? t('startTypingForSuggestions')
219
- : t('startTyping')
220
- : ''
221
- }
222
- className='min-w-20 flex-1 border-0 p-0 shadow-none focus-visible:ring-0 focus-visible:ring-offset-0'
223
- disabled={field.disabled}
224
- />
225
- </div>
226
-
227
- {showDropdown && filteredSuggestions.length > 0 && (
228
- <div
229
- ref={dropdownRef}
230
- className='bg-popover border-border absolute z-50 mt-1 max-h-48 w-full overflow-auto rounded-md border shadow-md'
231
- >
232
- {filteredSuggestions.map((suggestion, index) => (
233
- <button
234
- key={suggestion}
235
- type='button'
236
- className={cn(
237
- 'text-popover-foreground w-full px-3 py-2 text-left text-sm',
238
- 'hover:bg-accent hover:text-accent-foreground',
239
- highlightedIndex === index && 'bg-accent text-accent-foreground',
240
- )}
241
- onMouseDown={(e) => {
242
- e.preventDefault()
243
- addTag(suggestion)
244
- }}
245
- onMouseEnter={() => setHighlightedIndex(index)}
246
- >
247
- {suggestion}
248
- </button>
249
- ))}
250
- </div>
251
- )}
252
- </div>
253
- </FormInputElement>
254
- )
255
- }
1
+ import FormInputElement from '@/components/form/form-input-element'
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({ input, sectionName }: { input: TagsFieldClientConfig; sectionName: string }) {
14
+ const t = useI18n()
15
+ const searchParams = useSearchParams()
16
+ const localeParam = searchParams.get('locale')
17
+ const locale = input.localized ? (localeParam?.trim() ? localeParam : undefined) : undefined
18
+ const { control } = useFormContext()
19
+ const {
20
+ field,
21
+ fieldState: { invalid, isTouched, isDirty, error },
22
+ } = useController({
23
+ name: input.name,
24
+ control,
25
+ defaultValue: input.value ?? undefined,
26
+ })
27
+
28
+ const [inputValue, setInputValue] = useState('')
29
+ const [highlightedTag, setHighlightedTag] = useState<string | null>(null)
30
+ const [highlightedIndex, setHighlightedIndex] = useState(-1)
31
+ const [showDropdown, setShowDropdown] = useState(false)
32
+ const inputRef = useRef<HTMLInputElement>(null)
33
+ const dropdownRef = useRef<HTMLDivElement>(null)
34
+ const [debouncedQuery, setDebouncedQuery] = useState('')
35
+
36
+ // Debounce the query input
37
+ useEffect(() => {
38
+ if (!input.hasAutoCompletion) return
39
+ const timer = setTimeout(() => {
40
+ setDebouncedQuery(inputValue)
41
+ }, 300)
42
+ return () => clearTimeout(timer)
43
+ }, [inputValue, input.hasAutoCompletion])
44
+
45
+ const { data: suggestions } = trpc.fields.tagsAutoComplete.useQuery(
46
+ { sectionName, fieldName: input.name, query: debouncedQuery, locale },
47
+ { enabled: input.hasAutoCompletion && debouncedQuery.length >= 1 },
48
+ )
49
+
50
+ // Parse tags from comma-separated string
51
+ const getTags = useCallback((): string[] => {
52
+ if (!field.value || typeof field.value !== 'string') return []
53
+ return field.value.split(',').filter((tag) => tag.trim() !== '')
54
+ }, [field.value])
55
+
56
+ // Filter out already-added tags from suggestions
57
+ const filteredSuggestions = suggestions?.filter((s) => !getTags().includes(s)) ?? []
58
+
59
+ // Show/hide dropdown based on suggestions
60
+ useEffect(() => {
61
+ setShowDropdown(filteredSuggestions.length > 0 && inputValue.length >= 1)
62
+ setHighlightedIndex(-1)
63
+ }, [filteredSuggestions.length, inputValue.length])
64
+
65
+ // Convert tags array to comma-separated string
66
+ const setTags = (tags: string[]) => {
67
+ const filteredTags = tags.filter((tag) => tag.trim() !== '')
68
+ const value = filteredTags.length > 0 ? filteredTags.join(',') : ''
69
+ field.onChange(value)
70
+ }
71
+
72
+ const addTag = (tag: string) => {
73
+ const trimmedTag = tag.trim()
74
+ if (trimmedTag) {
75
+ const existingTags = getTags()
76
+ if (existingTags.includes(trimmedTag)) {
77
+ // Highlight the existing tag to show it already exists
78
+ setHighlightedTag(trimmedTag)
79
+ setTimeout(() => setHighlightedTag(null), 1000)
80
+ } else {
81
+ const newTags = [...existingTags, trimmedTag]
82
+ setTags(newTags)
83
+ setInputValue('')
84
+ setShowDropdown(false)
85
+ }
86
+ }
87
+ }
88
+
89
+ const removeTag = (tagToRemove: string) => {
90
+ const newTags = getTags().filter((tag) => tag !== tagToRemove)
91
+ setTags(newTags)
92
+ }
93
+
94
+ const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
95
+ if (showDropdown && filteredSuggestions.length > 0) {
96
+ if (event.key === 'ArrowDown') {
97
+ event.preventDefault()
98
+ setHighlightedIndex((prev) => (prev < filteredSuggestions.length - 1 ? prev + 1 : 0))
99
+ return
100
+ }
101
+ if (event.key === 'ArrowUp') {
102
+ event.preventDefault()
103
+ setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : filteredSuggestions.length - 1))
104
+ return
105
+ }
106
+ if (event.key === 'Enter' && highlightedIndex >= 0) {
107
+ event.preventDefault()
108
+ const suggestion = filteredSuggestions[highlightedIndex]
109
+ if (suggestion !== undefined) addTag(suggestion)
110
+ return
111
+ }
112
+ if (event.key === 'Escape') {
113
+ event.preventDefault()
114
+ setShowDropdown(false)
115
+ setHighlightedIndex(-1)
116
+ return
117
+ }
118
+ }
119
+
120
+ if (event.key === 'Enter' || event.key === ',') {
121
+ event.preventDefault()
122
+ if (inputValue.trim()) {
123
+ addTag(inputValue)
124
+ }
125
+ } else if (event.key === 'Backspace' && !inputValue && getTags().length > 0) {
126
+ // Remove last tag when backspace is pressed on empty input
127
+ const lastTag = getTags()[getTags().length - 1]
128
+ if (lastTag) {
129
+ removeTag(lastTag)
130
+ }
131
+ }
132
+ }
133
+
134
+ const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
135
+ setInputValue(event.target.value)
136
+ }
137
+
138
+ const handleContainerClick = () => {
139
+ inputRef.current?.focus()
140
+ }
141
+
142
+ const handleBlur = () => {
143
+ // Small delay to allow click events on dropdown items to fire first
144
+ setTimeout(() => {
145
+ setShowDropdown(false)
146
+ setHighlightedIndex(-1)
147
+ }, 200)
148
+ }
149
+
150
+ const tags = getTags()
151
+
152
+ return (
153
+ <FormInputElement
154
+ validationError={error}
155
+ value={field.value}
156
+ readonly={input.readonly}
157
+ label={input.label}
158
+ required={input.required}
159
+ >
160
+ <input
161
+ type='hidden'
162
+ className='hidden'
163
+ disabled={field.disabled}
164
+ name={field.name}
165
+ onBlur={field.onBlur}
166
+ value={field.value ?? ''}
167
+ ref={field.ref}
168
+ />
169
+
170
+ <div className='relative'>
171
+ <div
172
+ className={cn(
173
+ 'bg-input text-foreground w-full rounded px-3 py-1 shadow-xs ring-2 ring-gray-300 outline-0 transition-colors focus-within:ring-blue-500 hover:ring-gray-400',
174
+ 'flex min-h-9 flex-wrap items-center gap-1 text-sm',
175
+ 'cursor-text',
176
+ field.disabled && 'cursor-not-allowed opacity-50',
177
+ )}
178
+ onClick={handleContainerClick}
179
+ >
180
+ {tags.map((tag, index) => (
181
+ <Badge
182
+ key={`${tag}-${index}`}
183
+ variant='secondary'
184
+ className={cn(
185
+ 'border-foreground/40 flex items-center gap-1 px-2 py-1 text-xs transition-all duration-200',
186
+ highlightedTag === tag ? 'bg-success/20 border-success/50 animate-pulse' : '',
187
+ )}
188
+ >
189
+ <span>{tag}</span>
190
+ {!field.disabled ? (
191
+ <button
192
+ type='button'
193
+ onClick={(e) => {
194
+ e.stopPropagation()
195
+ removeTag(tag)
196
+ }}
197
+ className='hover:bg-muted-foreground/20 focus:ring-ring ml-1 rounded-sm focus:ring-1 focus:outline-none'
198
+ aria-label={`Remove ${tag} tag`}
199
+ >
200
+ <X className='h-3 w-3' />
201
+ </button>
202
+ ) : null}
203
+ </Badge>
204
+ ))}
205
+
206
+ <Input
207
+ ref={inputRef}
208
+ type='text'
209
+ value={inputValue}
210
+ onChange={handleInputChange}
211
+ onKeyDown={handleKeyDown}
212
+ onBlur={handleBlur}
213
+ placeholder={
214
+ tags.length === 0
215
+ ? input.placeholder
216
+ ? input.placeholder
217
+ : input.hasAutoCompletion
218
+ ? t('startTypingForSuggestions')
219
+ : t('startTyping')
220
+ : ''
221
+ }
222
+ className='min-w-20 flex-1 border-0 p-0 shadow-none focus-visible:ring-0 focus-visible:ring-offset-0'
223
+ disabled={field.disabled}
224
+ />
225
+ </div>
226
+
227
+ {showDropdown && filteredSuggestions.length > 0 && (
228
+ <div
229
+ ref={dropdownRef}
230
+ className='bg-popover border-border absolute z-50 mt-1 max-h-48 w-full overflow-auto rounded-md border shadow-md'
231
+ >
232
+ {filteredSuggestions.map((suggestion, index) => (
233
+ <button
234
+ key={suggestion}
235
+ type='button'
236
+ className={cn(
237
+ 'text-popover-foreground w-full px-3 py-2 text-left text-sm',
238
+ 'hover:bg-accent hover:text-accent-foreground',
239
+ highlightedIndex === index && 'bg-accent text-accent-foreground',
240
+ )}
241
+ onMouseDown={(e) => {
242
+ e.preventDefault()
243
+ addTag(suggestion)
244
+ }}
245
+ onMouseEnter={() => setHighlightedIndex(index)}
246
+ >
247
+ {suggestion}
248
+ </button>
249
+ ))}
250
+ </div>
251
+ )}
252
+ </div>
253
+ </FormInputElement>
254
+ )
255
+ }
@@ -1,61 +1,61 @@
1
- import FormInputElement from '@/components/form/form-input-element'
2
- import type { TextFieldClientConfig } from 'nextjs-cms/core/fields'
3
- import { useFormContext, useController } from 'react-hook-form'
4
- import { useLocale } from '@/components/form/content-locale-context'
5
- import { useUILanguage } from 'nextjs-cms/translations/client'
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 uiLanguage = useUILanguage()
27
- const locale = useLocale()
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={
40
- input.rtl !== undefined
41
- ? input.rtl
42
- ? 'rtl'
43
- : 'ltr'
44
- : locale
45
- ? locale.rtl
46
- ? 'rtl'
47
- : 'ltr'
48
- : uiLanguage.dir
49
- }
50
- readOnly={disabled}
51
- disabled={field.disabled}
52
- name={field.name}
53
- onChange={field.onChange}
54
- onBlur={field.onBlur}
55
- value={field.value}
56
- ref={field.ref}
57
- 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'
58
- />
59
- </FormInputElement>
60
- )
61
- }
1
+ import FormInputElement from '@/components/form/form-input-element'
2
+ import type { TextFieldClientConfig } from 'nextjs-cms/core/fields'
3
+ import { useFormContext, useController } from 'react-hook-form'
4
+ import { useLocale } from '@/components/form/content-locale-context'
5
+ import { useUILanguage } from 'nextjs-cms/translations/client'
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 uiLanguage = useUILanguage()
27
+ const locale = useLocale()
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={
40
+ input.rtl !== undefined
41
+ ? input.rtl
42
+ ? 'rtl'
43
+ : 'ltr'
44
+ : locale
45
+ ? locale.rtl
46
+ ? 'rtl'
47
+ : 'ltr'
48
+ : uiLanguage.dir
49
+ }
50
+ readOnly={disabled}
51
+ disabled={field.disabled}
52
+ name={field.name}
53
+ onChange={field.onChange}
54
+ onBlur={field.onBlur}
55
+ value={field.value}
56
+ ref={field.ref}
57
+ 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'
58
+ />
59
+ </FormInputElement>
60
+ )
61
+ }