@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.
- package/dist/AdminRoot.d.ts.map +1 -1
- package/dist/AdminRoot.js +22 -0
- package/dist/AdminRoot.js.map +1 -1
- package/dist/actuate-admin.css +1 -1
- package/dist/components/Breadcrumbs.d.ts.map +1 -1
- package/dist/components/Breadcrumbs.js +1 -0
- package/dist/components/Breadcrumbs.js.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/layout/Sidebar.d.ts.map +1 -1
- package/dist/layout/Sidebar.js +2 -2
- package/dist/layout/Sidebar.js.map +1 -1
- package/dist/views/ForgotPassword.d.ts +5 -0
- package/dist/views/ForgotPassword.d.ts.map +1 -0
- package/dist/views/ForgotPassword.js +41 -0
- package/dist/views/ForgotPassword.js.map +1 -0
- package/dist/views/ResetPassword.d.ts +6 -0
- package/dist/views/ResetPassword.d.ts.map +1 -0
- package/dist/views/ResetPassword.js +46 -0
- package/dist/views/ResetPassword.js.map +1 -0
- package/dist/views/ScriptTagEditor.d.ts +6 -0
- package/dist/views/ScriptTagEditor.d.ts.map +1 -0
- package/dist/views/ScriptTagEditor.js +109 -0
- package/dist/views/ScriptTagEditor.js.map +1 -0
- package/dist/views/ScriptTags.d.ts +5 -0
- package/dist/views/ScriptTags.d.ts.map +1 -0
- package/dist/views/ScriptTags.js +54 -0
- package/dist/views/ScriptTags.js.map +1 -0
- package/package.json +5 -3
- package/src/AdminRoot.tsx +25 -0
- package/src/components/Breadcrumbs.tsx +1 -0
- package/src/index.ts +4 -0
- package/src/layout/Sidebar.tsx +2 -0
- package/src/views/ForgotPassword.tsx +136 -0
- package/src/views/ResetPassword.tsx +192 -0
- package/src/views/ScriptTagEditor.tsx +361 -0
- 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
|
+
}
|