create-nextjs-cms 0.9.28 → 0.9.30

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