@actuate-media/cms-admin 0.68.0 → 0.69.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.
- package/CHANGELOG.md +18 -0
- package/dist/AdminRoot.d.ts.map +1 -1
- package/dist/AdminRoot.js +31 -11
- package/dist/AdminRoot.js.map +1 -1
- package/dist/__tests__/hooks/useIdleLogout.test.js +57 -2
- package/dist/__tests__/hooks/useIdleLogout.test.js.map +1 -1
- package/dist/__tests__/router/query-string.test.d.ts +2 -0
- package/dist/__tests__/router/query-string.test.d.ts.map +1 -0
- package/dist/__tests__/router/query-string.test.js +44 -0
- package/dist/__tests__/router/query-string.test.js.map +1 -0
- package/dist/__tests__/views/form-entries.render.test.js +14 -4
- package/dist/__tests__/views/form-entries.render.test.js.map +1 -1
- package/dist/__tests__/views/form-submissions.render.test.js +14 -4
- package/dist/__tests__/views/form-submissions.render.test.js.map +1 -1
- package/dist/__tests__/views/media-seo-audit.render.test.js +26 -0
- package/dist/__tests__/views/media-seo-audit.render.test.js.map +1 -1
- package/dist/__tests__/views/redirects-pane.render.test.d.ts +2 -0
- package/dist/__tests__/views/redirects-pane.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/redirects-pane.render.test.js +121 -0
- package/dist/__tests__/views/redirects-pane.render.test.js.map +1 -0
- package/dist/__tests__/views/users-selected-deeplink.render.test.d.ts +2 -0
- package/dist/__tests__/views/users-selected-deeplink.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/users-selected-deeplink.render.test.js +91 -0
- package/dist/__tests__/views/users-selected-deeplink.render.test.js.map +1 -0
- package/dist/actuate-admin.css +1 -1
- package/dist/components/SEOPanel.js +3 -3
- package/dist/components/SEOPanel.js.map +1 -1
- package/dist/components/collections/EntryDetailPane.js +1 -1
- package/dist/components/collections/EntryDetailPane.js.map +1 -1
- package/dist/components/forms/EntriesTable.d.ts +3 -1
- package/dist/components/forms/EntriesTable.d.ts.map +1 -1
- package/dist/components/forms/EntriesTable.js +5 -2
- package/dist/components/forms/EntriesTable.js.map +1 -1
- package/dist/components/forms/FormEntryDetailPane.d.ts +10 -0
- package/dist/components/forms/FormEntryDetailPane.d.ts.map +1 -0
- package/dist/components/forms/FormEntryDetailPane.js +137 -0
- package/dist/components/forms/FormEntryDetailPane.js.map +1 -0
- package/dist/components/forms/FormSchemaPane.js +1 -1
- package/dist/components/forms/FormSchemaPane.js.map +1 -1
- package/dist/components/script-tags/ScriptTagEditorPane.js +1 -1
- package/dist/components/script-tags/ScriptTagEditorPane.js.map +1 -1
- package/dist/components/seo/RedirectEditorPane.d.ts +12 -0
- package/dist/components/seo/RedirectEditorPane.d.ts.map +1 -0
- package/dist/components/seo/RedirectEditorPane.js +89 -0
- package/dist/components/seo/RedirectEditorPane.js.map +1 -0
- package/dist/components/seo/SeoEditorPane.js +1 -1
- package/dist/components/seo/SeoEditorPane.js.map +1 -1
- package/dist/components/ui/DataTable.js +2 -2
- package/dist/components/ui/DataTable.js.map +1 -1
- package/dist/components/ui/Skeleton.js +3 -3
- package/dist/components/ui/Skeleton.js.map +1 -1
- package/dist/components/ui/Toggle.js +1 -1
- package/dist/components/ui/Toggle.js.map +1 -1
- package/dist/fields/BlockBuilderField.js +1 -1
- package/dist/fields/BlockBuilderField.js.map +1 -1
- package/dist/fields/MediaField.js +1 -1
- package/dist/fields/MediaField.js.map +1 -1
- package/dist/fields/PropInput.js +1 -1
- package/dist/fields/PropInput.js.map +1 -1
- package/dist/hooks/useIdleLogout.d.ts +21 -6
- package/dist/hooks/useIdleLogout.d.ts.map +1 -1
- package/dist/hooks/useIdleLogout.js +41 -8
- package/dist/hooks/useIdleLogout.js.map +1 -1
- package/dist/layout/Header.js +1 -1
- package/dist/layout/Header.js.map +1 -1
- package/dist/router/index.d.ts +7 -0
- package/dist/router/index.d.ts.map +1 -1
- package/dist/router/index.js +23 -4
- package/dist/router/index.js.map +1 -1
- package/dist/views/DocumentEdit.js +1 -1
- package/dist/views/DocumentEdit.js.map +1 -1
- package/dist/views/FormEntries.d.ts.map +1 -1
- package/dist/views/FormEntries.js +18 -14
- package/dist/views/FormEntries.js.map +1 -1
- package/dist/views/FormSubmissions.d.ts.map +1 -1
- package/dist/views/FormSubmissions.js +18 -15
- package/dist/views/FormSubmissions.js.map +1 -1
- package/dist/views/Forms.js +5 -5
- package/dist/views/Forms.js.map +1 -1
- package/dist/views/MediaBrowser.d.ts +4 -1
- package/dist/views/MediaBrowser.d.ts.map +1 -1
- package/dist/views/MediaBrowser.js +28 -6
- package/dist/views/MediaBrowser.js.map +1 -1
- package/dist/views/MediaSeoAudit.d.ts +4 -1
- package/dist/views/MediaSeoAudit.d.ts.map +1 -1
- package/dist/views/MediaSeoAudit.js +12 -2
- package/dist/views/MediaSeoAudit.js.map +1 -1
- package/dist/views/Pages/PagesListView.js +14 -14
- package/dist/views/Pages/PagesListView.js.map +1 -1
- package/dist/views/Pages/PagesTreeTable.d.ts.map +1 -1
- package/dist/views/Pages/PagesTreeTable.js +3 -3
- package/dist/views/Pages/PagesTreeTable.js.map +1 -1
- package/dist/views/SEO.d.ts.map +1 -1
- package/dist/views/SEO.js +2 -1
- package/dist/views/SEO.js.map +1 -1
- package/dist/views/ScriptTags.d.ts.map +1 -1
- package/dist/views/ScriptTags.js +3 -3
- package/dist/views/ScriptTags.js.map +1 -1
- package/dist/views/Settings.d.ts +4 -1
- package/dist/views/Settings.d.ts.map +1 -1
- package/dist/views/Settings.js +4 -3
- package/dist/views/Settings.js.map +1 -1
- package/dist/views/Users.d.ts +4 -1
- package/dist/views/Users.d.ts.map +1 -1
- package/dist/views/Users.js +22 -2
- package/dist/views/Users.js.map +1 -1
- package/dist/views/seo/ContentTab.js +6 -6
- package/dist/views/seo/ContentTab.js.map +1 -1
- package/dist/views/seo/RedirectsTab.d.ts.map +1 -1
- package/dist/views/seo/RedirectsTab.js +23 -8
- package/dist/views/seo/RedirectsTab.js.map +1 -1
- package/dist/views/settings/SecurityTab.d.ts.map +1 -1
- package/dist/views/settings/SecurityTab.js +2 -1
- package/dist/views/settings/SecurityTab.js.map +1 -1
- package/package.json +2 -2
- package/src/AdminRoot.tsx +53 -12
- package/src/__tests__/hooks/useIdleLogout.test.tsx +103 -1
- package/src/__tests__/router/query-string.test.ts +54 -0
- package/src/__tests__/views/form-entries.render.test.tsx +16 -4
- package/src/__tests__/views/form-submissions.render.test.tsx +16 -4
- package/src/__tests__/views/media-seo-audit.render.test.tsx +34 -0
- package/src/__tests__/views/redirects-pane.render.test.tsx +156 -0
- package/src/__tests__/views/users-selected-deeplink.render.test.tsx +102 -0
- package/src/components/SEOPanel.tsx +9 -9
- package/src/components/collections/EntryDetailPane.tsx +1 -1
- package/src/components/forms/EntriesTable.tsx +12 -3
- package/src/components/forms/{EntryDetailDrawer.tsx → FormEntryDetailPane.tsx} +112 -102
- package/src/components/forms/FormSchemaPane.tsx +1 -1
- package/src/components/script-tags/ScriptTagEditorPane.tsx +1 -1
- package/src/components/seo/RedirectEditorPane.tsx +271 -0
- package/src/components/seo/SeoEditorPane.tsx +1 -1
- package/src/components/ui/DataTable.tsx +3 -3
- package/src/components/ui/Skeleton.tsx +8 -8
- package/src/components/ui/Toggle.tsx +1 -1
- package/src/fields/BlockBuilderField.tsx +1 -1
- package/src/fields/MediaField.tsx +1 -1
- package/src/fields/PropInput.tsx +1 -1
- package/src/hooks/useIdleLogout.ts +65 -13
- package/src/layout/Header.tsx +2 -2
- package/src/router/index.ts +25 -4
- package/src/styles/actuate-tokens.css +4 -0
- package/src/styles/theme.css +9 -3
- package/src/views/DocumentEdit.tsx +4 -4
- package/src/views/FormEntries.tsx +81 -66
- package/src/views/FormSubmissions.tsx +78 -64
- package/src/views/Forms.tsx +16 -16
- package/src/views/MediaBrowser.tsx +34 -6
- package/src/views/MediaSeoAudit.tsx +39 -2
- package/src/views/Pages/PagesListView.tsx +46 -46
- package/src/views/Pages/PagesTreeTable.tsx +11 -8
- package/src/views/SEO.tsx +2 -1
- package/src/views/ScriptTags.tsx +26 -36
- package/src/views/Settings.tsx +7 -2
- package/src/views/Users.tsx +23 -2
- package/src/views/seo/ContentTab.tsx +10 -10
- package/src/views/seo/RedirectsTab.tsx +145 -109
- package/src/views/settings/SecurityTab.tsx +2 -1
- package/dist/components/forms/EntryDetailDrawer.d.ts +0 -11
- package/dist/components/forms/EntryDetailDrawer.d.ts.map +0 -1
- package/dist/components/forms/EntryDetailDrawer.js +0 -145
- package/dist/components/forms/EntryDetailDrawer.js.map +0 -1
- package/dist/components/seo/RedirectEditorDrawer.d.ts +0 -9
- package/dist/components/seo/RedirectEditorDrawer.d.ts.map +0 -1
- package/dist/components/seo/RedirectEditorDrawer.js +0 -95
- package/dist/components/seo/RedirectEditorDrawer.js.map +0 -1
- package/src/components/seo/RedirectEditorDrawer.tsx +0 -231
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Inline redirect editor pane — design-parity replacement for the old
|
|
5
|
+
* `RedirectEditorDrawer` overlay. Rendered inside an `<InspectorPane>` beside
|
|
6
|
+
* the redirect rules table. Same fields, validation, and create/update save
|
|
7
|
+
* paths as the drawer; only the presentation pattern changed.
|
|
8
|
+
*/
|
|
9
|
+
import { useState } from 'react'
|
|
10
|
+
import { Trash2 } from 'lucide-react'
|
|
11
|
+
import { toast } from 'sonner'
|
|
12
|
+
import {
|
|
13
|
+
createRedirectRule,
|
|
14
|
+
updateRedirectRule,
|
|
15
|
+
type RedirectRule,
|
|
16
|
+
type RedirectRulePayload,
|
|
17
|
+
type RedirectStatusCode,
|
|
18
|
+
} from '../../lib/seo-service.js'
|
|
19
|
+
import {
|
|
20
|
+
InspectorPaneBody,
|
|
21
|
+
InspectorPaneFooter,
|
|
22
|
+
InspectorPaneHeader,
|
|
23
|
+
InspectorPaneSection,
|
|
24
|
+
} from '../ui/InspectorPane.js'
|
|
25
|
+
import { Toggle } from '../ui/Toggle.js'
|
|
26
|
+
|
|
27
|
+
const STATUS_CODES: { value: RedirectStatusCode; label: string }[] = [
|
|
28
|
+
{ value: 301, label: '301 — Permanent' },
|
|
29
|
+
{ value: 302, label: '302 — Temporary' },
|
|
30
|
+
{ value: 307, label: '307 — Temporary (preserve method)' },
|
|
31
|
+
{ value: 308, label: '308 — Permanent (preserve method)' },
|
|
32
|
+
{ value: 410, label: '410 — Gone (no destination)' },
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
const inputCls =
|
|
36
|
+
'w-full rounded-[7px] border border-[var(--bdr)] bg-[var(--card)] px-2.5 py-1.5 text-[12.5px] text-[var(--txt)] placeholder:text-[var(--muted)] focus:border-[var(--acc)] focus:outline-none'
|
|
37
|
+
|
|
38
|
+
const labelCls = 'mb-1 block text-[11.5px] font-medium text-[var(--sub)]'
|
|
39
|
+
|
|
40
|
+
function normalizePath(p: string): string {
|
|
41
|
+
const t = p.trim()
|
|
42
|
+
if (!t) return ''
|
|
43
|
+
if (/^https?:\/\//i.test(t)) return t
|
|
44
|
+
return t.startsWith('/') ? t : `/${t}`
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function PaneToggleRow({
|
|
48
|
+
label,
|
|
49
|
+
hint,
|
|
50
|
+
checked,
|
|
51
|
+
onCheckedChange,
|
|
52
|
+
variant,
|
|
53
|
+
}: {
|
|
54
|
+
label: string
|
|
55
|
+
hint?: string
|
|
56
|
+
checked: boolean
|
|
57
|
+
onCheckedChange: (checked: boolean) => void
|
|
58
|
+
variant?: 'enabled'
|
|
59
|
+
}) {
|
|
60
|
+
return (
|
|
61
|
+
<div className="flex items-center justify-between gap-3 py-1.5">
|
|
62
|
+
<div className="min-w-0">
|
|
63
|
+
<div className="text-[12.5px] font-medium text-[var(--txt)]">{label}</div>
|
|
64
|
+
{hint && <div className="text-[11px] text-[var(--muted)]">{hint}</div>}
|
|
65
|
+
</div>
|
|
66
|
+
<Toggle
|
|
67
|
+
checked={checked}
|
|
68
|
+
onCheckedChange={onCheckedChange}
|
|
69
|
+
variant={variant}
|
|
70
|
+
aria-label={label}
|
|
71
|
+
/>
|
|
72
|
+
</div>
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface RedirectEditorPaneProps {
|
|
77
|
+
/** null = create mode */
|
|
78
|
+
rule: RedirectRule | null
|
|
79
|
+
onClose: () => void
|
|
80
|
+
/** Called after a successful save so the list can refetch / close the pane. */
|
|
81
|
+
onSaved?: () => void
|
|
82
|
+
/** Ask the parent to confirm-and-delete this rule (edit mode only). */
|
|
83
|
+
onDelete?: (rule: RedirectRule) => void
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function RedirectEditorPane({ rule, onClose, onSaved, onDelete }: RedirectEditorPaneProps) {
|
|
87
|
+
// The parent keys this pane by rule id, so initializing from props is safe.
|
|
88
|
+
const [form, setForm] = useState<RedirectRulePayload>(() =>
|
|
89
|
+
rule
|
|
90
|
+
? {
|
|
91
|
+
type: rule.type,
|
|
92
|
+
fromPath: rule.fromPath,
|
|
93
|
+
toPath: rule.toPath,
|
|
94
|
+
enabled: rule.enabled,
|
|
95
|
+
preserveQuery: rule.preserveQuery,
|
|
96
|
+
notes: rule.notes ?? '',
|
|
97
|
+
}
|
|
98
|
+
: {
|
|
99
|
+
type: 301,
|
|
100
|
+
fromPath: '',
|
|
101
|
+
toPath: '',
|
|
102
|
+
enabled: true,
|
|
103
|
+
preserveQuery: false,
|
|
104
|
+
notes: '',
|
|
105
|
+
},
|
|
106
|
+
)
|
|
107
|
+
const [saving, setSaving] = useState(false)
|
|
108
|
+
const [clientError, setClientError] = useState<string | null>(null)
|
|
109
|
+
|
|
110
|
+
const isGone = form.type === 410
|
|
111
|
+
const update = (patch: Partial<RedirectRulePayload>) => setForm((f) => ({ ...f, ...patch }))
|
|
112
|
+
|
|
113
|
+
async function handleSave() {
|
|
114
|
+
const fromPath = normalizePath(form.fromPath)
|
|
115
|
+
const toPath = isGone ? '' : normalizePath(form.toPath)
|
|
116
|
+
if (!fromPath) {
|
|
117
|
+
setClientError('Source path is required.')
|
|
118
|
+
return
|
|
119
|
+
}
|
|
120
|
+
if (!isGone && !toPath) {
|
|
121
|
+
setClientError('Destination is required for redirect status codes.')
|
|
122
|
+
return
|
|
123
|
+
}
|
|
124
|
+
if (!isGone && fromPath === toPath) {
|
|
125
|
+
setClientError('Source and destination cannot be identical (would create a loop).')
|
|
126
|
+
return
|
|
127
|
+
}
|
|
128
|
+
setClientError(null)
|
|
129
|
+
setSaving(true)
|
|
130
|
+
const payload: RedirectRulePayload = { ...form, fromPath, toPath }
|
|
131
|
+
const res = rule
|
|
132
|
+
? await updateRedirectRule(rule.id, payload)
|
|
133
|
+
: await createRedirectRule(payload)
|
|
134
|
+
setSaving(false)
|
|
135
|
+
if (res.error) {
|
|
136
|
+
toast.error(res.error)
|
|
137
|
+
return
|
|
138
|
+
}
|
|
139
|
+
toast.success(rule ? 'Redirect updated.' : 'Redirect created.')
|
|
140
|
+
onSaved?.()
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return (
|
|
144
|
+
<>
|
|
145
|
+
<InspectorPaneHeader onClose={onClose} closeLabel="Close redirect editor">
|
|
146
|
+
<div className="truncate text-[13.5px] font-semibold text-[var(--txt)]">
|
|
147
|
+
{rule ? 'Edit redirect' : 'New redirect'}
|
|
148
|
+
</div>
|
|
149
|
+
{rule && (
|
|
150
|
+
<div className="mt-0.5 truncate font-mono text-[11.5px] text-[var(--muted)]">
|
|
151
|
+
{rule.fromPath}
|
|
152
|
+
</div>
|
|
153
|
+
)}
|
|
154
|
+
</InspectorPaneHeader>
|
|
155
|
+
|
|
156
|
+
<InspectorPaneBody>
|
|
157
|
+
{clientError && (
|
|
158
|
+
<div className="px-4 pt-3.5">
|
|
159
|
+
<p
|
|
160
|
+
className="rounded-[7px] bg-[var(--err-l)] px-3 py-2 text-[12px] text-[var(--err)]"
|
|
161
|
+
role="alert"
|
|
162
|
+
>
|
|
163
|
+
{clientError}
|
|
164
|
+
</p>
|
|
165
|
+
</div>
|
|
166
|
+
)}
|
|
167
|
+
|
|
168
|
+
<InspectorPaneSection title="Rule">
|
|
169
|
+
<div className="mb-3">
|
|
170
|
+
<label className={labelCls} htmlFor="redir-type">
|
|
171
|
+
Status code
|
|
172
|
+
</label>
|
|
173
|
+
<select
|
|
174
|
+
id="redir-type"
|
|
175
|
+
className={inputCls}
|
|
176
|
+
value={form.type}
|
|
177
|
+
onChange={(e) => update({ type: Number(e.target.value) as RedirectStatusCode })}
|
|
178
|
+
>
|
|
179
|
+
{STATUS_CODES.map((s) => (
|
|
180
|
+
<option key={s.value} value={s.value}>
|
|
181
|
+
{s.label}
|
|
182
|
+
</option>
|
|
183
|
+
))}
|
|
184
|
+
</select>
|
|
185
|
+
</div>
|
|
186
|
+
|
|
187
|
+
<div className="mb-3">
|
|
188
|
+
<label className={labelCls} htmlFor="redir-from">
|
|
189
|
+
Source path
|
|
190
|
+
</label>
|
|
191
|
+
<input
|
|
192
|
+
id="redir-from"
|
|
193
|
+
className={inputCls}
|
|
194
|
+
value={form.fromPath}
|
|
195
|
+
onChange={(e) => update({ fromPath: e.target.value })}
|
|
196
|
+
placeholder="/old-path"
|
|
197
|
+
/>
|
|
198
|
+
</div>
|
|
199
|
+
|
|
200
|
+
{!isGone && (
|
|
201
|
+
<div className="mb-3 last:mb-0">
|
|
202
|
+
<label className={labelCls} htmlFor="redir-to">
|
|
203
|
+
Destination
|
|
204
|
+
</label>
|
|
205
|
+
<input
|
|
206
|
+
id="redir-to"
|
|
207
|
+
className={inputCls}
|
|
208
|
+
value={form.toPath}
|
|
209
|
+
onChange={(e) => update({ toPath: e.target.value })}
|
|
210
|
+
placeholder="/new-path or https://example.com/page"
|
|
211
|
+
/>
|
|
212
|
+
</div>
|
|
213
|
+
)}
|
|
214
|
+
</InspectorPaneSection>
|
|
215
|
+
|
|
216
|
+
<InspectorPaneSection title="Options">
|
|
217
|
+
<PaneToggleRow
|
|
218
|
+
label="Enabled"
|
|
219
|
+
checked={!!form.enabled}
|
|
220
|
+
onCheckedChange={(v) => update({ enabled: v })}
|
|
221
|
+
variant="enabled"
|
|
222
|
+
/>
|
|
223
|
+
{!isGone && (
|
|
224
|
+
<PaneToggleRow
|
|
225
|
+
label="Preserve query string"
|
|
226
|
+
hint="Append the original query params to the destination."
|
|
227
|
+
checked={!!form.preserveQuery}
|
|
228
|
+
onCheckedChange={(v) => update({ preserveQuery: v })}
|
|
229
|
+
/>
|
|
230
|
+
)}
|
|
231
|
+
<div className="mt-2">
|
|
232
|
+
<label className={labelCls} htmlFor="redir-notes">
|
|
233
|
+
Notes <span className="font-normal text-[var(--muted)]">(optional)</span>
|
|
234
|
+
</label>
|
|
235
|
+
<input
|
|
236
|
+
id="redir-notes"
|
|
237
|
+
className={inputCls}
|
|
238
|
+
value={form.notes ?? ''}
|
|
239
|
+
onChange={(e) => update({ notes: e.target.value })}
|
|
240
|
+
placeholder="Why this redirect exists"
|
|
241
|
+
/>
|
|
242
|
+
</div>
|
|
243
|
+
</InspectorPaneSection>
|
|
244
|
+
</InspectorPaneBody>
|
|
245
|
+
|
|
246
|
+
<InspectorPaneFooter>
|
|
247
|
+
<div className="flex items-center gap-2">
|
|
248
|
+
{rule && onDelete && (
|
|
249
|
+
<button
|
|
250
|
+
type="button"
|
|
251
|
+
onClick={() => onDelete(rule)}
|
|
252
|
+
disabled={saving}
|
|
253
|
+
aria-label={`Delete redirect ${rule.fromPath}`}
|
|
254
|
+
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-[7px] border border-[var(--bdr)] text-[var(--err)] transition-colors duration-[var(--motion-fast)] hover:bg-[var(--err-l)] focus-visible:ring-2 focus-visible:ring-[var(--ring)] focus-visible:outline-none disabled:opacity-50"
|
|
255
|
+
>
|
|
256
|
+
<Trash2 size={16} aria-hidden />
|
|
257
|
+
</button>
|
|
258
|
+
)}
|
|
259
|
+
<button
|
|
260
|
+
type="button"
|
|
261
|
+
onClick={() => void handleSave()}
|
|
262
|
+
disabled={saving}
|
|
263
|
+
className="bg-primary text-primary-foreground min-w-0 flex-1 rounded-[7px] px-3 py-2 text-[13px] font-semibold transition-colors duration-[var(--motion-fast)] hover:opacity-90 focus-visible:ring-2 focus-visible:ring-[var(--ring)] focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
|
264
|
+
>
|
|
265
|
+
{saving ? 'Saving…' : rule ? 'Save changes' : 'Create redirect'}
|
|
266
|
+
</button>
|
|
267
|
+
</div>
|
|
268
|
+
</InspectorPaneFooter>
|
|
269
|
+
</>
|
|
270
|
+
)
|
|
271
|
+
}
|
|
@@ -523,7 +523,7 @@ export function SeoEditorPane({ entityType, entityId, onClose, onSaved }: SeoEdi
|
|
|
523
523
|
type="button"
|
|
524
524
|
onClick={() => void handleSave()}
|
|
525
525
|
disabled={saving || loading || !!error}
|
|
526
|
-
className="w-full rounded-[7px]
|
|
526
|
+
className="bg-primary text-primary-foreground w-full rounded-[7px] px-3 py-2 text-[13px] font-semibold transition-colors duration-[var(--motion-fast)] hover:opacity-90 focus-visible:ring-2 focus-visible:ring-[var(--ring)] focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
|
527
527
|
>
|
|
528
528
|
{saving ? 'Saving…' : 'Save Changes'}
|
|
529
529
|
</button>
|
|
@@ -59,7 +59,7 @@ export function DataTable({
|
|
|
59
59
|
return (
|
|
60
60
|
<div className="overflow-x-auto rounded-lg border border-[var(--border)]">
|
|
61
61
|
<table className="w-full text-left text-sm">
|
|
62
|
-
<thead className="border-b border-[var(--border)]
|
|
62
|
+
<thead className="bg-muted border-b border-[var(--border)]">
|
|
63
63
|
<tr>
|
|
64
64
|
<th className="w-10 px-4 py-3">
|
|
65
65
|
<input
|
|
@@ -146,7 +146,7 @@ function RowActionsMenu({ actions, row }: { actions: RowAction[]; row: Row }) {
|
|
|
146
146
|
<button
|
|
147
147
|
key={action.key}
|
|
148
148
|
onClick={() => action.onClick(row)}
|
|
149
|
-
className="rounded p-1 text-[var(--muted-foreground)] hover:
|
|
149
|
+
className="hover:bg-muted rounded p-1 text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
|
|
150
150
|
aria-label={action.label}
|
|
151
151
|
title={action.label}
|
|
152
152
|
>
|
|
@@ -155,7 +155,7 @@ function RowActionsMenu({ actions, row }: { actions: RowAction[]; row: Row }) {
|
|
|
155
155
|
))}
|
|
156
156
|
<button
|
|
157
157
|
onClick={() => setOpen((o) => !o)}
|
|
158
|
-
className="rounded p-1
|
|
158
|
+
className="hover:bg-muted rounded p-1"
|
|
159
159
|
aria-label="More actions"
|
|
160
160
|
>
|
|
161
161
|
<MoreVerticalIcon />
|
|
@@ -10,10 +10,10 @@ export function Skeleton({ variant = 'text', lines = 3, className = '' }: Skelet
|
|
|
10
10
|
if (variant === 'card') {
|
|
11
11
|
return (
|
|
12
12
|
<div className={`animate-pulse rounded-lg border border-[var(--border)] p-5 ${className}`}>
|
|
13
|
-
<div className="mb-3 h-4 w-1/3 rounded
|
|
13
|
+
<div className="bg-muted mb-3 h-4 w-1/3 rounded" />
|
|
14
14
|
<div className="space-y-2">
|
|
15
|
-
<div className="h-3 w-full rounded
|
|
16
|
-
<div className="h-3 w-2/3 rounded
|
|
15
|
+
<div className="bg-muted h-3 w-full rounded" />
|
|
16
|
+
<div className="bg-muted h-3 w-2/3 rounded" />
|
|
17
17
|
</div>
|
|
18
18
|
</div>
|
|
19
19
|
)
|
|
@@ -24,10 +24,10 @@ export function Skeleton({ variant = 'text', lines = 3, className = '' }: Skelet
|
|
|
24
24
|
<div className={`animate-pulse ${className}`}>
|
|
25
25
|
{Array.from({ length: lines }, (_, i) => (
|
|
26
26
|
<div key={i} className="flex gap-4 border-b border-[var(--border)] px-4 py-3">
|
|
27
|
-
<div className="h-4 w-8 rounded
|
|
28
|
-
<div className="h-4 flex-1 rounded
|
|
29
|
-
<div className="h-4 w-20 rounded
|
|
30
|
-
<div className="h-4 w-24 rounded
|
|
27
|
+
<div className="bg-muted h-4 w-8 rounded" />
|
|
28
|
+
<div className="bg-muted h-4 flex-1 rounded" />
|
|
29
|
+
<div className="bg-muted h-4 w-20 rounded" />
|
|
30
|
+
<div className="bg-muted h-4 w-24 rounded" />
|
|
31
31
|
</div>
|
|
32
32
|
))}
|
|
33
33
|
</div>
|
|
@@ -39,7 +39,7 @@ export function Skeleton({ variant = 'text', lines = 3, className = '' }: Skelet
|
|
|
39
39
|
{Array.from({ length: lines }, (_, i) => (
|
|
40
40
|
<div
|
|
41
41
|
key={i}
|
|
42
|
-
className="h-3 rounded
|
|
42
|
+
className="bg-muted h-3 rounded"
|
|
43
43
|
style={{ width: `${Math.max(40, 100 - i * 15)}%` }}
|
|
44
44
|
/>
|
|
45
45
|
))}
|
|
@@ -26,7 +26,7 @@ const toggleTrack = cv(
|
|
|
26
26
|
)
|
|
27
27
|
|
|
28
28
|
const toggleThumb = cv(
|
|
29
|
-
'pointer-events-none block rounded-full bg-
|
|
29
|
+
'pointer-events-none block rounded-full bg-[var(--toggle-thumb)] shadow-[0_1px_3px_rgba(0,0,0,0.2)] transition-transform duration-[var(--motion-base)]',
|
|
30
30
|
{
|
|
31
31
|
variants: {
|
|
32
32
|
size: {
|
|
@@ -114,7 +114,7 @@ function BlockField({ field, value, onChange }: BlockFieldProps) {
|
|
|
114
114
|
{field.required && <span className="ml-0.5 text-[var(--destructive)]">*</span>}
|
|
115
115
|
</label>
|
|
116
116
|
<div className="flex items-center gap-3 rounded-md border border-dashed border-[var(--border)] p-3">
|
|
117
|
-
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-md
|
|
117
|
+
<div className="bg-muted flex h-12 w-12 shrink-0 items-center justify-center rounded-md">
|
|
118
118
|
<Image className="h-5 w-5 text-[var(--muted-foreground)]" />
|
|
119
119
|
</div>
|
|
120
120
|
<span className="text-sm text-[var(--muted-foreground)]">
|
|
@@ -18,7 +18,7 @@ export function MediaField({ label, value, onChange, required, helpText }: Media
|
|
|
18
18
|
|
|
19
19
|
{value ? (
|
|
20
20
|
<div className="flex items-center gap-3 rounded-md border border-[var(--border)] p-3">
|
|
21
|
-
<div className="h-16 w-16 shrink-0 rounded-md
|
|
21
|
+
<div className="bg-muted h-16 w-16 shrink-0 rounded-md" />
|
|
22
22
|
<div className="flex-1">
|
|
23
23
|
<p className="text-sm font-medium">{value.filename}</p>
|
|
24
24
|
<button
|
package/src/fields/PropInput.tsx
CHANGED
|
@@ -302,7 +302,7 @@ function LiteralDisplay({
|
|
|
302
302
|
return (
|
|
303
303
|
<div className="text-sm">
|
|
304
304
|
<div className="mb-1">{label}</div>
|
|
305
|
-
<div className="rounded-md border border-dashed border-[var(--border)]
|
|
305
|
+
<div className="bg-muted rounded-md border border-dashed border-[var(--border)] px-3 py-2 text-xs text-[var(--muted-foreground)]">
|
|
306
306
|
Fixed: <code>{String(literal)}</code>
|
|
307
307
|
</div>
|
|
308
308
|
</div>
|
|
@@ -1,13 +1,31 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
-
import { useEffect, useRef } from 'react'
|
|
3
|
+
import { useCallback, useEffect, useRef } from 'react'
|
|
4
4
|
|
|
5
5
|
/** How often the idle checker wakes up to compare against the deadline. */
|
|
6
6
|
const CHECK_INTERVAL_MS = 30_000
|
|
7
7
|
|
|
8
|
+
export interface UseIdleLogoutOptions {
|
|
9
|
+
enabled: boolean
|
|
10
|
+
timeoutMs: number
|
|
11
|
+
onIdle: () => void
|
|
12
|
+
/**
|
|
13
|
+
* How long before the idle deadline to fire `onWarn` (so the UI can show a
|
|
14
|
+
* "you're about to be signed out" prompt). Ignored unless `onWarn` is set
|
|
15
|
+
* or when it doesn't fit inside `timeoutMs`.
|
|
16
|
+
*/
|
|
17
|
+
warningMs?: number
|
|
18
|
+
/** Fired once when the session enters the warning window. */
|
|
19
|
+
onWarn?: () => void
|
|
20
|
+
/** Fired when activity resumes after `onWarn` (dismiss the prompt). */
|
|
21
|
+
onWarnCancelled?: () => void
|
|
22
|
+
}
|
|
23
|
+
|
|
8
24
|
/**
|
|
9
25
|
* Signs the user out after `timeoutMs` of no user activity, when enabled by
|
|
10
|
-
* the Settings → Security "Idle auto-logout" policy.
|
|
26
|
+
* the Settings → Security "Idle auto-logout" policy. Optionally fires a
|
|
27
|
+
* warning `warningMs` before the deadline so the UI can offer a
|
|
28
|
+
* "Stay signed in" action (the returned `stayActive` resets the clock).
|
|
11
29
|
*
|
|
12
30
|
* Deliberately lightweight: activity events only stamp a ref (no state, no
|
|
13
31
|
* re-renders), and a single coarse interval compares the last-activity stamp
|
|
@@ -17,31 +35,53 @@ const CHECK_INTERVAL_MS = 30_000
|
|
|
17
35
|
*
|
|
18
36
|
* All listeners and the interval are torn down on unmount or when disabled.
|
|
19
37
|
*/
|
|
20
|
-
export function useIdleLogout(options: {
|
|
21
|
-
enabled
|
|
22
|
-
|
|
23
|
-
onIdle: () => void
|
|
24
|
-
}): void {
|
|
25
|
-
const { enabled, timeoutMs } = options
|
|
26
|
-
// Keep the latest callback without re-subscribing listeners on every render.
|
|
38
|
+
export function useIdleLogout(options: UseIdleLogoutOptions): { stayActive: () => void } {
|
|
39
|
+
const { enabled, timeoutMs, warningMs } = options
|
|
40
|
+
// Keep the latest callbacks without re-subscribing listeners on every render.
|
|
27
41
|
const onIdleRef = useRef(options.onIdle)
|
|
28
42
|
onIdleRef.current = options.onIdle
|
|
43
|
+
const onWarnRef = useRef(options.onWarn)
|
|
44
|
+
onWarnRef.current = options.onWarn
|
|
45
|
+
const onWarnCancelledRef = useRef(options.onWarnCancelled)
|
|
46
|
+
onWarnCancelledRef.current = options.onWarnCancelled
|
|
47
|
+
|
|
48
|
+
// The effect re-assigns this so `stayActive` always resets the live timer.
|
|
49
|
+
const resetRef = useRef<() => void>(() => undefined)
|
|
29
50
|
|
|
30
51
|
useEffect(() => {
|
|
31
|
-
if (!enabled || timeoutMs <= 0 || typeof window === 'undefined')
|
|
52
|
+
if (!enabled || timeoutMs <= 0 || typeof window === 'undefined') {
|
|
53
|
+
resetRef.current = () => undefined
|
|
54
|
+
return
|
|
55
|
+
}
|
|
32
56
|
|
|
33
57
|
let lastActivity = Date.now()
|
|
34
58
|
let fired = false
|
|
59
|
+
let warned = false
|
|
60
|
+
|
|
61
|
+
// Only meaningful when a warn callback exists and the window fits.
|
|
62
|
+
const effectiveWarningMs =
|
|
63
|
+
onWarnRef.current && warningMs && warningMs > 0 && warningMs < timeoutMs ? warningMs : 0
|
|
35
64
|
|
|
36
65
|
const markActivity = () => {
|
|
37
66
|
lastActivity = Date.now()
|
|
67
|
+
if (warned && !fired) {
|
|
68
|
+
warned = false
|
|
69
|
+
onWarnCancelledRef.current?.()
|
|
70
|
+
}
|
|
38
71
|
}
|
|
72
|
+
resetRef.current = markActivity
|
|
39
73
|
|
|
40
74
|
const checkIdle = () => {
|
|
41
75
|
if (fired) return
|
|
42
|
-
|
|
76
|
+
const idleFor = Date.now() - lastActivity
|
|
77
|
+
if (idleFor >= timeoutMs) {
|
|
43
78
|
fired = true
|
|
44
79
|
onIdleRef.current()
|
|
80
|
+
return
|
|
81
|
+
}
|
|
82
|
+
if (effectiveWarningMs > 0 && !warned && idleFor >= timeoutMs - effectiveWarningMs) {
|
|
83
|
+
warned = true
|
|
84
|
+
onWarnRef.current?.()
|
|
45
85
|
}
|
|
46
86
|
}
|
|
47
87
|
|
|
@@ -64,14 +104,26 @@ export function useIdleLogout(options: {
|
|
|
64
104
|
}
|
|
65
105
|
document.addEventListener('visibilitychange', onVisibility)
|
|
66
106
|
|
|
67
|
-
|
|
107
|
+
// Tick often enough to hit the warning window, not just the deadline.
|
|
108
|
+
const tick =
|
|
109
|
+
effectiveWarningMs > 0
|
|
110
|
+
? Math.min(CHECK_INTERVAL_MS, timeoutMs, effectiveWarningMs)
|
|
111
|
+
: Math.min(CHECK_INTERVAL_MS, timeoutMs)
|
|
112
|
+
const interval = window.setInterval(checkIdle, tick)
|
|
68
113
|
|
|
69
114
|
return () => {
|
|
115
|
+
resetRef.current = () => undefined
|
|
70
116
|
for (const event of activityEvents) {
|
|
71
117
|
window.removeEventListener(event, markActivity)
|
|
72
118
|
}
|
|
73
119
|
document.removeEventListener('visibilitychange', onVisibility)
|
|
74
120
|
window.clearInterval(interval)
|
|
75
121
|
}
|
|
76
|
-
}, [enabled, timeoutMs])
|
|
122
|
+
}, [enabled, timeoutMs, warningMs])
|
|
123
|
+
|
|
124
|
+
const stayActive = useCallback(() => {
|
|
125
|
+
resetRef.current()
|
|
126
|
+
}, [])
|
|
127
|
+
|
|
128
|
+
return { stayActive }
|
|
77
129
|
}
|
package/src/layout/Header.tsx
CHANGED
|
@@ -127,8 +127,8 @@ export function Header({
|
|
|
127
127
|
className="hover:bg-accent flex items-center gap-1.5 rounded-lg p-1 transition-colors"
|
|
128
128
|
aria-label="Account menu"
|
|
129
129
|
>
|
|
130
|
-
<div className="flex h-8 w-8 items-center justify-center rounded-full
|
|
131
|
-
<span className="text-xs font-semibold
|
|
130
|
+
<div className="bg-primary flex h-8 w-8 items-center justify-center rounded-full">
|
|
131
|
+
<span className="text-primary-foreground text-xs font-semibold">{initial}</span>
|
|
132
132
|
</div>
|
|
133
133
|
<ChevronDown
|
|
134
134
|
className="text-muted-foreground hidden h-4 w-4 sm:block"
|
package/src/router/index.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
-
import { useState, useCallback, useEffect, useRef } from 'react'
|
|
3
|
+
import { useState, useCallback, useEffect, useMemo, useRef } from 'react'
|
|
4
4
|
|
|
5
5
|
interface RouteParams {
|
|
6
6
|
[key: string]: string
|
|
@@ -14,6 +14,15 @@ function stripBase(pathname: string, basePath: string): string {
|
|
|
14
14
|
return '/'
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
/**
|
|
18
|
+
* Parse the query string out of an admin path (`/pages/new?parent=abc`) or a
|
|
19
|
+
* raw search string (`?parent=abc`) into `URLSearchParams`. Pure helper for
|
|
20
|
+
* view components that only receive `currentPath` / `onNavigate` props.
|
|
21
|
+
*/
|
|
22
|
+
export function parseRouteQuery(path: string): URLSearchParams {
|
|
23
|
+
return new URLSearchParams(path.split('#')[0]!.split('?')[1] ?? '')
|
|
24
|
+
}
|
|
25
|
+
|
|
17
26
|
export function useAdminRouter(basePath = '/admin', serverPath = '/') {
|
|
18
27
|
const [currentPath, setCurrentPath] = useState(serverPath)
|
|
19
28
|
const baseRef = useRef(basePath)
|
|
@@ -21,7 +30,10 @@ export function useAdminRouter(basePath = '/admin', serverPath = '/') {
|
|
|
21
30
|
|
|
22
31
|
useEffect(() => {
|
|
23
32
|
if (typeof window !== 'undefined') {
|
|
24
|
-
|
|
33
|
+
// Include the query string so state-bearing URLs (`/pages/new?parent=x`,
|
|
34
|
+
// `/settings?tab=users`) survive hard reloads. matchRoute strips the
|
|
35
|
+
// query before matching, so `/?foo=1` still resolves to `/`.
|
|
36
|
+
const browserPath = stripBase(window.location.pathname, basePath) + window.location.search
|
|
25
37
|
if (browserPath !== currentPath) {
|
|
26
38
|
setCurrentPath(browserPath)
|
|
27
39
|
}
|
|
@@ -30,7 +42,9 @@ export function useAdminRouter(basePath = '/admin', serverPath = '/') {
|
|
|
30
42
|
|
|
31
43
|
useEffect(() => {
|
|
32
44
|
function onPopState() {
|
|
33
|
-
|
|
45
|
+
// Query string included for the same reason as the mount sync above —
|
|
46
|
+
// back/forward must restore deep-link state, not just the pathname.
|
|
47
|
+
setCurrentPath(stripBase(window.location.pathname, baseRef.current) + window.location.search)
|
|
34
48
|
}
|
|
35
49
|
window.addEventListener('popstate', onPopState)
|
|
36
50
|
return () => window.removeEventListener('popstate', onPopState)
|
|
@@ -92,5 +106,12 @@ export function useAdminRouter(basePath = '/admin', serverPath = '/') {
|
|
|
92
106
|
[currentPath],
|
|
93
107
|
)
|
|
94
108
|
|
|
95
|
-
|
|
109
|
+
// Raw query string of the current route (`?tab=users`), or '' when absent.
|
|
110
|
+
// Views wanting parsed params can pass it (or currentPath) to parseRouteQuery.
|
|
111
|
+
const search = useMemo(() => {
|
|
112
|
+
const queryIndex = currentPath.indexOf('?')
|
|
113
|
+
return queryIndex === -1 ? '' : currentPath.slice(queryIndex).split('#')[0]!
|
|
114
|
+
}, [currentPath])
|
|
115
|
+
|
|
116
|
+
return { currentPath, search, navigate, matchRoute, buildHref }
|
|
96
117
|
}
|
|
@@ -63,6 +63,10 @@
|
|
|
63
63
|
--motion-fast: 0.12s;
|
|
64
64
|
--motion-base: 0.18s;
|
|
65
65
|
--motion-layout: 0.22s cubic-bezier(0.4, 0, 0.2, 1);
|
|
66
|
+
|
|
67
|
+
/* Controls — the toggle thumb stays white in both modes per the design
|
|
68
|
+
system (.tog-thumb { background: #fff }), tokenized for themeability. */
|
|
69
|
+
--toggle-thumb: #ffffff;
|
|
66
70
|
}
|
|
67
71
|
|
|
68
72
|
.dark .actuate-admin,
|
package/src/styles/theme.css
CHANGED
|
@@ -19,7 +19,13 @@
|
|
|
19
19
|
--primary-foreground: #ffffff;
|
|
20
20
|
--secondary: var(--acc-l);
|
|
21
21
|
--secondary-foreground: var(--txt);
|
|
22
|
-
|
|
22
|
+
/*
|
|
23
|
+
* IMPORTANT: never redefine --muted here. The raw design token --muted
|
|
24
|
+
* (actuate-tokens.css) is a faint TEXT color used by design-parity
|
|
25
|
+
* surfaces via text-[var(--muted)]. The muted SURFACE used by the
|
|
26
|
+
* Tailwind bg-muted utility is a separate private token below.
|
|
27
|
+
*/
|
|
28
|
+
--surface-muted: color-mix(in srgb, var(--bdr) 55%, var(--card));
|
|
23
29
|
--muted-foreground: var(--sub);
|
|
24
30
|
--accent: var(--acc-l);
|
|
25
31
|
--accent-foreground: var(--acc);
|
|
@@ -68,7 +74,7 @@
|
|
|
68
74
|
--info-foreground: #0d0c18;
|
|
69
75
|
--destructive-foreground: #0d0c18;
|
|
70
76
|
--sidebar-primary-foreground: #0d0c18;
|
|
71
|
-
--muted: color-mix(in srgb, var(--bdr) 40%, var(--card));
|
|
77
|
+
--surface-muted: color-mix(in srgb, var(--bdr) 40%, var(--card));
|
|
72
78
|
}
|
|
73
79
|
|
|
74
80
|
@theme inline {
|
|
@@ -82,7 +88,7 @@
|
|
|
82
88
|
--color-primary-foreground: var(--primary-foreground);
|
|
83
89
|
--color-secondary: var(--secondary);
|
|
84
90
|
--color-secondary-foreground: var(--secondary-foreground);
|
|
85
|
-
--color-muted: var(--muted);
|
|
91
|
+
--color-muted: var(--surface-muted);
|
|
86
92
|
--color-muted-foreground: var(--muted-foreground);
|
|
87
93
|
--color-accent: var(--accent);
|
|
88
94
|
--color-accent-foreground: var(--accent-foreground);
|
|
@@ -321,7 +321,7 @@ export function DocumentEdit({
|
|
|
321
321
|
<div className="flex items-center gap-2">
|
|
322
322
|
<button
|
|
323
323
|
onClick={() => setShowPreview((v) => !v)}
|
|
324
|
-
className="inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm text-[var(--muted-foreground)] hover:
|
|
324
|
+
className="hover:bg-muted inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
|
|
325
325
|
title={showPreview ? 'Hide preview' : 'Show preview'}
|
|
326
326
|
>
|
|
327
327
|
{showPreview ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
|
@@ -331,7 +331,7 @@ export function DocumentEdit({
|
|
|
331
331
|
<>
|
|
332
332
|
<button
|
|
333
333
|
onClick={() => setShowShareLink(true)}
|
|
334
|
-
className="inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm text-[var(--muted-foreground)] hover:
|
|
334
|
+
className="hover:bg-muted inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
|
|
335
335
|
title="Share a signed preview link for the current draft"
|
|
336
336
|
>
|
|
337
337
|
<Share2 className="h-4 w-4" />
|
|
@@ -339,7 +339,7 @@ export function DocumentEdit({
|
|
|
339
339
|
</button>
|
|
340
340
|
<button
|
|
341
341
|
onClick={() => setShowVersions(true)}
|
|
342
|
-
className="inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm text-[var(--muted-foreground)] hover:
|
|
342
|
+
className="hover:bg-muted inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
|
|
343
343
|
title="Version history"
|
|
344
344
|
>
|
|
345
345
|
<Clock className="h-4 w-4" />
|
|
@@ -347,7 +347,7 @@ export function DocumentEdit({
|
|
|
347
347
|
</button>
|
|
348
348
|
<button
|
|
349
349
|
onClick={handleDuplicate}
|
|
350
|
-
className="inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm text-[var(--muted-foreground)] hover:
|
|
350
|
+
className="hover:bg-muted inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
|
|
351
351
|
title="Duplicate this document"
|
|
352
352
|
>
|
|
353
353
|
<Copy className="h-4 w-4" />
|