@actuate-media/cms-admin 0.1.4 → 0.2.1

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 (95) hide show
  1. package/LICENSE +21 -21
  2. package/dist/AdminRoot.d.ts.map +1 -1
  3. package/dist/AdminRoot.js +17 -11
  4. package/dist/AdminRoot.js.map +1 -1
  5. package/dist/actuate-admin.css +2 -0
  6. package/dist/components/TipTapEditor.js +78 -78
  7. package/dist/lib/useApiData.d.ts +8 -1
  8. package/dist/lib/useApiData.d.ts.map +1 -1
  9. package/dist/lib/useApiData.js +39 -7
  10. package/dist/lib/useApiData.js.map +1 -1
  11. package/dist/views/Dashboard.d.ts +2 -1
  12. package/dist/views/Dashboard.d.ts.map +1 -1
  13. package/dist/views/Dashboard.js +52 -7
  14. package/dist/views/Dashboard.js.map +1 -1
  15. package/package.json +10 -5
  16. package/src/AdminRoot.tsx +312 -0
  17. package/src/__tests__/lib/search.test.ts +138 -0
  18. package/src/__tests__/lib/utils.test.ts +19 -0
  19. package/src/__tests__/router/match-route.test.ts +47 -0
  20. package/src/__tests__/router/strip-base.test.ts +30 -0
  21. package/src/components/Breadcrumbs.tsx +92 -0
  22. package/src/components/CommandPalette.tsx +384 -0
  23. package/src/components/ErrorBoundary.tsx +52 -0
  24. package/src/components/FocalPointPicker.tsx +54 -0
  25. package/src/components/FolderTree.tsx +427 -0
  26. package/src/components/LivePreview.tsx +136 -0
  27. package/src/components/LocaleProvider.tsx +51 -0
  28. package/src/components/LocaleSwitcher.tsx +51 -0
  29. package/src/components/MediaPickerModal.tsx +183 -0
  30. package/src/components/PresenceIndicator.tsx +71 -0
  31. package/src/components/SEOPanel.tsx +767 -0
  32. package/src/components/ThemeProvider.tsx +98 -0
  33. package/src/components/TipTapEditor.tsx +469 -0
  34. package/src/components/VersionHistory.tsx +167 -0
  35. package/src/components/ui/Avatar.tsx +42 -0
  36. package/src/components/ui/Badge.tsx +25 -0
  37. package/src/components/ui/Button.tsx +52 -0
  38. package/src/components/ui/CommandPalette.tsx +119 -0
  39. package/src/components/ui/ConfirmDialog.tsx +52 -0
  40. package/src/components/ui/DataTable.tsx +194 -0
  41. package/src/components/ui/EmptyState.tsx +29 -0
  42. package/src/components/ui/Modal.tsx +48 -0
  43. package/src/components/ui/Pagination.tsx +79 -0
  44. package/src/components/ui/SearchInput.tsx +44 -0
  45. package/src/components/ui/Skeleton.tsx +48 -0
  46. package/src/components/ui/Toast.tsx +66 -0
  47. package/src/components/ui/index.ts +24 -0
  48. package/src/fields/ArrayField.tsx +92 -0
  49. package/src/fields/BlockBuilderField.tsx +421 -0
  50. package/src/fields/DateField.tsx +41 -0
  51. package/src/fields/FieldRenderer.tsx +84 -0
  52. package/src/fields/GroupField.tsx +41 -0
  53. package/src/fields/MediaField.tsx +48 -0
  54. package/src/fields/NavBuilderField.tsx +78 -0
  55. package/src/fields/NumberField.tsx +45 -0
  56. package/src/fields/RelationshipField.tsx +245 -0
  57. package/src/fields/RichTextField.tsx +26 -0
  58. package/src/fields/SelectField.tsx +117 -0
  59. package/src/fields/SlugField.tsx +65 -0
  60. package/src/fields/TextField.tsx +48 -0
  61. package/src/fields/ToggleField.tsx +36 -0
  62. package/src/fields/block-types.ts +95 -0
  63. package/src/fields/index.ts +17 -0
  64. package/src/hooks/useContentLock.ts +52 -0
  65. package/src/hooks/useDebounce.ts +14 -0
  66. package/src/hooks/useKeyboardShortcuts.ts +32 -0
  67. package/src/index.ts +55 -0
  68. package/src/layout/Header.tsx +135 -0
  69. package/src/layout/Layout.tsx +77 -0
  70. package/src/layout/Sidebar.tsx +216 -0
  71. package/src/lib/api.ts +67 -0
  72. package/src/lib/search.ts +59 -0
  73. package/src/lib/useApiData.ts +95 -0
  74. package/src/lib/utils.ts +6 -0
  75. package/src/router/index.ts +81 -0
  76. package/src/styles/build-input.css +11 -0
  77. package/src/styles/tailwind.css +11 -6
  78. package/src/styles/theme.css +182 -181
  79. package/src/views/CollectionList.tsx +270 -0
  80. package/src/views/Dashboard.tsx +300 -0
  81. package/src/views/DocumentEdit.tsx +377 -0
  82. package/src/views/FormEditor.tsx +533 -0
  83. package/src/views/FormSubmissions.tsx +316 -0
  84. package/src/views/Forms.tsx +106 -0
  85. package/src/views/Login.tsx +322 -0
  86. package/src/views/MediaBrowser.tsx +774 -0
  87. package/src/views/PageEditor.tsx +192 -0
  88. package/src/views/Pages.tsx +354 -0
  89. package/src/views/PostEditor.tsx +251 -0
  90. package/src/views/Posts.tsx +243 -0
  91. package/src/views/Redirects.tsx +293 -0
  92. package/src/views/SEO.tsx +458 -0
  93. package/src/views/Settings.tsx +811 -0
  94. package/src/views/SetupWizard.tsx +207 -0
  95. package/src/views/Users.tsx +282 -0
@@ -0,0 +1,78 @@
1
+ 'use client';
2
+
3
+ import { useState } from 'react';
4
+ import { Button } from '../components/ui/Button.js';
5
+
6
+ interface NavNode {
7
+ id: string;
8
+ label: string;
9
+ url: string;
10
+ children: NavNode[];
11
+ }
12
+
13
+ export interface NavBuilderFieldProps {
14
+ label: string;
15
+ value?: NavNode[];
16
+ onChange: (value: NavNode[]) => void;
17
+ helpText?: string;
18
+ }
19
+
20
+ export function NavBuilderField({ label, value = [], onChange, helpText }: NavBuilderFieldProps) {
21
+ function addItem() {
22
+ onChange([
23
+ ...value,
24
+ { id: crypto.randomUUID(), label: 'New Item', url: '/', children: [] },
25
+ ]);
26
+ }
27
+
28
+ function removeItem(id: string) {
29
+ onChange(value.filter((n) => n.id !== id));
30
+ }
31
+
32
+ function renderNode(node: NavNode, depth: number) {
33
+ return (
34
+ <div
35
+ key={node.id}
36
+ className="rounded-md border border-[var(--border)] bg-[var(--card)] p-3"
37
+ style={{ marginLeft: depth * 24 }}
38
+ >
39
+ <div className="flex items-center justify-between">
40
+ <div className="flex items-center gap-2 text-sm">
41
+ <svg className="h-4 w-4 text-[var(--muted-foreground)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
42
+ <path strokeLinecap="round" strokeLinejoin="round" d="M4 6h16M4 12h16M4 18h16" />
43
+ </svg>
44
+ <span className="font-medium">{node.label}</span>
45
+ <span className="text-xs text-[var(--muted-foreground)]">{node.url}</span>
46
+ </div>
47
+ <button
48
+ type="button"
49
+ onClick={() => removeItem(node.id)}
50
+ className="text-xs text-[var(--muted-foreground)] hover:text-[var(--destructive)]"
51
+ >
52
+ Remove
53
+ </button>
54
+ </div>
55
+ {node.children.length > 0 && (
56
+ <div className="mt-2 space-y-2">
57
+ {node.children.map((child) => renderNode(child, depth + 1))}
58
+ </div>
59
+ )}
60
+ </div>
61
+ );
62
+ }
63
+
64
+ return (
65
+ <div>
66
+ <label className="mb-2 block text-sm font-medium">{label}</label>
67
+ <div className="space-y-2">
68
+ {value.map((node) => renderNode(node, 0))}
69
+ </div>
70
+ <Button variant="secondary" size="sm" onClick={addItem} className="mt-2">
71
+ Add Nav Item
72
+ </Button>
73
+ {helpText && (
74
+ <p className="mt-1 text-xs text-[var(--muted-foreground)]">{helpText}</p>
75
+ )}
76
+ </div>
77
+ );
78
+ }
@@ -0,0 +1,45 @@
1
+ 'use client';
2
+
3
+ export interface NumberFieldProps {
4
+ label: string;
5
+ value?: number;
6
+ onChange: (value: number) => void;
7
+ min?: number;
8
+ max?: number;
9
+ required?: boolean;
10
+ helpText?: string;
11
+ error?: string;
12
+ }
13
+
14
+ export function NumberField({ label, value, onChange, min, max, required, helpText, error }: NumberFieldProps) {
15
+ return (
16
+ <div>
17
+ <label className="mb-1 block text-sm font-medium">
18
+ {label}
19
+ {required && <span className="ml-0.5 text-[var(--destructive)]">*</span>}
20
+ </label>
21
+ <input
22
+ type="number"
23
+ value={value ?? ''}
24
+ onChange={(e) => onChange(Number(e.target.value))}
25
+ min={min}
26
+ max={max}
27
+ required={required}
28
+ className={`w-full rounded-md border bg-[var(--input-background)] px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-[var(--ring)] ${
29
+ error ? 'border-[var(--destructive)]' : 'border-[var(--border)]'
30
+ }`}
31
+ />
32
+ {min !== undefined && max !== undefined && (
33
+ <p className="mt-1 text-xs text-[var(--muted-foreground)]">
34
+ Range: {min} – {max}
35
+ </p>
36
+ )}
37
+ {helpText && !error && (
38
+ <p className="mt-1 text-xs text-[var(--muted-foreground)]">{helpText}</p>
39
+ )}
40
+ {error && (
41
+ <p className="mt-1 text-xs text-[var(--destructive)]">{error}</p>
42
+ )}
43
+ </div>
44
+ );
45
+ }
@@ -0,0 +1,245 @@
1
+ 'use client';
2
+
3
+ import { useState, useEffect, useRef, useCallback } from 'react';
4
+ import { Search, X, Loader2, Plus } from 'lucide-react';
5
+ import { cmsApi } from '../lib/api.js';
6
+
7
+ export interface RelationshipFieldProps {
8
+ label: string;
9
+ value?: string | string[];
10
+ onChange: (value: string | string[]) => void;
11
+ multi?: boolean;
12
+ required?: boolean;
13
+ helpText?: string;
14
+ relationTo?: string;
15
+ useAsTitle?: string;
16
+ onNavigate?: (path: string) => void;
17
+ }
18
+
19
+ interface RelatedDoc {
20
+ id: string;
21
+ title?: string;
22
+ status?: string;
23
+ collection?: string;
24
+ updatedAt?: string;
25
+ data?: Record<string, any>;
26
+ }
27
+
28
+ const STATUS_STYLES: Record<string, string> = {
29
+ PUBLISHED: 'bg-green-100 text-green-800',
30
+ DRAFT: 'bg-yellow-100 text-yellow-800',
31
+ ARCHIVED: 'bg-gray-100 text-gray-600',
32
+ SCHEDULED: 'bg-blue-100 text-blue-800',
33
+ };
34
+
35
+ function relativeTime(dateStr: string | undefined): string {
36
+ if (!dateStr) return '';
37
+ const diff = Date.now() - new Date(dateStr).getTime();
38
+ const seconds = Math.floor(diff / 1000);
39
+ if (seconds < 60) return 'just now';
40
+ const minutes = Math.floor(seconds / 60);
41
+ if (minutes < 60) return `${minutes}m ago`;
42
+ const hours = Math.floor(minutes / 60);
43
+ if (hours < 24) return `${hours}h ago`;
44
+ const days = Math.floor(hours / 24);
45
+ if (days < 30) return `${days}d ago`;
46
+ const months = Math.floor(days / 30);
47
+ if (months < 12) return `${months}mo ago`;
48
+ return `${Math.floor(months / 12)}y ago`;
49
+ }
50
+
51
+ export function RelationshipField({
52
+ label,
53
+ value,
54
+ onChange,
55
+ multi = false,
56
+ required,
57
+ helpText,
58
+ relationTo = 'pages',
59
+ useAsTitle = 'title',
60
+ onNavigate,
61
+ }: RelationshipFieldProps) {
62
+ const [searchTerm, setSearchTerm] = useState('');
63
+ const [open, setOpen] = useState(false);
64
+ const [options, setOptions] = useState<RelatedDoc[]>([]);
65
+ const [loading, setLoading] = useState(false);
66
+ const dropdownRef = useRef<HTMLDivElement>(null);
67
+ const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
68
+
69
+ const selectedIds = multi
70
+ ? (Array.isArray(value) ? value : [])
71
+ : (typeof value === 'string' && value ? [value] : []);
72
+
73
+ const fetchOptions = useCallback(async (query: string) => {
74
+ setLoading(true);
75
+ const searchParam = query ? `&search=${encodeURIComponent(query)}` : '';
76
+ const res = await cmsApi<{ docs: RelatedDoc[] }>(
77
+ `/collections/${relationTo}?pageSize=50${searchParam}`,
78
+ );
79
+ if (res.data) {
80
+ const docs: RelatedDoc[] = (res.data as any).docs ?? [];
81
+ setOptions(docs);
82
+ }
83
+ setLoading(false);
84
+ }, [relationTo]);
85
+
86
+ useEffect(() => {
87
+ if (open) fetchOptions(searchTerm);
88
+ }, [open]);
89
+
90
+ const handleSearch = useCallback((query: string) => {
91
+ setSearchTerm(query);
92
+ if (debounceRef.current) clearTimeout(debounceRef.current);
93
+ debounceRef.current = setTimeout(() => fetchOptions(query), 300);
94
+ }, [fetchOptions]);
95
+
96
+ useEffect(() => {
97
+ function handleClickOutside(e: MouseEvent) {
98
+ if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
99
+ setOpen(false);
100
+ }
101
+ }
102
+ document.addEventListener('mousedown', handleClickOutside);
103
+ return () => document.removeEventListener('mousedown', handleClickOutside);
104
+ }, []);
105
+
106
+ function getDocTitle(doc: RelatedDoc): string {
107
+ if (doc.title) return doc.title;
108
+ const d = doc.data;
109
+ if (d && typeof d === 'object') {
110
+ if (typeof d[useAsTitle] === 'string') return d[useAsTitle];
111
+ if (typeof d.title === 'string') return d.title;
112
+ if (typeof d.name === 'string') return d.name;
113
+ }
114
+ return doc.id;
115
+ }
116
+
117
+ function handleToggle(id: string) {
118
+ if (multi) {
119
+ const next = selectedIds.includes(id)
120
+ ? selectedIds.filter((v) => v !== id)
121
+ : [...selectedIds, id];
122
+ onChange(next);
123
+ } else {
124
+ onChange(id);
125
+ setOpen(false);
126
+ }
127
+ }
128
+
129
+ function handleRemove(id: string) {
130
+ if (multi) {
131
+ onChange(selectedIds.filter((v) => v !== id));
132
+ } else {
133
+ onChange('');
134
+ }
135
+ }
136
+
137
+ const selectedItems = options.filter((opt) => selectedIds.includes(opt.id));
138
+ const unselectedItems = options.filter((opt) => !selectedIds.includes(opt.id));
139
+
140
+ function renderOption(opt: RelatedDoc, isSelected: boolean) {
141
+ const status = opt.status ?? (opt.data as any)?.status;
142
+ const collection = opt.collection ?? relationTo;
143
+ const statusClass = STATUS_STYLES[status] ?? 'bg-gray-100 text-gray-600';
144
+
145
+ return (
146
+ <button
147
+ type="button"
148
+ onClick={() => handleToggle(opt.id)}
149
+ className={`flex w-full items-center gap-2 px-3 py-2 text-sm hover:bg-[var(--accent)] ${isSelected ? 'font-medium' : ''}`}
150
+ >
151
+ <div className="flex-1 min-w-0 text-left">
152
+ <div className="flex items-center gap-2">
153
+ <span className="truncate">{getDocTitle(opt)}</span>
154
+ {isSelected && <span className="shrink-0 text-[var(--primary)]">&#10003;</span>}
155
+ </div>
156
+ <div className="flex items-center gap-2 mt-0.5">
157
+ <span className="text-[10px] text-[var(--muted-foreground)]">{collection}</span>
158
+ {opt.updatedAt && (
159
+ <span className="text-[10px] text-[var(--muted-foreground)]">{relativeTime(opt.updatedAt)}</span>
160
+ )}
161
+ </div>
162
+ </div>
163
+ {status && (
164
+ <span className={`shrink-0 rounded px-1.5 py-0.5 text-[10px] font-medium ${statusClass}`}>
165
+ {status}
166
+ </span>
167
+ )}
168
+ </button>
169
+ );
170
+ }
171
+
172
+ return (
173
+ <div className="relative" ref={dropdownRef}>
174
+ <label className="mb-1 block text-sm font-medium">
175
+ {label}
176
+ {required && <span className="ml-0.5 text-[var(--destructive)]">*</span>}
177
+ </label>
178
+
179
+ {selectedItems.length > 0 && (
180
+ <div className="mb-2 flex flex-wrap gap-1">
181
+ {selectedItems.map((item) => (
182
+ <span
183
+ key={item.id}
184
+ className="inline-flex items-center gap-1 rounded-md bg-[var(--accent)] px-2 py-1 text-xs"
185
+ >
186
+ {getDocTitle(item)}
187
+ <button type="button" onClick={() => handleRemove(item.id)} className="hover:text-[var(--destructive)]">
188
+ <X className="w-3 h-3" />
189
+ </button>
190
+ </span>
191
+ ))}
192
+ </div>
193
+ )}
194
+
195
+ <div className="relative">
196
+ <Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--muted-foreground)] pointer-events-none" />
197
+ <input
198
+ type="text"
199
+ value={searchTerm}
200
+ onChange={(e) => handleSearch(e.target.value)}
201
+ onFocus={() => setOpen(true)}
202
+ placeholder={`Search ${relationTo}...`}
203
+ className="w-full rounded-md border border-[var(--border)] bg-[var(--input-background)] pl-8 pr-3 py-2 text-sm outline-none focus:ring-2 focus:ring-[var(--ring)]"
204
+ />
205
+ {loading && (
206
+ <Loader2 className="absolute right-2.5 top-1/2 -translate-y-1/2 w-4 h-4 animate-spin text-[var(--muted-foreground)]" />
207
+ )}
208
+
209
+ {open && (
210
+ <ul className="absolute z-50 mt-1 w-full max-h-60 overflow-y-auto rounded-md border border-[var(--border)] bg-[var(--popover)] py-1 shadow-lg">
211
+ {unselectedItems.map((opt) => (
212
+ <li key={opt.id}>{renderOption(opt, false)}</li>
213
+ ))}
214
+ {selectedItems.length > 0 && unselectedItems.length > 0 && (
215
+ <li className="border-t border-[var(--border)] my-1" />
216
+ )}
217
+ {selectedItems.map((opt) => (
218
+ <li key={opt.id}>{renderOption(opt, true)}</li>
219
+ ))}
220
+ {options.length === 0 && !loading && (
221
+ <li className="px-3 py-2 text-sm text-[var(--muted-foreground)]">No results</li>
222
+ )}
223
+ <li className="border-t border-[var(--border)] mt-1">
224
+ <button
225
+ type="button"
226
+ onClick={() => {
227
+ setOpen(false);
228
+ onNavigate?.(`/${relationTo}/new`);
229
+ }}
230
+ className="flex w-full items-center gap-2 px-3 py-2 text-sm text-[var(--primary)] hover:bg-[var(--accent)]"
231
+ >
232
+ <Plus className="w-3.5 h-3.5" />
233
+ Create New
234
+ </button>
235
+ </li>
236
+ </ul>
237
+ )}
238
+ </div>
239
+
240
+ {helpText && (
241
+ <p className="mt-1 text-xs text-[var(--muted-foreground)]">{helpText}</p>
242
+ )}
243
+ </div>
244
+ );
245
+ }
@@ -0,0 +1,26 @@
1
+ 'use client';
2
+
3
+ import { TipTapEditor } from '../components/TipTapEditor.js';
4
+
5
+ export interface RichTextFieldProps {
6
+ label: string;
7
+ value?: string;
8
+ onChange: (value: string) => void;
9
+ required?: boolean;
10
+ helpText?: string;
11
+ }
12
+
13
+ export function RichTextField({ label, value = '', onChange, required, helpText }: RichTextFieldProps) {
14
+ return (
15
+ <div>
16
+ <label className="mb-1 block text-sm font-medium">
17
+ {label}
18
+ {required && <span className="ml-0.5 text-[var(--destructive)]">*</span>}
19
+ </label>
20
+ <TipTapEditor content={value} onChange={onChange} />
21
+ {helpText && (
22
+ <p className="mt-1 text-xs text-[var(--muted-foreground)]">{helpText}</p>
23
+ )}
24
+ </div>
25
+ );
26
+ }
@@ -0,0 +1,117 @@
1
+ 'use client';
2
+
3
+ import { useState } from 'react';
4
+
5
+ export interface SelectFieldProps {
6
+ label: string;
7
+ value?: string | string[];
8
+ onChange: (value: string | string[]) => void;
9
+ options?: string[];
10
+ multi?: boolean;
11
+ required?: boolean;
12
+ helpText?: string;
13
+ }
14
+
15
+ export function SelectField({
16
+ label,
17
+ value,
18
+ onChange,
19
+ options = [],
20
+ multi = false,
21
+ required,
22
+ helpText,
23
+ }: SelectFieldProps) {
24
+ const [searchTerm, setSearchTerm] = useState('');
25
+ const [open, setOpen] = useState(false);
26
+
27
+ const selectedValues = multi
28
+ ? (Array.isArray(value) ? value : [])
29
+ : [];
30
+ const singleValue = multi ? '' : (typeof value === 'string' ? value : '');
31
+
32
+ const filtered = options.filter((opt) =>
33
+ opt.toLowerCase().includes(searchTerm.toLowerCase()),
34
+ );
35
+
36
+ function handleSelect(opt: string) {
37
+ if (multi) {
38
+ const next = selectedValues.includes(opt)
39
+ ? selectedValues.filter((v) => v !== opt)
40
+ : [...selectedValues, opt];
41
+ onChange(next);
42
+ } else {
43
+ onChange(opt);
44
+ setOpen(false);
45
+ }
46
+ }
47
+
48
+ return (
49
+ <div className="relative">
50
+ <label className="mb-1 block text-sm font-medium">
51
+ {label}
52
+ {required && <span className="ml-0.5 text-[var(--destructive)]">*</span>}
53
+ </label>
54
+
55
+ <button
56
+ type="button"
57
+ onClick={() => setOpen((o) => !o)}
58
+ className="flex w-full items-center justify-between rounded-md border border-[var(--border)] bg-[var(--input-background)] px-3 py-2 text-sm"
59
+ >
60
+ <span className={singleValue || selectedValues.length ? '' : 'text-[var(--muted-foreground)]'}>
61
+ {multi
62
+ ? selectedValues.length ? `${selectedValues.length} selected` : 'Select...'
63
+ : singleValue || 'Select...'}
64
+ </span>
65
+ <svg className="h-4 w-4 text-[var(--muted-foreground)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
66
+ <path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
67
+ </svg>
68
+ </button>
69
+
70
+ {open && (
71
+ <div className="absolute z-50 mt-1 w-full rounded-md border border-[var(--border)] bg-[var(--popover)] shadow-lg">
72
+ <div className="border-b border-[var(--border)] p-2">
73
+ <input
74
+ type="text"
75
+ value={searchTerm}
76
+ onChange={(e) => setSearchTerm(e.target.value)}
77
+ placeholder="Search..."
78
+ className="w-full rounded bg-[var(--input-background)] px-2 py-1 text-sm outline-none"
79
+ />
80
+ </div>
81
+ <ul className="max-h-48 overflow-y-auto py-1">
82
+ {filtered.map((opt) => {
83
+ const isSelected = multi ? selectedValues.includes(opt) : singleValue === opt;
84
+ return (
85
+ <li key={opt}>
86
+ <button
87
+ type="button"
88
+ onClick={() => handleSelect(opt)}
89
+ className={`flex w-full items-center gap-2 px-3 py-1.5 text-sm hover:bg-[var(--accent)] ${
90
+ isSelected ? 'font-medium' : ''
91
+ }`}
92
+ >
93
+ {multi && (
94
+ <span className={`flex h-4 w-4 items-center justify-center rounded border ${
95
+ isSelected ? 'border-[var(--primary)] bg-[var(--primary)] text-[var(--primary-foreground)]' : 'border-[var(--border)]'
96
+ }`}>
97
+ {isSelected && '✓'}
98
+ </span>
99
+ )}
100
+ {opt}
101
+ </button>
102
+ </li>
103
+ );
104
+ })}
105
+ {filtered.length === 0 && (
106
+ <li className="px-3 py-2 text-sm text-[var(--muted-foreground)]">No options found</li>
107
+ )}
108
+ </ul>
109
+ </div>
110
+ )}
111
+
112
+ {helpText && (
113
+ <p className="mt-1 text-xs text-[var(--muted-foreground)]">{helpText}</p>
114
+ )}
115
+ </div>
116
+ );
117
+ }
@@ -0,0 +1,65 @@
1
+ 'use client';
2
+
3
+ import { useState, useEffect } from 'react';
4
+
5
+ export interface SlugFieldProps {
6
+ label: string;
7
+ value?: string;
8
+ onChange: (value: string) => void;
9
+ from?: string;
10
+ required?: boolean;
11
+ helpText?: string;
12
+ }
13
+
14
+ function toSlug(text: string): string {
15
+ return text
16
+ .toLowerCase()
17
+ .replace(/[^\w\s-]/g, '')
18
+ .replace(/[\s_]+/g, '-')
19
+ .replace(/^-+|-+$/g, '');
20
+ }
21
+
22
+ export function SlugField({ label, value = '', onChange, required, helpText }: SlugFieldProps) {
23
+ const [locked, setLocked] = useState(true);
24
+
25
+ return (
26
+ <div>
27
+ <label className="mb-1 block text-sm font-medium">
28
+ {label}
29
+ {required && <span className="ml-0.5 text-[var(--destructive)]">*</span>}
30
+ </label>
31
+ <div className="flex items-center gap-2">
32
+ <div className="flex flex-1 items-center rounded-md border border-[var(--border)] bg-[var(--input-background)]">
33
+ <span className="pl-3 text-sm text-[var(--muted-foreground)]">/</span>
34
+ <input
35
+ type="text"
36
+ value={value}
37
+ onChange={(e) => onChange(toSlug(e.target.value))}
38
+ readOnly={locked}
39
+ className="flex-1 bg-transparent px-1 py-2 text-sm outline-none"
40
+ />
41
+ </div>
42
+ <button
43
+ type="button"
44
+ onClick={() => setLocked((l) => !l)}
45
+ className="rounded-md border border-[var(--border)] p-2 text-sm hover:bg-[var(--accent)]"
46
+ aria-label={locked ? 'Unlock slug' : 'Lock slug'}
47
+ >
48
+ <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
49
+ {locked ? (
50
+ <path strokeLinecap="round" strokeLinejoin="round" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
51
+ ) : (
52
+ <path strokeLinecap="round" strokeLinejoin="round" d="M8 11V7a4 4 0 118 0m-4 8v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2z" />
53
+ )}
54
+ </svg>
55
+ </button>
56
+ </div>
57
+ {value && (
58
+ <p className="mt-1 text-xs text-[var(--muted-foreground)]">URL preview: /{value}</p>
59
+ )}
60
+ {helpText && (
61
+ <p className="mt-1 text-xs text-[var(--muted-foreground)]">{helpText}</p>
62
+ )}
63
+ </div>
64
+ );
65
+ }
@@ -0,0 +1,48 @@
1
+ 'use client';
2
+
3
+ export interface TextFieldProps {
4
+ label: string;
5
+ value?: string;
6
+ onChange: (value: string) => void;
7
+ required?: boolean;
8
+ maxLength?: number;
9
+ helpText?: string;
10
+ error?: string;
11
+ }
12
+
13
+ export function TextField({ label, value = '', onChange, required, maxLength, helpText, error }: TextFieldProps) {
14
+ const charCount = value.length;
15
+ const hasError = !!error || (maxLength !== undefined && charCount > maxLength);
16
+
17
+ return (
18
+ <div>
19
+ <label className="mb-1 flex items-baseline justify-between text-sm font-medium">
20
+ <span>
21
+ {label}
22
+ {required && <span className="ml-0.5 text-[var(--destructive)]">*</span>}
23
+ </span>
24
+ {maxLength !== undefined && (
25
+ <span className={`text-xs ${hasError ? 'text-[var(--destructive)]' : 'text-[var(--muted-foreground)]'}`}>
26
+ {charCount}/{maxLength}
27
+ </span>
28
+ )}
29
+ </label>
30
+ <input
31
+ type="text"
32
+ value={value}
33
+ onChange={(e) => onChange(e.target.value)}
34
+ required={required}
35
+ maxLength={maxLength}
36
+ className={`w-full rounded-md border bg-[var(--input-background)] px-3 py-2 text-sm outline-none transition-colors focus:ring-2 focus:ring-[var(--ring)] ${
37
+ hasError ? 'border-[var(--destructive)]' : 'border-[var(--border)]'
38
+ }`}
39
+ />
40
+ {helpText && !error && (
41
+ <p className="mt-1 text-xs text-[var(--muted-foreground)]">{helpText}</p>
42
+ )}
43
+ {error && (
44
+ <p className="mt-1 text-xs text-[var(--destructive)]">{error}</p>
45
+ )}
46
+ </div>
47
+ );
48
+ }
@@ -0,0 +1,36 @@
1
+ 'use client';
2
+
3
+ export interface ToggleFieldProps {
4
+ label: string;
5
+ value?: boolean;
6
+ onChange: (value: boolean) => void;
7
+ helpText?: string;
8
+ }
9
+
10
+ export function ToggleField({ label, value = false, onChange, helpText }: ToggleFieldProps) {
11
+ return (
12
+ <div className="flex items-center justify-between">
13
+ <div>
14
+ <span className="text-sm font-medium">{label}</span>
15
+ {helpText && (
16
+ <p className="text-xs text-[var(--muted-foreground)]">{helpText}</p>
17
+ )}
18
+ </div>
19
+ <button
20
+ type="button"
21
+ role="switch"
22
+ aria-checked={value}
23
+ onClick={() => onChange(!value)}
24
+ className={`relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors ${
25
+ value ? 'bg-[var(--primary)]' : 'bg-[var(--muted)]'
26
+ }`}
27
+ >
28
+ <span
29
+ className={`pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow-sm ring-0 transition-transform ${
30
+ value ? 'translate-x-5' : 'translate-x-0'
31
+ }`}
32
+ />
33
+ </button>
34
+ </div>
35
+ );
36
+ }