create-nextjs-cms 0.7.6 → 0.7.8

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-nextjs-cms",
3
- "version": "0.7.6",
3
+ "version": "0.7.8",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
@@ -95,7 +95,13 @@ export default function FormInputs({ inputs, sectionName }: { inputs: FieldClien
95
95
  case 'password':
96
96
  return <PasswordFormInput input={input as PasswordFieldClientConfig} key={input.name} />
97
97
  case 'tags':
98
- return <TagsFormInput input={input as TagsFieldClientConfig} key={input.name} />
98
+ return (
99
+ <TagsFormInput
100
+ input={input as TagsFieldClientConfig}
101
+ sectionName={sectionName}
102
+ key={input.name}
103
+ />
104
+ )
99
105
  case 'map':
100
106
  return <MapFormInput input={input as MapFieldClientConfig} key={input.name} />
101
107
  case 'checkbox':
@@ -4,20 +4,32 @@ import { CheckboxFieldClientConfig } from 'nextjs-cms/core/fields'
4
4
  import { useController, useFormContext } from 'react-hook-form'
5
5
  import { Switch } from '@/components/ui/switch'
6
6
 
7
+ function toChecked(value: unknown): boolean {
8
+ if (value === true) return true
9
+ if (value === 1) return true
10
+ if (value === '1') return true
11
+ if (value === 'true') return true
12
+ if (value === 'on') return true
13
+ return false
14
+ }
15
+
7
16
  export default function CheckboxFormInput({ input }: { input: CheckboxFieldClientConfig }) {
8
17
  const { control } = useFormContext()
18
+ const initialValue =
19
+ input.value !== undefined && input.value !== null ? toChecked(input.value) : toChecked(input.defaultValue)
20
+
9
21
  const {
10
22
  field,
11
- fieldState: { invalid, isTouched, isDirty, error },
23
+ fieldState: { error },
12
24
  } = useController({
13
25
  name: input.name,
14
26
  control,
15
- defaultValue: input.value === 1,
27
+ defaultValue: initialValue,
16
28
  })
17
29
  return (
18
30
  <FormInputElement
19
31
  validationError={error}
20
- value={input.value}
32
+ value={initialValue}
21
33
  readonly={input.readonly}
22
34
  label={input.label}
23
35
  required={input.required}
@@ -25,8 +37,9 @@ export default function CheckboxFormInput({ input }: { input: CheckboxFieldClien
25
37
  <Switch
26
38
  ref={field.ref}
27
39
  onCheckedChange={field.onChange}
28
- defaultChecked={input.value === 1}
40
+ checked={toChecked(field.value)}
29
41
  name={field.name}
42
+ disabled={input.readonly}
30
43
  />
31
44
  </FormInputElement>
32
45
  )
@@ -5,10 +5,17 @@ import { useController, useFormContext } from 'react-hook-form'
5
5
  import { Badge } from '@/components/ui/badge'
6
6
  import { Input } from '@/components/ui/input'
7
7
  import { X } from 'lucide-react'
8
- import { useState, useRef } from 'react'
8
+ import { useState, useRef, useEffect, useCallback } from 'react'
9
9
  import { cn } from '@/lib/utils'
10
+ import { trpc } from '@/app/_trpc/client'
10
11
 
11
- export default function TagsFormInput({ input }: { input: TagsFieldClientConfig }) {
12
+ export default function TagsFormInput({
13
+ input,
14
+ sectionName,
15
+ }: {
16
+ input: TagsFieldClientConfig
17
+ sectionName: string
18
+ }) {
12
19
  const t = useI18n()
13
20
  const { control } = useFormContext()
14
21
  const {
@@ -22,13 +29,40 @@ export default function TagsFormInput({ input }: { input: TagsFieldClientConfig
22
29
 
23
30
  const [inputValue, setInputValue] = useState('')
24
31
  const [highlightedTag, setHighlightedTag] = useState<string | null>(null)
32
+ const [highlightedIndex, setHighlightedIndex] = useState(-1)
33
+ const [showDropdown, setShowDropdown] = useState(false)
25
34
  const inputRef = useRef<HTMLInputElement>(null)
35
+ const dropdownRef = useRef<HTMLDivElement>(null)
36
+ const [debouncedQuery, setDebouncedQuery] = useState('')
37
+
38
+ // Debounce the query input
39
+ useEffect(() => {
40
+ if (!input.hasAutoCompletion) return
41
+ const timer = setTimeout(() => {
42
+ setDebouncedQuery(inputValue)
43
+ }, 300)
44
+ return () => clearTimeout(timer)
45
+ }, [inputValue, input.hasAutoCompletion])
46
+
47
+ const { data: suggestions } = trpc.fields.tagsAutoComplete.useQuery(
48
+ { sectionName, fieldName: input.name, query: debouncedQuery },
49
+ { enabled: input.hasAutoCompletion && debouncedQuery.length >= 1 },
50
+ )
26
51
 
27
52
  // Parse tags from comma-separated string
28
- const getTags = (): string[] => {
53
+ const getTags = useCallback((): string[] => {
29
54
  if (!field.value || typeof field.value !== 'string') return []
30
55
  return field.value.split(',').filter((tag) => tag.trim() !== '')
31
- }
56
+ }, [field.value])
57
+
58
+ // Filter out already-added tags from suggestions
59
+ const filteredSuggestions = suggestions?.filter((s) => !getTags().includes(s)) ?? []
60
+
61
+ // Show/hide dropdown based on suggestions
62
+ useEffect(() => {
63
+ setShowDropdown(filteredSuggestions.length > 0 && inputValue.length >= 1)
64
+ setHighlightedIndex(-1)
65
+ }, [filteredSuggestions.length, inputValue.length])
32
66
 
33
67
  // Convert tags array to comma-separated string
34
68
  const setTags = (tags: string[]) => {
@@ -49,6 +83,7 @@ export default function TagsFormInput({ input }: { input: TagsFieldClientConfig
49
83
  const newTags = [...existingTags, trimmedTag]
50
84
  setTags(newTags)
51
85
  setInputValue('')
86
+ setShowDropdown(false)
52
87
  }
53
88
  }
54
89
  }
@@ -59,6 +94,34 @@ export default function TagsFormInput({ input }: { input: TagsFieldClientConfig
59
94
  }
60
95
 
61
96
  const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
97
+ if (showDropdown && filteredSuggestions.length > 0) {
98
+ if (event.key === 'ArrowDown') {
99
+ event.preventDefault()
100
+ setHighlightedIndex((prev) =>
101
+ prev < filteredSuggestions.length - 1 ? prev + 1 : 0,
102
+ )
103
+ return
104
+ }
105
+ if (event.key === 'ArrowUp') {
106
+ event.preventDefault()
107
+ setHighlightedIndex((prev) =>
108
+ prev > 0 ? prev - 1 : filteredSuggestions.length - 1,
109
+ )
110
+ return
111
+ }
112
+ if (event.key === 'Enter' && highlightedIndex >= 0) {
113
+ event.preventDefault()
114
+ addTag(filteredSuggestions[highlightedIndex])
115
+ return
116
+ }
117
+ if (event.key === 'Escape') {
118
+ event.preventDefault()
119
+ setShowDropdown(false)
120
+ setHighlightedIndex(-1)
121
+ return
122
+ }
123
+ }
124
+
62
125
  if (event.key === 'Enter' || event.key === ',') {
63
126
  event.preventDefault()
64
127
  if (inputValue.trim()) {
@@ -81,6 +144,14 @@ export default function TagsFormInput({ input }: { input: TagsFieldClientConfig
81
144
  inputRef.current?.focus()
82
145
  }
83
146
 
147
+ const handleBlur = () => {
148
+ // Small delay to allow click events on dropdown items to fire first
149
+ setTimeout(() => {
150
+ setShowDropdown(false)
151
+ setHighlightedIndex(-1)
152
+ }, 200)
153
+ }
154
+
84
155
  const tags = getTags()
85
156
 
86
157
  return (
@@ -101,53 +172,88 @@ export default function TagsFormInput({ input }: { input: TagsFieldClientConfig
101
172
  ref={field.ref}
102
173
  />
103
174
 
104
- <div
105
- className={cn(
106
- 'bg-input text-foreground w-full rounded px-3 py-1 shadow-xs ring-2 ring-gray-300 outline-0 transition-colors hover:ring-gray-400 focus-within:ring-blue-500',
107
- 'flex min-h-9 flex-wrap items-center gap-1 text-sm',
108
- 'cursor-text',
109
- field.disabled && 'cursor-not-allowed opacity-50',
110
- )}
111
- onClick={handleContainerClick}
112
- >
113
- {tags.map((tag, index) => (
114
- <Badge
115
- key={`${tag}-${index}`}
116
- variant='secondary'
117
- className={cn(
118
- 'flex items-center gap-1 px-2 py-1 text-xs transition-all duration-200 border-foreground/40',
119
- highlightedTag === tag ? 'bg-success/20 border-success/50 animate-pulse' : '',
120
- )}
175
+ <div className='relative'>
176
+ <div
177
+ className={cn(
178
+ 'bg-input text-foreground w-full rounded px-3 py-1 shadow-xs ring-2 ring-gray-300 outline-0 transition-colors hover:ring-gray-400 focus-within:ring-blue-500',
179
+ 'flex min-h-9 flex-wrap items-center gap-1 text-sm',
180
+ 'cursor-text',
181
+ field.disabled && 'cursor-not-allowed opacity-50',
182
+ )}
183
+ onClick={handleContainerClick}
184
+ >
185
+ {tags.map((tag, index) => (
186
+ <Badge
187
+ key={`${tag}-${index}`}
188
+ variant='secondary'
189
+ className={cn(
190
+ 'flex items-center gap-1 px-2 py-1 text-xs transition-all duration-200 border-foreground/40',
191
+ highlightedTag === tag ? 'bg-success/20 border-success/50 animate-pulse' : '',
192
+ )}
193
+ >
194
+ <span>{tag}</span>
195
+ {!field.disabled ? (
196
+ <button
197
+ type='button'
198
+ onClick={(e) => {
199
+ e.stopPropagation()
200
+ removeTag(tag)
201
+ }}
202
+ className='hover:bg-muted-foreground/20 focus:ring-ring ml-1 rounded-sm focus:ring-1 focus:outline-none'
203
+ aria-label={`Remove ${tag} tag`}
204
+ >
205
+ <X className='h-3 w-3' />
206
+ </button>
207
+ ) : null}
208
+ </Badge>
209
+ ))}
210
+
211
+ <Input
212
+ ref={inputRef}
213
+ type='text'
214
+ value={inputValue}
215
+ onChange={handleInputChange}
216
+ onKeyDown={handleKeyDown}
217
+ onBlur={handleBlur}
218
+ placeholder={
219
+ tags.length === 0
220
+ ? input.placeholder
221
+ ? input.placeholder
222
+ : input.hasAutoCompletion
223
+ ? t('startTypingForSuggestions')
224
+ : t('startTyping')
225
+ : ''
226
+ }
227
+ className='min-w-20 flex-1 border-0 p-0 shadow-none focus-visible:ring-0 focus-visible:ring-offset-0'
228
+ disabled={field.disabled}
229
+ />
230
+ </div>
231
+
232
+ {showDropdown && filteredSuggestions.length > 0 && (
233
+ <div
234
+ ref={dropdownRef}
235
+ className='bg-popover border-border absolute z-50 mt-1 max-h-48 w-full overflow-auto rounded-md border shadow-md'
121
236
  >
122
- <span>{tag}</span>
123
- {!field.disabled ? (
237
+ {filteredSuggestions.map((suggestion, index) => (
124
238
  <button
239
+ key={suggestion}
125
240
  type='button'
126
- onClick={(e) => {
127
- e.stopPropagation()
128
- removeTag(tag)
241
+ className={cn(
242
+ 'text-popover-foreground w-full px-3 py-2 text-left text-sm',
243
+ 'hover:bg-accent hover:text-accent-foreground',
244
+ highlightedIndex === index && 'bg-accent text-accent-foreground',
245
+ )}
246
+ onMouseDown={(e) => {
247
+ e.preventDefault()
248
+ addTag(suggestion)
129
249
  }}
130
- className='hover:bg-muted-foreground/20 focus:ring-ring ml-1 rounded-sm focus:ring-1 focus:outline-none'
131
- aria-label={`Remove ${tag} tag`}
250
+ onMouseEnter={() => setHighlightedIndex(index)}
132
251
  >
133
- <X className='h-3 w-3' />
252
+ {suggestion}
134
253
  </button>
135
- ) : null}
136
- </Badge>
137
- ))}
138
-
139
- <Input
140
- ref={inputRef}
141
- type='text'
142
- value={inputValue}
143
- onChange={handleInputChange}
144
- onKeyDown={handleKeyDown}
145
- placeholder={
146
- tags.length === 0 ? (input.placeholder ? input.placeholder : t('startTyping')) : ''
147
- }
148
- className='min-w-20 flex-1 border-0 p-0 shadow-none focus-visible:ring-0 focus-visible:ring-offset-0'
149
- disabled={field.disabled}
150
- />
254
+ ))}
255
+ </div>
256
+ )}
151
257
  </div>
152
258
  </FormInputElement>
153
259
  )
@@ -66,7 +66,7 @@
66
66
  "nanoid": "^5.1.2",
67
67
  "next": "16.1.1",
68
68
  "next-themes": "^0.4.6",
69
- "nextjs-cms": "0.7.6",
69
+ "nextjs-cms": "0.7.8",
70
70
  "plaiceholder": "^3.0.0",
71
71
  "prettier-plugin-tailwindcss": "^0.7.2",
72
72
  "qrcode": "^1.5.4",