@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
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* All Entries — a cross-form submissions inbox. Same token-only table, search,
|
|
5
5
|
* status pills (All / Unread / Starred / Archived), bulk actions, CSV export,
|
|
6
|
-
* pagination, and detail
|
|
7
|
-
* filter and a Form column so editors can triage every form from one
|
|
6
|
+
* pagination, and inline detail pane as the per-form Submissions view, plus a
|
|
7
|
+
* Form filter and a Form column so editors can triage every form from one
|
|
8
|
+
* place.
|
|
8
9
|
*/
|
|
9
10
|
import { useCallback, useEffect, useState } from 'react'
|
|
10
11
|
import { Download, Inbox, Search } from 'lucide-react'
|
|
@@ -22,9 +23,10 @@ import {
|
|
|
22
23
|
type FormSubmission,
|
|
23
24
|
} from '../lib/forms-service.js'
|
|
24
25
|
import { emitFormsChanged, onFormsChanged } from '../lib/forms-events.js'
|
|
25
|
-
import {
|
|
26
|
+
import { FormEntryDetailPane } from '../components/forms/FormEntryDetailPane.js'
|
|
26
27
|
import { EntriesTable } from '../components/forms/EntriesTable.js'
|
|
27
28
|
import { ConfirmDialog } from '../components/ui/ConfirmDialog.js'
|
|
29
|
+
import { InspectorPane, SplitPaneLayout, SplitPaneList } from '../components/ui/InspectorPane.js'
|
|
28
30
|
import {
|
|
29
31
|
FormsEmptyState,
|
|
30
32
|
FormsErrorState,
|
|
@@ -106,7 +108,6 @@ export function FormEntries({ onNavigate }: FormEntriesProps) {
|
|
|
106
108
|
const [page, setPage] = useState(1)
|
|
107
109
|
const [selected, setSelected] = useState<Set<string>>(new Set())
|
|
108
110
|
const [activeEntry, setActiveEntry] = useState<string | null>(null)
|
|
109
|
-
const [drawerOpen, setDrawerOpen] = useState(false)
|
|
110
111
|
const [confirmBulkDelete, setConfirmBulkDelete] = useState(false)
|
|
111
112
|
|
|
112
113
|
const scopedFormId = formFilter === 'all' ? undefined : formFilter
|
|
@@ -167,8 +168,8 @@ export function FormEntries({ onNavigate }: FormEntriesProps) {
|
|
|
167
168
|
}, [load, loadCounts])
|
|
168
169
|
|
|
169
170
|
const openEntry = useCallback((id: string) => {
|
|
170
|
-
|
|
171
|
-
|
|
171
|
+
// Toggle: clicking the open row again collapses the pane.
|
|
172
|
+
setActiveEntry((cur) => (cur === id ? null : id))
|
|
172
173
|
setEntries((cur) => cur.map((e) => (e.id === id ? { ...e, unread: false } : e)))
|
|
173
174
|
}, [])
|
|
174
175
|
|
|
@@ -372,68 +373,82 @@ export function FormEntries({ onNavigate }: FormEntriesProps) {
|
|
|
372
373
|
</div>
|
|
373
374
|
)}
|
|
374
375
|
|
|
375
|
-
{
|
|
376
|
-
|
|
377
|
-
)
|
|
378
|
-
<
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
selected={selected}
|
|
409
|
-
allSelected={allSelected}
|
|
410
|
-
onToggleSelectAll={toggleSelectAll}
|
|
411
|
-
onToggleSelect={toggleSelect}
|
|
412
|
-
onOpen={openEntry}
|
|
413
|
-
onToggleStar={toggleStar}
|
|
414
|
-
showForm
|
|
415
|
-
onOpenForm={(id) => onNavigate?.(`/forms/${id}/submissions`)}
|
|
416
|
-
ariaLabel="All entries"
|
|
417
|
-
footer={
|
|
418
|
-
<FormsPagination
|
|
419
|
-
page={page}
|
|
420
|
-
pageSize={PAGE_SIZE}
|
|
421
|
-
total={total}
|
|
422
|
-
onPageChange={setPage}
|
|
423
|
-
noun="submission"
|
|
424
|
-
label="Entries pagination"
|
|
376
|
+
{/* The page scrolls, so the split area takes a viewport-derived height
|
|
377
|
+
to keep the pane's body/footer independently scrollable. */}
|
|
378
|
+
<SplitPaneLayout className="h-[calc(100vh-340px)] min-h-[480px]">
|
|
379
|
+
<SplitPaneList>
|
|
380
|
+
{error ? (
|
|
381
|
+
<FormsErrorState message={error} onRetry={load} />
|
|
382
|
+
) : loading ? (
|
|
383
|
+
<FormsLoading label="Loading entries…" />
|
|
384
|
+
) : showEmpty ? (
|
|
385
|
+
<FormsEmptyState
|
|
386
|
+
icon={<Inbox className="h-6 w-6" aria-hidden />}
|
|
387
|
+
title={isFiltered ? 'No entries match your filters' : 'No entries yet'}
|
|
388
|
+
description={
|
|
389
|
+
isFiltered
|
|
390
|
+
? 'Try a different search term, form, or status filter.'
|
|
391
|
+
: 'Submissions across all your forms will appear here.'
|
|
392
|
+
}
|
|
393
|
+
action={
|
|
394
|
+
isFiltered ? (
|
|
395
|
+
<button
|
|
396
|
+
type="button"
|
|
397
|
+
onClick={() => {
|
|
398
|
+
setSearch('')
|
|
399
|
+
setFilter('all')
|
|
400
|
+
setFormFilter('all')
|
|
401
|
+
setPage(1)
|
|
402
|
+
}}
|
|
403
|
+
className="text-primary text-sm font-medium hover:underline"
|
|
404
|
+
>
|
|
405
|
+
Clear filters
|
|
406
|
+
</button>
|
|
407
|
+
) : undefined
|
|
408
|
+
}
|
|
425
409
|
/>
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
410
|
+
) : (
|
|
411
|
+
<EntriesTable
|
|
412
|
+
entries={entries}
|
|
413
|
+
selected={selected}
|
|
414
|
+
allSelected={allSelected}
|
|
415
|
+
onToggleSelectAll={toggleSelectAll}
|
|
416
|
+
onToggleSelect={toggleSelect}
|
|
417
|
+
onOpen={openEntry}
|
|
418
|
+
onToggleStar={toggleStar}
|
|
419
|
+
activeId={activeEntry}
|
|
420
|
+
showForm
|
|
421
|
+
onOpenForm={(id) => onNavigate?.(`/forms/${id}/submissions`)}
|
|
422
|
+
ariaLabel="All entries"
|
|
423
|
+
footer={
|
|
424
|
+
<FormsPagination
|
|
425
|
+
page={page}
|
|
426
|
+
pageSize={PAGE_SIZE}
|
|
427
|
+
total={total}
|
|
428
|
+
onPageChange={setPage}
|
|
429
|
+
noun="submission"
|
|
430
|
+
label="Entries pagination"
|
|
431
|
+
/>
|
|
432
|
+
}
|
|
433
|
+
/>
|
|
434
|
+
)}
|
|
435
|
+
</SplitPaneList>
|
|
429
436
|
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
+
<InspectorPane open={activeEntry !== null} width={400} aria-label="Entry details">
|
|
438
|
+
{activeEntry && (
|
|
439
|
+
<FormEntryDetailPane
|
|
440
|
+
key={activeEntry}
|
|
441
|
+
entryId={activeEntry}
|
|
442
|
+
onClose={() => setActiveEntry(null)}
|
|
443
|
+
onChanged={refresh}
|
|
444
|
+
onDeleted={() => {
|
|
445
|
+
setActiveEntry(null)
|
|
446
|
+
refresh()
|
|
447
|
+
}}
|
|
448
|
+
/>
|
|
449
|
+
)}
|
|
450
|
+
</InspectorPane>
|
|
451
|
+
</SplitPaneLayout>
|
|
437
452
|
|
|
438
453
|
<ConfirmDialog
|
|
439
454
|
open={confirmBulkDelete}
|
|
@@ -6,9 +6,9 @@
|
|
|
6
6
|
*
|
|
7
7
|
* Features: summary chips, search + status pills (All / Unread / Starred /
|
|
8
8
|
* Archived), row selection with bulk actions (mark read, star, archive,
|
|
9
|
-
* delete), per-row star toggle, CSV export, and
|
|
10
|
-
* row click (marking the entry read). Loading / empty /
|
|
11
|
-
* states are all handled.
|
|
9
|
+
* delete), per-row star toggle, CSV export, and an inline detail pane that
|
|
10
|
+
* opens on row click (marking the entry read). Loading / empty /
|
|
11
|
+
* filtered-empty / error states are all handled.
|
|
12
12
|
*/
|
|
13
13
|
import { useCallback, useEffect, useState } from 'react'
|
|
14
14
|
import { ArrowLeft, Download, Inbox, Search } from 'lucide-react'
|
|
@@ -25,9 +25,10 @@ import {
|
|
|
25
25
|
type FormSubmission,
|
|
26
26
|
} from '../lib/forms-service.js'
|
|
27
27
|
import { emitFormsChanged, onFormsChanged } from '../lib/forms-events.js'
|
|
28
|
-
import {
|
|
28
|
+
import { FormEntryDetailPane } from '../components/forms/FormEntryDetailPane.js'
|
|
29
29
|
import { EntriesTable } from '../components/forms/EntriesTable.js'
|
|
30
30
|
import { ConfirmDialog } from '../components/ui/ConfirmDialog.js'
|
|
31
|
+
import { InspectorPane, SplitPaneLayout, SplitPaneList } from '../components/ui/InspectorPane.js'
|
|
31
32
|
import {
|
|
32
33
|
FormsEmptyState,
|
|
33
34
|
FormsErrorState,
|
|
@@ -109,7 +110,6 @@ export function FormSubmissions({ formId, onNavigate }: FormSubmissionsProps) {
|
|
|
109
110
|
const [page, setPage] = useState(1)
|
|
110
111
|
const [selected, setSelected] = useState<Set<string>>(new Set())
|
|
111
112
|
const [activeEntry, setActiveEntry] = useState<string | null>(null)
|
|
112
|
-
const [drawerOpen, setDrawerOpen] = useState(false)
|
|
113
113
|
const [confirmBulkDelete, setConfirmBulkDelete] = useState(false)
|
|
114
114
|
|
|
115
115
|
// Form metadata for the title (loads once).
|
|
@@ -177,9 +177,9 @@ export function FormSubmissions({ formId, onNavigate }: FormSubmissionsProps) {
|
|
|
177
177
|
}, [load, loadCounts])
|
|
178
178
|
|
|
179
179
|
const openEntry = useCallback((id: string) => {
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
// Reflect read state immediately in the row; the
|
|
180
|
+
// Toggle: clicking the open row again collapses the pane.
|
|
181
|
+
setActiveEntry((cur) => (cur === id ? null : id))
|
|
182
|
+
// Reflect read state immediately in the row; the pane persists it.
|
|
183
183
|
setEntries((cur) => cur.map((e) => (e.id === id ? { ...e, unread: false } : e)))
|
|
184
184
|
}, [])
|
|
185
185
|
|
|
@@ -377,64 +377,78 @@ export function FormSubmissions({ formId, onNavigate }: FormSubmissionsProps) {
|
|
|
377
377
|
</div>
|
|
378
378
|
)}
|
|
379
379
|
|
|
380
|
-
{
|
|
381
|
-
|
|
382
|
-
)
|
|
383
|
-
<
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
selected={selected}
|
|
413
|
-
allSelected={allSelected}
|
|
414
|
-
onToggleSelectAll={toggleSelectAll}
|
|
415
|
-
onToggleSelect={toggleSelect}
|
|
416
|
-
onOpen={openEntry}
|
|
417
|
-
onToggleStar={toggleStar}
|
|
418
|
-
footer={
|
|
419
|
-
<FormsPagination
|
|
420
|
-
page={page}
|
|
421
|
-
pageSize={PAGE_SIZE}
|
|
422
|
-
total={total}
|
|
423
|
-
onPageChange={setPage}
|
|
424
|
-
noun="submission"
|
|
425
|
-
label="Submissions pagination"
|
|
380
|
+
{/* The page scrolls, so the split area takes a viewport-derived height
|
|
381
|
+
to keep the pane's body/footer independently scrollable. */}
|
|
382
|
+
<SplitPaneLayout className="h-[calc(100vh-340px)] min-h-[480px]">
|
|
383
|
+
<SplitPaneList>
|
|
384
|
+
{error ? (
|
|
385
|
+
<FormsErrorState message={error} onRetry={load} />
|
|
386
|
+
) : loading ? (
|
|
387
|
+
<FormsLoading label="Loading submissions…" />
|
|
388
|
+
) : showEmpty ? (
|
|
389
|
+
<FormsEmptyState
|
|
390
|
+
icon={<Inbox className="h-6 w-6" aria-hidden />}
|
|
391
|
+
title={isFiltered ? 'No submissions match your filters' : 'No submissions yet'}
|
|
392
|
+
description={
|
|
393
|
+
isFiltered
|
|
394
|
+
? 'Try a different search term or status filter.'
|
|
395
|
+
: 'Submissions will appear here once your form starts receiving entries.'
|
|
396
|
+
}
|
|
397
|
+
action={
|
|
398
|
+
isFiltered ? (
|
|
399
|
+
<button
|
|
400
|
+
type="button"
|
|
401
|
+
onClick={() => {
|
|
402
|
+
setSearch('')
|
|
403
|
+
setFilter('all')
|
|
404
|
+
setPage(1)
|
|
405
|
+
}}
|
|
406
|
+
className="text-primary text-sm font-medium hover:underline"
|
|
407
|
+
>
|
|
408
|
+
Clear filters
|
|
409
|
+
</button>
|
|
410
|
+
) : undefined
|
|
411
|
+
}
|
|
426
412
|
/>
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
413
|
+
) : (
|
|
414
|
+
<EntriesTable
|
|
415
|
+
entries={entries}
|
|
416
|
+
selected={selected}
|
|
417
|
+
allSelected={allSelected}
|
|
418
|
+
onToggleSelectAll={toggleSelectAll}
|
|
419
|
+
onToggleSelect={toggleSelect}
|
|
420
|
+
onOpen={openEntry}
|
|
421
|
+
onToggleStar={toggleStar}
|
|
422
|
+
activeId={activeEntry}
|
|
423
|
+
footer={
|
|
424
|
+
<FormsPagination
|
|
425
|
+
page={page}
|
|
426
|
+
pageSize={PAGE_SIZE}
|
|
427
|
+
total={total}
|
|
428
|
+
onPageChange={setPage}
|
|
429
|
+
noun="submission"
|
|
430
|
+
label="Submissions pagination"
|
|
431
|
+
/>
|
|
432
|
+
}
|
|
433
|
+
/>
|
|
434
|
+
)}
|
|
435
|
+
</SplitPaneList>
|
|
430
436
|
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
437
|
+
<InspectorPane open={activeEntry !== null} width={400} aria-label="Entry details">
|
|
438
|
+
{activeEntry && (
|
|
439
|
+
<FormEntryDetailPane
|
|
440
|
+
key={activeEntry}
|
|
441
|
+
entryId={activeEntry}
|
|
442
|
+
onClose={() => setActiveEntry(null)}
|
|
443
|
+
onChanged={refresh}
|
|
444
|
+
onDeleted={() => {
|
|
445
|
+
setActiveEntry(null)
|
|
446
|
+
refresh()
|
|
447
|
+
}}
|
|
448
|
+
/>
|
|
449
|
+
)}
|
|
450
|
+
</InspectorPane>
|
|
451
|
+
</SplitPaneLayout>
|
|
438
452
|
|
|
439
453
|
<ConfirmDialog
|
|
440
454
|
open={confirmBulkDelete}
|
package/src/views/Forms.tsx
CHANGED
|
@@ -166,8 +166,8 @@ export function Forms({ onNavigate }: FormsProps) {
|
|
|
166
166
|
<div className="space-y-5 p-6 pb-4">
|
|
167
167
|
<header className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
|
168
168
|
<div>
|
|
169
|
-
<h1 className="text-
|
|
170
|
-
<p className="
|
|
169
|
+
<h1 className="text-[var(--txt)]">Forms</h1>
|
|
170
|
+
<p className="mt-1 text-sm text-[var(--sub)]">
|
|
171
171
|
Manage forms and view incoming submissions
|
|
172
172
|
</p>
|
|
173
173
|
</div>
|
|
@@ -196,7 +196,7 @@ export function Forms({ onNavigate }: FormsProps) {
|
|
|
196
196
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
197
197
|
<div className="relative w-full sm:max-w-xs">
|
|
198
198
|
<Search
|
|
199
|
-
className="
|
|
199
|
+
className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-[var(--sub)]"
|
|
200
200
|
aria-hidden
|
|
201
201
|
/>
|
|
202
202
|
<input
|
|
@@ -208,7 +208,7 @@ export function Forms({ onNavigate }: FormsProps) {
|
|
|
208
208
|
setPage(1)
|
|
209
209
|
}}
|
|
210
210
|
aria-label="Search forms"
|
|
211
|
-
className="
|
|
211
|
+
className="bg-input-background w-full rounded-lg border border-[var(--bdr)] py-2 pr-3 pl-9 text-sm text-[var(--txt)] focus-visible:ring-2 focus-visible:ring-[var(--ring)] focus-visible:outline-none"
|
|
212
212
|
/>
|
|
213
213
|
</div>
|
|
214
214
|
<div className="flex gap-1" role="group" aria-label="Filter by status">
|
|
@@ -224,7 +224,7 @@ export function Forms({ onNavigate }: FormsProps) {
|
|
|
224
224
|
className={`rounded-full px-3 py-1.5 text-sm font-medium transition-colors ${
|
|
225
225
|
filter === f.id
|
|
226
226
|
? 'bg-primary text-primary-foreground'
|
|
227
|
-
: 'text-
|
|
227
|
+
: 'text-[var(--sub)] hover:bg-[var(--acc-l)] hover:text-[var(--txt)]'
|
|
228
228
|
}`}
|
|
229
229
|
>
|
|
230
230
|
{f.label}
|
|
@@ -326,11 +326,11 @@ function FormsTable({
|
|
|
326
326
|
footer?: ReactNode
|
|
327
327
|
}) {
|
|
328
328
|
return (
|
|
329
|
-
<div className="
|
|
329
|
+
<div className="rounded-lg border border-[var(--bdr)] bg-[var(--card)]">
|
|
330
330
|
<div className="overflow-x-auto">
|
|
331
331
|
<table className="w-full min-w-[760px] text-sm" aria-label="Forms">
|
|
332
332
|
<thead>
|
|
333
|
-
<tr className="border-
|
|
333
|
+
<tr className="border-b border-[var(--bdr)] text-left text-xs text-[var(--sub)]">
|
|
334
334
|
<th scope="col" className="px-4 py-2.5 font-medium">
|
|
335
335
|
Form
|
|
336
336
|
</th>
|
|
@@ -363,8 +363,8 @@ function FormsTable({
|
|
|
363
363
|
return (
|
|
364
364
|
<tr
|
|
365
365
|
key={form.id}
|
|
366
|
-
className={`border-
|
|
367
|
-
isSelected ? 'bg-[var(--acc-l)]' : 'hover:bg-
|
|
366
|
+
className={`border-b border-[var(--bdr)] transition-colors last:border-b-0 ${
|
|
367
|
+
isSelected ? 'bg-[var(--acc-l)]' : 'hover:bg-[var(--acc-l)]'
|
|
368
368
|
} ${muted ? 'opacity-60' : ''}`}
|
|
369
369
|
>
|
|
370
370
|
<td className="px-4 py-3">
|
|
@@ -373,10 +373,10 @@ function FormsTable({
|
|
|
373
373
|
onClick={() => onOpenForm(form.id)}
|
|
374
374
|
className="block max-w-xs text-left"
|
|
375
375
|
>
|
|
376
|
-
<span className="
|
|
376
|
+
<span className="block truncate font-medium text-[var(--txt)] hover:underline">
|
|
377
377
|
{form.name}
|
|
378
378
|
</span>
|
|
379
|
-
<span className="
|
|
379
|
+
<span className="block truncate text-xs text-[var(--sub)]">
|
|
380
380
|
/{form.slug}
|
|
381
381
|
{form.description ? ` · ${form.description}` : ''}
|
|
382
382
|
</span>
|
|
@@ -387,7 +387,7 @@ function FormsTable({
|
|
|
387
387
|
{form.fields.slice(0, 4).map((field) => (
|
|
388
388
|
<FieldTypeBadge key={field.id} type={field.type} />
|
|
389
389
|
))}
|
|
390
|
-
<span className="text-
|
|
390
|
+
<span className="text-xs text-[var(--sub)]">{fieldCount} fields</span>
|
|
391
391
|
</div>
|
|
392
392
|
</td>
|
|
393
393
|
<td className="px-4 py-3">
|
|
@@ -397,15 +397,15 @@ function FormsTable({
|
|
|
397
397
|
<button
|
|
398
398
|
type="button"
|
|
399
399
|
onClick={() => onOpenEntries(form.id)}
|
|
400
|
-
className="
|
|
400
|
+
className="inline-flex items-center gap-1.5 rounded font-medium text-[var(--txt)] tabular-nums hover:text-[var(--acc)] focus-visible:ring-2 focus-visible:ring-[var(--ring)] focus-visible:outline-none"
|
|
401
401
|
aria-label={`View ${form.totalEntries ?? 0} entries for ${form.name}`}
|
|
402
402
|
>
|
|
403
403
|
{form.totalEntries ?? 0}
|
|
404
404
|
<UnreadBadge count={unread} />
|
|
405
|
-
<ChevronRight className="
|
|
405
|
+
<ChevronRight className="h-3.5 w-3.5 text-[var(--sub)]" aria-hidden />
|
|
406
406
|
</button>
|
|
407
407
|
</td>
|
|
408
|
-
<td className="
|
|
408
|
+
<td className="px-4 py-3 whitespace-nowrap text-[var(--sub)]">
|
|
409
409
|
{formatRelative(form.lastEntryAt)}
|
|
410
410
|
</td>
|
|
411
411
|
<td className="px-4 py-3 text-center">
|
|
@@ -421,7 +421,7 @@ function FormsTable({
|
|
|
421
421
|
type="button"
|
|
422
422
|
onClick={() => onOpenSchema(form)}
|
|
423
423
|
aria-expanded={isSelected}
|
|
424
|
-
className="
|
|
424
|
+
className="inline-flex items-center gap-1.5 rounded-md border border-[var(--bdr)] px-2.5 py-1.5 text-xs font-medium text-[var(--txt)] transition-colors hover:bg-[var(--acc-l)] focus-visible:ring-2 focus-visible:ring-[var(--ring)] focus-visible:outline-none"
|
|
425
425
|
>
|
|
426
426
|
<Settings2 className="h-3.5 w-3.5" aria-hidden />
|
|
427
427
|
Schema
|
|
@@ -24,8 +24,9 @@ import {
|
|
|
24
24
|
FolderInput,
|
|
25
25
|
GripVertical,
|
|
26
26
|
} from 'lucide-react'
|
|
27
|
-
import { useState, useMemo, useRef, useCallback } from 'react'
|
|
27
|
+
import { useState, useMemo, useRef, useCallback, useEffect } from 'react'
|
|
28
28
|
import { toast } from 'sonner'
|
|
29
|
+
import { parseRouteQuery } from '../router/index.js'
|
|
29
30
|
import { sortByRelevance, type SortConfig, toggleSort } from '../lib/search.js'
|
|
30
31
|
import { useApiData } from '../lib/useApiData.js'
|
|
31
32
|
import { cmsApi } from '../lib/api.js'
|
|
@@ -97,6 +98,9 @@ type MediaSortKey = 'name' | 'type' | 'size' | 'date'
|
|
|
97
98
|
|
|
98
99
|
export interface MediaBrowserProps {
|
|
99
100
|
onNavigate?: (path: string) => void
|
|
101
|
+
/** Router path including the query string. `?selected=<mediaId>` (written by
|
|
102
|
+
* the command palette) opens that item's detail panel once the list loads. */
|
|
103
|
+
currentPath?: string
|
|
100
104
|
}
|
|
101
105
|
|
|
102
106
|
function buildMediaApiUrl(folderSel: FolderSelection): string {
|
|
@@ -109,7 +113,7 @@ function buildMediaApiUrl(folderSel: FolderSelection): string {
|
|
|
109
113
|
return `${base}&folderId=${folderSel.folderId}`
|
|
110
114
|
}
|
|
111
115
|
|
|
112
|
-
export function MediaBrowser({ onNavigate }: MediaBrowserProps) {
|
|
116
|
+
export function MediaBrowser({ onNavigate, currentPath }: MediaBrowserProps) {
|
|
113
117
|
const [folderSel, setFolderSel] = useState<FolderSelection>({ type: 'smart', smart: 'all' })
|
|
114
118
|
const [sidebarOpen, setSidebarOpen] = useState(true)
|
|
115
119
|
|
|
@@ -144,7 +148,7 @@ export function MediaBrowser({ onNavigate }: MediaBrowserProps) {
|
|
|
144
148
|
const [uploading, setUploading] = useState(false)
|
|
145
149
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
|
146
150
|
|
|
147
|
-
const mediaItems = data?.data ?? data?.items ?? []
|
|
151
|
+
const mediaItems = useMemo(() => data?.data ?? data?.items ?? [], [data])
|
|
148
152
|
|
|
149
153
|
const filteredAndSorted = useMemo(() => {
|
|
150
154
|
let results = mediaItems.filter((item) => {
|
|
@@ -185,7 +189,7 @@ export function MediaBrowser({ onNavigate }: MediaBrowserProps) {
|
|
|
185
189
|
return { total: mediaItems.length, images, videos, documents, bytes }
|
|
186
190
|
}, [mediaItems])
|
|
187
191
|
|
|
188
|
-
const openDetail = (item: MediaItem) => {
|
|
192
|
+
const openDetail = useCallback((item: MediaItem) => {
|
|
189
193
|
setActiveItem(item)
|
|
190
194
|
setEditAlt(item.altTag ?? '')
|
|
191
195
|
setEditTitle(item.title ?? '')
|
|
@@ -193,7 +197,26 @@ export function MediaBrowser({ onNavigate }: MediaBrowserProps) {
|
|
|
193
197
|
setEditCaption(item.caption ?? '')
|
|
194
198
|
setEditDescription(item.description ?? '')
|
|
195
199
|
setEditFocusKeyword(item.focusKeyword ?? '')
|
|
196
|
-
}
|
|
200
|
+
}, [])
|
|
201
|
+
|
|
202
|
+
// Deep link from the command palette (`/media?selected=<id>`): open the
|
|
203
|
+
// item's detail panel once the library loads. Each query value is handled
|
|
204
|
+
// once, so closing the panel doesn't get overridden on refetch; unknown
|
|
205
|
+
// ids are ignored.
|
|
206
|
+
const selectedMediaId = useMemo(() => {
|
|
207
|
+
const source = currentPath ?? (typeof window !== 'undefined' ? window.location.search : '')
|
|
208
|
+
return parseRouteQuery(source).get('selected')
|
|
209
|
+
}, [currentPath])
|
|
210
|
+
const handledSelectedRef = useRef<string | null>(null)
|
|
211
|
+
useEffect(() => {
|
|
212
|
+
if (!selectedMediaId || loading) return
|
|
213
|
+
if (handledSelectedRef.current === selectedMediaId) return
|
|
214
|
+
const item = mediaItems.find((m) => String(m.id) === selectedMediaId)
|
|
215
|
+
if (!item) return
|
|
216
|
+
handledSelectedRef.current = selectedMediaId
|
|
217
|
+
setActiveTab('library')
|
|
218
|
+
openDetail(item)
|
|
219
|
+
}, [selectedMediaId, loading, mediaItems, openDetail])
|
|
197
220
|
|
|
198
221
|
// Count of items missing alt OR title — drives the SEO Audit tab badge.
|
|
199
222
|
const auditAttentionCount = useMemo(
|
|
@@ -546,7 +569,12 @@ export function MediaBrowser({ onNavigate }: MediaBrowserProps) {
|
|
|
546
569
|
</div>
|
|
547
570
|
|
|
548
571
|
{activeTab === 'audit' ? (
|
|
549
|
-
<MediaSeoAudit
|
|
572
|
+
<MediaSeoAudit
|
|
573
|
+
items={mediaItems}
|
|
574
|
+
onEdit={handleAuditEdit}
|
|
575
|
+
error={error}
|
|
576
|
+
onRetry={refetch}
|
|
577
|
+
/>
|
|
550
578
|
) : (
|
|
551
579
|
<div className="flex min-h-0 flex-1 gap-4 overflow-hidden">
|
|
552
580
|
{sidebarOpen && (
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { Check, FileImage, ImageIcon, SquarePen, TriangleAlert, X } from 'lucide-react'
|
|
4
4
|
import { useMemo, useState } from 'react'
|
|
5
|
+
import type { KeyboardEvent } from 'react'
|
|
5
6
|
import { FilterPill } from '../components/ui/FilterPill.js'
|
|
6
7
|
|
|
7
8
|
/**
|
|
@@ -27,6 +28,9 @@ export interface MediaSeoAuditProps {
|
|
|
27
28
|
items: MediaSeoAuditItem[]
|
|
28
29
|
/** Fired when a row / Edit button is activated — opens the item in the Library tab. */
|
|
29
30
|
onEdit: (item: MediaSeoAuditItem) => void
|
|
31
|
+
/** Media fetch error — renders a retryable error state instead of an empty audit. */
|
|
32
|
+
error?: string | null
|
|
33
|
+
onRetry?: () => void
|
|
30
34
|
}
|
|
31
35
|
|
|
32
36
|
type AuditFilter = 'all' | 'alt' | 'title' | 'poor'
|
|
@@ -144,7 +148,7 @@ function StatCard({
|
|
|
144
148
|
)
|
|
145
149
|
}
|
|
146
150
|
|
|
147
|
-
export function MediaSeoAudit({ items, onEdit }: MediaSeoAuditProps) {
|
|
151
|
+
export function MediaSeoAudit({ items, onEdit, error, onRetry }: MediaSeoAuditProps) {
|
|
148
152
|
const [filter, setFilter] = useState<AuditFilter>('all')
|
|
149
153
|
|
|
150
154
|
const stats = useMemo(() => {
|
|
@@ -174,6 +178,30 @@ export function MediaSeoAudit({ items, onEdit }: MediaSeoAuditProps) {
|
|
|
174
178
|
}
|
|
175
179
|
}, [items, filter])
|
|
176
180
|
|
|
181
|
+
if (error && items.length === 0) {
|
|
182
|
+
return (
|
|
183
|
+
<div className="min-h-0 flex-1 overflow-y-auto">
|
|
184
|
+
<div
|
|
185
|
+
role="alert"
|
|
186
|
+
className="flex flex-col items-center justify-center rounded-[10px] border border-[var(--bdr)] bg-[var(--card)] px-4 py-14 text-center"
|
|
187
|
+
>
|
|
188
|
+
<TriangleAlert size={24} className="mb-3 text-[var(--err)]" aria-hidden />
|
|
189
|
+
<p className="text-sm font-medium text-[var(--txt)]">Couldn't load media</p>
|
|
190
|
+
<p className="mt-1 max-w-sm text-xs text-[var(--sub)]">{error}</p>
|
|
191
|
+
{onRetry && (
|
|
192
|
+
<button
|
|
193
|
+
type="button"
|
|
194
|
+
onClick={onRetry}
|
|
195
|
+
className="mt-4 rounded-[7px] border border-[var(--bdr)] bg-[var(--card)] px-3.5 py-1.5 text-[12.5px] font-medium text-[var(--txt)] transition-colors hover:border-[var(--acc)] hover:text-[var(--acc)] focus-visible:ring-2 focus-visible:ring-[var(--ring)] focus-visible:outline-none"
|
|
196
|
+
>
|
|
197
|
+
Retry
|
|
198
|
+
</button>
|
|
199
|
+
)}
|
|
200
|
+
</div>
|
|
201
|
+
</div>
|
|
202
|
+
)
|
|
203
|
+
}
|
|
204
|
+
|
|
177
205
|
return (
|
|
178
206
|
<div className="min-h-0 flex-1 overflow-y-auto">
|
|
179
207
|
<div className="mb-[18px] grid grid-cols-2 gap-2.5 lg:grid-cols-4">
|
|
@@ -253,8 +281,17 @@ export function MediaSeoAudit({ items, onEdit }: MediaSeoAuditProps) {
|
|
|
253
281
|
return (
|
|
254
282
|
<tr
|
|
255
283
|
key={item.id}
|
|
284
|
+
tabIndex={0}
|
|
256
285
|
onClick={() => onEdit(item)}
|
|
257
|
-
|
|
286
|
+
onKeyDown={(e: KeyboardEvent<HTMLTableRowElement>) => {
|
|
287
|
+
if (e.target !== e.currentTarget) return
|
|
288
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
289
|
+
e.preventDefault()
|
|
290
|
+
onEdit(item)
|
|
291
|
+
}
|
|
292
|
+
}}
|
|
293
|
+
aria-label={`Edit SEO metadata for ${item.name}`}
|
|
294
|
+
className="cursor-pointer border-b border-[var(--bdr)] transition-colors last:border-b-0 hover:bg-[var(--acc-l)] focus-visible:bg-[var(--acc-l)] focus-visible:ring-2 focus-visible:ring-[var(--ring)] focus-visible:outline-none focus-visible:ring-inset"
|
|
258
295
|
>
|
|
259
296
|
<td className="px-3 py-[9px] align-middle">
|
|
260
297
|
<div className="flex items-center gap-2.5">
|