create-nextjs-cms 0.9.30 → 0.9.31

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 (143) 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 -34
  14. package/templates/default/app/(auth)/layout.tsx +81 -81
  15. package/templates/default/app/(rootLayout)/(plugins)/[...slug]/plugin-server-registry.ts +10 -6
  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 +70 -70
  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/api/auth/csrf/route.ts +25 -25
  28. package/templates/default/app/api/auth/refresh/route.ts +10 -10
  29. package/templates/default/app/api/auth/route.ts +49 -49
  30. package/templates/default/app/api/auth/session/route.ts +20 -20
  31. package/templates/default/app/api/document/route.ts +165 -165
  32. package/templates/default/app/api/editor/photo/route.ts +49 -49
  33. package/templates/default/app/api/photo/route.ts +27 -27
  34. package/templates/default/app/api/submit/section/item/[slug]/route.ts +95 -95
  35. package/templates/default/app/api/submit/section/item/route.ts +56 -56
  36. package/templates/default/app/api/submit/section/simple/route.ts +86 -86
  37. package/templates/default/app/api/video/route.ts +174 -174
  38. package/templates/default/app/globals.css +236 -236
  39. package/templates/default/cms.config.ts +56 -56
  40. package/templates/default/components/admin/admin-card.tsx +165 -165
  41. package/templates/default/components/admin/admin-edit-page.tsx +124 -124
  42. package/templates/default/components/admin/admin-privilege-card.tsx +184 -184
  43. package/templates/default/components/admin/new-admin-form.tsx +172 -172
  44. package/templates/default/components/container-box.tsx +24 -24
  45. package/templates/default/components/dnd-kit/draggable.tsx +21 -21
  46. package/templates/default/components/dnd-kit/droppable.tsx +20 -20
  47. package/templates/default/components/dnd-kit/sortable-item.tsx +18 -18
  48. package/templates/default/components/feedback/error-component.tsx +16 -16
  49. package/templates/default/components/feedback/info-card.tsx +93 -93
  50. package/templates/default/components/feedback/loading-spinners.tsx +67 -67
  51. package/templates/default/components/feedback/modal.tsx +166 -166
  52. package/templates/default/components/feedback/progress-bar.tsx +48 -48
  53. package/templates/default/components/feedback/tooltip-component.tsx +27 -27
  54. package/templates/default/components/form/form-input-element.tsx +70 -70
  55. package/templates/default/components/form/helpers/_section-hot-reload.js +1 -1
  56. package/templates/default/components/form/helpers/util.ts +17 -17
  57. package/templates/default/components/form/inputs/checkbox-form-input.tsx +46 -46
  58. package/templates/default/components/form/inputs/color-form-input.tsx +44 -44
  59. package/templates/default/components/form/inputs/date-form-input.tsx +93 -93
  60. package/templates/default/components/form/inputs/map-form-input.tsx +141 -141
  61. package/templates/default/components/form/inputs/multiple-select-form-input.tsx +85 -85
  62. package/templates/default/components/form/inputs/number-form-input.tsx +43 -43
  63. package/templates/default/components/form/inputs/password-form-input.tsx +47 -47
  64. package/templates/default/components/form/inputs/photo-form-input.tsx +279 -279
  65. package/templates/default/components/form/inputs/rich-text-form-input.tsx +148 -148
  66. package/templates/default/components/form/inputs/select-form-input.tsx +159 -159
  67. package/templates/default/components/form/inputs/slug-form-input.tsx +131 -131
  68. package/templates/default/components/form/inputs/tags-form-input.tsx +255 -255
  69. package/templates/default/components/form/inputs/text-form-input.tsx +61 -61
  70. package/templates/default/components/form/inputs/textarea-form-input.tsx +61 -61
  71. package/templates/default/components/layout/default-nav-items.tsx +3 -3
  72. package/templates/default/components/layout/layout.tsx +84 -84
  73. package/templates/default/components/layout/navbar.tsx +258 -258
  74. package/templates/default/components/layout/sidebar-dropdown-item.tsx +83 -83
  75. package/templates/default/components/layout/sidebar-item.tsx +24 -24
  76. package/templates/default/components/layout/sidebar.tsx +229 -229
  77. package/templates/default/components/layout/theme-provider.tsx +8 -8
  78. package/templates/default/components/layout/theme-toggle.tsx +39 -39
  79. package/templates/default/components/locale/locale-switcher.tsx +98 -98
  80. package/templates/default/components/media/dropzone.tsx +154 -154
  81. package/templates/default/components/media/protected-document.tsx +44 -44
  82. package/templates/default/components/media/protected-image.tsx +143 -143
  83. package/templates/default/components/media/protected-video.tsx +76 -76
  84. package/templates/default/components/multi-select.tsx +1150 -1150
  85. package/templates/default/components/pages/admins-page.tsx +43 -43
  86. package/templates/default/components/pages/browse-page.tsx +106 -106
  87. package/templates/default/components/pages/categorized-section-page.tsx +31 -31
  88. package/templates/default/components/pages/dashboard-page-alt.tsx +45 -45
  89. package/templates/default/components/pages/item-edit-page.tsx +267 -267
  90. package/templates/default/components/pages/log-page.tsx +107 -107
  91. package/templates/default/components/pages/new-page.tsx +183 -183
  92. package/templates/default/components/pages/section-page.tsx +203 -203
  93. package/templates/default/components/pages/settings-page.tsx +232 -232
  94. package/templates/default/components/pagination/pagination-buttons.tsx +147 -147
  95. package/templates/default/components/pagination/pagination.tsx +36 -36
  96. package/templates/default/components/sections/category-delete-confirm-page.tsx +130 -130
  97. package/templates/default/components/sections/category-section-select-input.tsx +139 -139
  98. package/templates/default/components/sections/conditional-fields.tsx +49 -49
  99. package/templates/default/components/sections/section-icon.tsx +8 -8
  100. package/templates/default/components/sections/section-item-card.tsx +143 -143
  101. package/templates/default/components/sections/section-item-status-badge.tsx +17 -17
  102. package/templates/default/components/sections/select-input-buttons.tsx +125 -125
  103. package/templates/default/components/select-box.tsx +98 -98
  104. package/templates/default/components/ui/accordion.tsx +53 -53
  105. package/templates/default/components/ui/alert-dialog.tsx +113 -113
  106. package/templates/default/components/ui/alert.tsx +47 -47
  107. package/templates/default/components/ui/badge.tsx +38 -38
  108. package/templates/default/components/ui/card.tsx +43 -43
  109. package/templates/default/components/ui/command.tsx +137 -137
  110. package/templates/default/components/ui/custom-alert-dialog.tsx +113 -113
  111. package/templates/default/components/ui/custom-dialog.tsx +123 -123
  112. package/templates/default/components/ui/dialog.tsx +123 -123
  113. package/templates/default/components/ui/direction.tsx +22 -22
  114. package/templates/default/components/ui/dropdown-menu.tsx +182 -182
  115. package/templates/default/components/ui/input-group.tsx +54 -54
  116. package/templates/default/components/ui/input.tsx +22 -22
  117. package/templates/default/components/ui/label.tsx +19 -19
  118. package/templates/default/components/ui/popover.tsx +42 -42
  119. package/templates/default/components/ui/progress.tsx +31 -31
  120. package/templates/default/components/ui/scroll-area.tsx +42 -42
  121. package/templates/default/components/ui/select.tsx +165 -165
  122. package/templates/default/components/ui/separator.tsx +28 -28
  123. package/templates/default/components/ui/sheet.tsx +103 -103
  124. package/templates/default/components/ui/spinner.tsx +16 -16
  125. package/templates/default/components/ui/switch.tsx +29 -29
  126. package/templates/default/components/ui/table.tsx +83 -83
  127. package/templates/default/components/ui/tabs.tsx +55 -55
  128. package/templates/default/components/ui/toast.tsx +113 -113
  129. package/templates/default/components/ui/toaster.tsx +35 -35
  130. package/templates/default/components/ui/tooltip.tsx +30 -30
  131. package/templates/default/components/ui/use-toast.ts +187 -187
  132. package/templates/default/drizzle.config.ts +4 -4
  133. package/templates/default/dynamic-schemas/schema.ts +225 -75
  134. package/templates/default/env/env.ts +46 -46
  135. package/templates/default/envConfig.ts +4 -4
  136. package/templates/default/lib/postinstall.js +14 -14
  137. package/templates/default/lib/utils.ts +6 -6
  138. package/templates/default/next-env.d.ts +6 -6
  139. package/templates/default/next.config.ts +24 -24
  140. package/templates/default/package.json +1 -1
  141. package/templates/default/postcss.config.mjs +6 -6
  142. package/templates/default/proxy.ts +32 -32
  143. 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
+ }