@actuate-media/cms-admin 0.4.0 → 0.6.0

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 (39) hide show
  1. package/dist/AdminRoot.d.ts.map +1 -1
  2. package/dist/AdminRoot.js +22 -0
  3. package/dist/AdminRoot.js.map +1 -1
  4. package/dist/actuate-admin.css +1 -1
  5. package/dist/components/Breadcrumbs.d.ts.map +1 -1
  6. package/dist/components/Breadcrumbs.js +1 -0
  7. package/dist/components/Breadcrumbs.js.map +1 -1
  8. package/dist/index.d.ts +4 -0
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +2 -0
  11. package/dist/index.js.map +1 -1
  12. package/dist/layout/Sidebar.d.ts.map +1 -1
  13. package/dist/layout/Sidebar.js +2 -2
  14. package/dist/layout/Sidebar.js.map +1 -1
  15. package/dist/views/ForgotPassword.d.ts +5 -0
  16. package/dist/views/ForgotPassword.d.ts.map +1 -0
  17. package/dist/views/ForgotPassword.js +41 -0
  18. package/dist/views/ForgotPassword.js.map +1 -0
  19. package/dist/views/ResetPassword.d.ts +6 -0
  20. package/dist/views/ResetPassword.d.ts.map +1 -0
  21. package/dist/views/ResetPassword.js +46 -0
  22. package/dist/views/ResetPassword.js.map +1 -0
  23. package/dist/views/ScriptTagEditor.d.ts +6 -0
  24. package/dist/views/ScriptTagEditor.d.ts.map +1 -0
  25. package/dist/views/ScriptTagEditor.js +109 -0
  26. package/dist/views/ScriptTagEditor.js.map +1 -0
  27. package/dist/views/ScriptTags.d.ts +5 -0
  28. package/dist/views/ScriptTags.d.ts.map +1 -0
  29. package/dist/views/ScriptTags.js +54 -0
  30. package/dist/views/ScriptTags.js.map +1 -0
  31. package/package.json +5 -3
  32. package/src/AdminRoot.tsx +25 -0
  33. package/src/components/Breadcrumbs.tsx +1 -0
  34. package/src/index.ts +4 -0
  35. package/src/layout/Sidebar.tsx +2 -0
  36. package/src/views/ForgotPassword.tsx +136 -0
  37. package/src/views/ResetPassword.tsx +192 -0
  38. package/src/views/ScriptTagEditor.tsx +361 -0
  39. package/src/views/ScriptTags.tsx +174 -0
@@ -0,0 +1,192 @@
1
+ 'use client';
2
+
3
+ import { useState, type FormEvent } from 'react';
4
+ import { Shield, Eye, EyeOff, Loader2, CheckCircle2, AlertTriangle, XCircle } from 'lucide-react';
5
+ import { cmsApi } from '../lib/api.js';
6
+
7
+ export interface ResetPasswordProps {
8
+ onNavigate: (path: string) => void;
9
+ token: string | null;
10
+ }
11
+
12
+ export function ResetPassword({ onNavigate, token }: ResetPasswordProps) {
13
+ const [password, setPassword] = useState('');
14
+ const [confirmPassword, setConfirmPassword] = useState('');
15
+ const [showPassword, setShowPassword] = useState(false);
16
+ const [submitting, setSubmitting] = useState(false);
17
+ const [success, setSuccess] = useState(false);
18
+ const [error, setError] = useState('');
19
+
20
+ if (!token) {
21
+ return (
22
+ <div className="min-h-screen flex items-center justify-center bg-gray-50 px-4">
23
+ <div className="w-full max-w-md">
24
+ <div className="text-center mb-8">
25
+ <div className="mx-auto mb-4 w-14 h-14 bg-red-100 rounded-xl flex items-center justify-center">
26
+ <XCircle className="w-7 h-7 text-red-600" />
27
+ </div>
28
+ <h1 className="text-2xl font-bold text-gray-900">Invalid Reset Link</h1>
29
+ <p className="text-gray-600 mt-2">This password reset link is invalid or has expired.</p>
30
+ </div>
31
+ <div className="bg-white rounded-xl border border-gray-200 p-6 shadow-sm text-center space-y-4">
32
+ <button
33
+ type="button"
34
+ onClick={() => onNavigate('/forgot-password')}
35
+ className="w-full py-2.5 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 transition-colors"
36
+ >
37
+ Request New Reset Link
38
+ </button>
39
+ <button
40
+ type="button"
41
+ onClick={() => onNavigate('/login')}
42
+ className="w-full text-sm text-gray-600 hover:text-gray-800 transition-colors"
43
+ >
44
+ Back to Sign In
45
+ </button>
46
+ </div>
47
+ </div>
48
+ </div>
49
+ );
50
+ }
51
+
52
+ const passwordsMatch = password === confirmPassword;
53
+ const meetsLength = password.length >= 8;
54
+ const canSubmit = password && confirmPassword && passwordsMatch && meetsLength && !submitting;
55
+
56
+ const handleSubmit = async (e: FormEvent) => {
57
+ e.preventDefault();
58
+ if (!canSubmit) return;
59
+
60
+ setError('');
61
+ setSubmitting(true);
62
+
63
+ try {
64
+ const result = await cmsApi<{ success: boolean }>('/auth/reset-password', {
65
+ method: 'POST',
66
+ body: JSON.stringify({ token, password }),
67
+ });
68
+
69
+ if (result.error) {
70
+ setError(result.error);
71
+ } else {
72
+ setSuccess(true);
73
+ }
74
+ } catch {
75
+ setError('An unexpected error occurred. Please try again.');
76
+ } finally {
77
+ setSubmitting(false);
78
+ }
79
+ };
80
+
81
+ return (
82
+ <div className="min-h-screen flex items-center justify-center bg-gray-50 px-4">
83
+ <div className="w-full max-w-md">
84
+ <div className="text-center mb-8">
85
+ <div className="mx-auto mb-4 w-14 h-14 bg-blue-600 rounded-xl flex items-center justify-center">
86
+ <Shield className="w-7 h-7 text-white" />
87
+ </div>
88
+ <h1 className="text-2xl font-bold text-gray-900">
89
+ {success ? 'Password Reset' : 'Choose New Password'}
90
+ </h1>
91
+ <p className="text-gray-600 mt-2">
92
+ {success ? 'Your password has been updated' : 'Enter your new password below'}
93
+ </p>
94
+ </div>
95
+
96
+ <div className="bg-white rounded-xl border border-gray-200 p-6 shadow-sm space-y-5">
97
+ {success ? (
98
+ <div className="text-center space-y-4">
99
+ <div className="mx-auto w-12 h-12 bg-green-100 rounded-full flex items-center justify-center">
100
+ <CheckCircle2 className="w-6 h-6 text-green-600" />
101
+ </div>
102
+ <p className="text-sm text-gray-600">
103
+ Your password has been successfully reset. All existing sessions have been revoked for security.
104
+ </p>
105
+ <button
106
+ type="button"
107
+ onClick={() => onNavigate('/login')}
108
+ className="w-full py-2.5 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 transition-colors"
109
+ >
110
+ Sign In with New Password
111
+ </button>
112
+ </div>
113
+ ) : (
114
+ <form onSubmit={handleSubmit} className="space-y-5">
115
+ {error && (
116
+ <div className="flex items-start gap-3 p-3 bg-red-50 border border-red-200 rounded-lg">
117
+ <AlertTriangle className="w-5 h-5 text-red-600 mt-0.5 shrink-0" />
118
+ <p className="text-sm text-red-800">{error}</p>
119
+ </div>
120
+ )}
121
+
122
+ <div>
123
+ <label htmlFor="reset-password" className="block text-sm font-medium text-gray-700 mb-1.5">
124
+ New Password
125
+ </label>
126
+ <div className="relative">
127
+ <input
128
+ id="reset-password"
129
+ type={showPassword ? 'text' : 'password'}
130
+ value={password}
131
+ onChange={(e) => setPassword(e.target.value)}
132
+ placeholder="Enter new password"
133
+ className="w-full px-3 py-2.5 pr-10 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
134
+ required
135
+ autoFocus
136
+ autoComplete="new-password"
137
+ minLength={8}
138
+ />
139
+ <button
140
+ type="button"
141
+ onClick={() => setShowPassword(!showPassword)}
142
+ className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
143
+ tabIndex={-1}
144
+ >
145
+ {showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
146
+ </button>
147
+ </div>
148
+ {password && !meetsLength && (
149
+ <p className="mt-1 text-xs text-red-600">Password must be at least 8 characters</p>
150
+ )}
151
+ </div>
152
+
153
+ <div>
154
+ <label htmlFor="reset-confirm" className="block text-sm font-medium text-gray-700 mb-1.5">
155
+ Confirm Password
156
+ </label>
157
+ <input
158
+ id="reset-confirm"
159
+ type={showPassword ? 'text' : 'password'}
160
+ value={confirmPassword}
161
+ onChange={(e) => setConfirmPassword(e.target.value)}
162
+ placeholder="Confirm new password"
163
+ className="w-full px-3 py-2.5 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
164
+ required
165
+ autoComplete="new-password"
166
+ />
167
+ {confirmPassword && !passwordsMatch && (
168
+ <p className="mt-1 text-xs text-red-600">Passwords do not match</p>
169
+ )}
170
+ </div>
171
+
172
+ <button
173
+ type="submit"
174
+ disabled={!canSubmit}
175
+ className="w-full py-2.5 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
176
+ >
177
+ {submitting ? (
178
+ <>
179
+ <Loader2 className="w-4 h-4 animate-spin" />
180
+ Resetting...
181
+ </>
182
+ ) : (
183
+ 'Reset Password'
184
+ )}
185
+ </button>
186
+ </form>
187
+ )}
188
+ </div>
189
+ </div>
190
+ </div>
191
+ );
192
+ }
@@ -0,0 +1,361 @@
1
+ 'use client';
2
+
3
+ import { useState, useEffect } from 'react';
4
+ import { ArrowLeft, Loader2, AlertTriangle, Trash2, X, Plus } from 'lucide-react';
5
+ import { toast } from 'sonner';
6
+ import { useApiData } from '../lib/useApiData.js';
7
+ import { cmsApi } from '../lib/api.js';
8
+
9
+ export interface ScriptTagEditorProps {
10
+ tagId?: string;
11
+ onNavigate?: (path: string) => void;
12
+ }
13
+
14
+ export function ScriptTagEditor({ tagId, onNavigate }: ScriptTagEditorProps) {
15
+ const isNew = !tagId;
16
+ const { data, loading, error } = useApiData<any>(
17
+ tagId ? `/script-tags` : null,
18
+ );
19
+
20
+ const [name, setName] = useState('');
21
+ const [code, setCode] = useState('');
22
+ const [placement, setPlacement] = useState('head');
23
+ const [scope, setScope] = useState('site');
24
+ const [targetPaths, setTargetPaths] = useState<string[]>([]);
25
+ const [pathInput, setPathInput] = useState('');
26
+ const [priority, setPriority] = useState(100);
27
+ const [enabled, setEnabled] = useState(true);
28
+ const [saving, setSaving] = useState(false);
29
+ const [deleting, setDeleting] = useState(false);
30
+ const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
31
+
32
+ useEffect(() => {
33
+ if (data && tagId) {
34
+ const tags = Array.isArray(data) ? data : [];
35
+ const tag = tags.find((t: any) => t.id === tagId);
36
+ if (tag) {
37
+ setName(tag.name ?? '');
38
+ setCode(tag.code ?? '');
39
+ setPlacement(tag.placement ?? 'head');
40
+ setScope(tag.scope ?? 'site');
41
+ setTargetPaths(tag.targetPaths ?? []);
42
+ setPriority(tag.priority ?? 100);
43
+ setEnabled(tag.enabled ?? true);
44
+ }
45
+ }
46
+ }, [data, tagId]);
47
+
48
+ const addPath = () => {
49
+ const trimmed = pathInput.trim();
50
+ if (!trimmed) return;
51
+ const normalized = trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
52
+ if (!targetPaths.includes(normalized)) {
53
+ setTargetPaths([...targetPaths, normalized]);
54
+ }
55
+ setPathInput('');
56
+ };
57
+
58
+ const removePath = (path: string) => {
59
+ setTargetPaths(targetPaths.filter((p) => p !== path));
60
+ };
61
+
62
+ const handleSave = async () => {
63
+ if (!name.trim()) {
64
+ toast.error('Name is required');
65
+ return;
66
+ }
67
+ if (!code.trim()) {
68
+ toast.error('Code snippet is required');
69
+ return;
70
+ }
71
+ if (scope !== 'site' && targetPaths.length === 0) {
72
+ toast.error('At least one target path is required for this scope');
73
+ return;
74
+ }
75
+
76
+ setSaving(true);
77
+ const payload = {
78
+ name: name.trim(),
79
+ code,
80
+ placement,
81
+ scope,
82
+ targetPaths: scope === 'site' ? [] : targetPaths,
83
+ priority,
84
+ enabled,
85
+ };
86
+
87
+ const res = isNew
88
+ ? await cmsApi('/script-tags', { method: 'POST', body: JSON.stringify(payload) })
89
+ : await cmsApi(`/script-tags/${tagId}`, { method: 'PUT', body: JSON.stringify(payload) });
90
+
91
+ setSaving(false);
92
+ if (res.error) {
93
+ toast.error(res.error);
94
+ } else {
95
+ toast.success(isNew ? 'Tag created' : 'Tag updated');
96
+ onNavigate?.('/script-tags');
97
+ }
98
+ };
99
+
100
+ const handleDelete = async () => {
101
+ if (!tagId) return;
102
+ setDeleting(true);
103
+ const res = await cmsApi(`/script-tags/${tagId}`, { method: 'DELETE' });
104
+ setDeleting(false);
105
+ if (res.error) {
106
+ toast.error(res.error);
107
+ } else {
108
+ toast.success('Tag deleted');
109
+ onNavigate?.('/script-tags');
110
+ }
111
+ };
112
+
113
+ if (loading && !isNew) {
114
+ return (
115
+ <div className="p-3 pr-6 sm:p-4 sm:pr-8 flex items-center justify-center h-64">
116
+ <Loader2 className="w-6 h-6 animate-spin text-blue-600" />
117
+ </div>
118
+ );
119
+ }
120
+
121
+ return (
122
+ <div className="p-3 pr-6 sm:p-4 sm:pr-8 max-w-3xl">
123
+ {error && (
124
+ <div className="mb-4 flex items-center gap-3 rounded-lg border border-red-200 bg-red-50 p-3">
125
+ <AlertTriangle className="w-5 h-5 text-red-600 shrink-0" />
126
+ <span className="text-sm text-red-800 flex-1">{error}</span>
127
+ </div>
128
+ )}
129
+
130
+ <div className="mb-6">
131
+ <button
132
+ type="button"
133
+ onClick={() => onNavigate?.('/script-tags')}
134
+ className="flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-700 transition-colors mb-3"
135
+ >
136
+ <ArrowLeft className="w-4 h-4" />
137
+ Back to Script Tags
138
+ </button>
139
+ <h1 className="text-2xl font-semibold text-gray-900">
140
+ {isNew ? 'New Script Tag' : 'Edit Script Tag'}
141
+ </h1>
142
+ </div>
143
+
144
+ <div className="rounded-lg border border-amber-200 bg-amber-50 p-3 mb-6">
145
+ <div className="flex items-start gap-2">
146
+ <AlertTriangle className="w-4 h-4 text-amber-600 mt-0.5 shrink-0" />
147
+ <p className="text-sm text-amber-800">
148
+ This code will run on public pages matching the scope below. Only add trusted code from verified sources (e.g. Google Analytics, Meta Pixel).
149
+ </p>
150
+ </div>
151
+ </div>
152
+
153
+ <div className="space-y-6">
154
+ <div className="rounded-lg border border-gray-200 bg-white p-4 space-y-4">
155
+ <div>
156
+ <label className="mb-1 block text-sm font-medium text-gray-700">Name</label>
157
+ <input
158
+ type="text"
159
+ value={name}
160
+ onChange={(e) => setName(e.target.value)}
161
+ placeholder="e.g. Google Tag Manager, Facebook Pixel"
162
+ className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
163
+ />
164
+ </div>
165
+
166
+ <div>
167
+ <label className="mb-1 block text-sm font-medium text-gray-700">Code</label>
168
+ <textarea
169
+ value={code}
170
+ onChange={(e) => setCode(e.target.value)}
171
+ placeholder="Paste your HTML/JavaScript snippet here..."
172
+ rows={10}
173
+ className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500"
174
+ spellCheck={false}
175
+ />
176
+ <p className="mt-1 text-xs text-gray-500">
177
+ Paste the full code snippet including {'<script>'} tags.
178
+ </p>
179
+ </div>
180
+ </div>
181
+
182
+ <div className="rounded-lg border border-gray-200 bg-white p-4 space-y-4">
183
+ <h3 className="text-sm font-semibold text-gray-900">Placement & Scope</h3>
184
+
185
+ <div>
186
+ <label className="mb-1 block text-sm font-medium text-gray-700">Placement</label>
187
+ <select
188
+ value={placement}
189
+ onChange={(e) => setPlacement(e.target.value)}
190
+ className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
191
+ >
192
+ <option value="head">Head — inside {'<head>'}</option>
193
+ <option value="body_open">Body Open — right after {'<body>'}</option>
194
+ <option value="body_close">Body Close — before {'</body>'}</option>
195
+ </select>
196
+ <p className="mt-1 text-xs text-gray-500">
197
+ {placement === 'head' && 'Best for analytics scripts, meta tags, and custom CSS.'}
198
+ {placement === 'body_open' && 'Best for GTM noscript tags and early-loading scripts.'}
199
+ {placement === 'body_close' && 'Best for chat widgets, deferred scripts, and tracking pixels.'}
200
+ </p>
201
+ </div>
202
+
203
+ <div>
204
+ <label className="mb-1 block text-sm font-medium text-gray-700">Scope</label>
205
+ <select
206
+ value={scope}
207
+ onChange={(e) => setScope(e.target.value)}
208
+ className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
209
+ >
210
+ <option value="site">Entire Website</option>
211
+ <option value="parents">Specific Parent Pages (includes child pages)</option>
212
+ <option value="urls">Specific URLs (exact match)</option>
213
+ </select>
214
+ </div>
215
+
216
+ {scope !== 'site' && (
217
+ <div>
218
+ <label className="mb-1 block text-sm font-medium text-gray-700">
219
+ {scope === 'parents' ? 'Parent Paths' : 'URL Paths'}
220
+ </label>
221
+ <div className="flex gap-2">
222
+ <input
223
+ type="text"
224
+ value={pathInput}
225
+ onChange={(e) => setPathInput(e.target.value)}
226
+ onKeyDown={(e) => {
227
+ if (e.key === 'Enter') {
228
+ e.preventDefault();
229
+ addPath();
230
+ }
231
+ }}
232
+ placeholder="/services"
233
+ className="flex-1 rounded-lg border border-gray-300 px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500"
234
+ />
235
+ <button
236
+ type="button"
237
+ onClick={addPath}
238
+ className="rounded-lg border border-gray-300 px-3 py-2 text-sm hover:bg-gray-50 transition-colors"
239
+ >
240
+ <Plus className="w-4 h-4" />
241
+ </button>
242
+ </div>
243
+ {scope === 'parents' && (
244
+ <p className="mt-1 text-xs text-gray-500">
245
+ This tag will also apply to all child pages under each path.
246
+ </p>
247
+ )}
248
+ {targetPaths.length > 0 && (
249
+ <div className="mt-2 flex flex-wrap gap-2">
250
+ {targetPaths.map((p) => (
251
+ <span
252
+ key={p}
253
+ className="inline-flex items-center gap-1 rounded-full bg-gray-100 px-3 py-1 text-xs font-mono text-gray-700"
254
+ >
255
+ {p}
256
+ <button
257
+ type="button"
258
+ onClick={() => removePath(p)}
259
+ className="ml-0.5 text-gray-400 hover:text-gray-600"
260
+ >
261
+ <X className="w-3 h-3" />
262
+ </button>
263
+ </span>
264
+ ))}
265
+ </div>
266
+ )}
267
+ </div>
268
+ )}
269
+
270
+ <div className="grid grid-cols-2 gap-4">
271
+ <div>
272
+ <label className="mb-1 block text-sm font-medium text-gray-700">Priority</label>
273
+ <input
274
+ type="number"
275
+ value={priority}
276
+ onChange={(e) => setPriority(parseInt(e.target.value, 10) || 0)}
277
+ min={0}
278
+ className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
279
+ />
280
+ <p className="mt-1 text-xs text-gray-500">
281
+ Lower numbers load first (e.g. 1 = first, 100 = default)
282
+ </p>
283
+ </div>
284
+ <div>
285
+ <label className="mb-1 block text-sm font-medium text-gray-700">Enabled</label>
286
+ <div className="pt-2">
287
+ <button
288
+ type="button"
289
+ onClick={() => setEnabled(!enabled)}
290
+ className={`relative h-6 w-11 shrink-0 rounded-full transition-colors ${enabled ? 'bg-blue-600' : 'bg-gray-300'}`}
291
+ aria-pressed={enabled}
292
+ >
293
+ <span
294
+ className={`absolute top-0.5 block h-5 w-5 rounded-full bg-white transition-transform ${
295
+ enabled ? 'translate-x-[22px]' : 'translate-x-0.5'
296
+ }`}
297
+ />
298
+ </button>
299
+ </div>
300
+ </div>
301
+ </div>
302
+ </div>
303
+
304
+ <div className="flex items-center justify-between">
305
+ <div>
306
+ {!isNew && (
307
+ <>
308
+ {showDeleteConfirm ? (
309
+ <div className="flex items-center gap-2">
310
+ <span className="text-sm text-red-600">Delete this tag?</span>
311
+ <button
312
+ type="button"
313
+ onClick={handleDelete}
314
+ disabled={deleting}
315
+ className="rounded-lg bg-red-600 px-3 py-1.5 text-sm text-white hover:bg-red-700 disabled:opacity-50"
316
+ >
317
+ {deleting ? 'Deleting...' : 'Confirm'}
318
+ </button>
319
+ <button
320
+ type="button"
321
+ onClick={() => setShowDeleteConfirm(false)}
322
+ className="rounded-lg border border-gray-300 px-3 py-1.5 text-sm hover:bg-gray-50"
323
+ >
324
+ Cancel
325
+ </button>
326
+ </div>
327
+ ) : (
328
+ <button
329
+ type="button"
330
+ onClick={() => setShowDeleteConfirm(true)}
331
+ className="flex items-center gap-1.5 text-sm text-red-600 hover:text-red-700 transition-colors"
332
+ >
333
+ <Trash2 className="w-4 h-4" />
334
+ Delete
335
+ </button>
336
+ )}
337
+ </>
338
+ )}
339
+ </div>
340
+ <div className="flex items-center gap-3">
341
+ <button
342
+ type="button"
343
+ onClick={() => onNavigate?.('/script-tags')}
344
+ className="rounded-lg border border-gray-300 px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 transition-colors"
345
+ >
346
+ Cancel
347
+ </button>
348
+ <button
349
+ type="button"
350
+ onClick={handleSave}
351
+ disabled={saving}
352
+ className="rounded-lg bg-blue-600 px-6 py-2 text-sm text-white transition-colors hover:bg-blue-700 disabled:opacity-50"
353
+ >
354
+ {saving ? 'Saving...' : isNew ? 'Create Tag' : 'Save Changes'}
355
+ </button>
356
+ </div>
357
+ </div>
358
+ </div>
359
+ </div>
360
+ );
361
+ }