@prenta/admin 1.17.17 → 1.17.18
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 +6 -0
- package/dist/__tests__/components/virtual-window.test.d.ts +2 -0
- package/dist/__tests__/components/virtual-window.test.d.ts.map +1 -0
- package/dist/__tests__/components/virtual-window.test.js +22 -0
- package/dist/__tests__/components/virtual-window.test.js.map +1 -0
- package/dist/components/virtual-window.d.ts +28 -0
- package/dist/components/virtual-window.d.ts.map +1 -0
- package/dist/components/virtual-window.js +62 -0
- package/dist/components/virtual-window.js.map +1 -0
- package/dist/prenta-admin.css +1 -1
- package/dist/views/CollectionList.d.ts.map +1 -1
- package/dist/views/CollectionList.js +25 -14
- package/dist/views/CollectionList.js.map +1 -1
- package/dist/views/MediaBrowser.d.ts.map +1 -1
- package/dist/views/MediaBrowser.js +80 -23
- package/dist/views/MediaBrowser.js.map +1 -1
- package/dist/views/Pages/PagesListView.d.ts.map +1 -1
- package/dist/views/Pages/PagesListView.js +35 -2
- package/dist/views/Pages/PagesListView.js.map +1 -1
- package/dist/views/Posts/PostsListView.d.ts.map +1 -1
- package/dist/views/Posts/PostsListView.js +14 -2
- package/dist/views/Posts/PostsListView.js.map +1 -1
- package/package.json +6 -5
- package/src/__tests__/components/virtual-window.test.tsx +29 -0
- package/src/components/virtual-window.tsx +81 -0
- package/src/views/CollectionList.tsx +14 -1
- package/src/views/MediaBrowser.tsx +155 -90
- package/src/views/Pages/PagesListView.tsx +67 -27
- package/src/views/Posts/PostsListView.tsx +29 -12
|
@@ -14,6 +14,7 @@ import { useState, useMemo, useCallback, useEffect, useRef } from 'react'
|
|
|
14
14
|
import { toast } from 'sonner'
|
|
15
15
|
import { cmsApi } from '../lib/api.js'
|
|
16
16
|
import { useApiData } from '../lib/useApiData.js'
|
|
17
|
+
import { useVirtualWindow, VirtualTablePadding } from '../components/virtual-window.js'
|
|
17
18
|
import { editPathForDoc, newPathForCollection } from '../lib/collection-routes.js'
|
|
18
19
|
import { InspectorPane, SplitPaneLayout, SplitPaneList } from '../components/ui/InspectorPane.js'
|
|
19
20
|
import { EntryDetailPane } from '../components/collections/EntryDetailPane.js'
|
|
@@ -254,6 +255,13 @@ export function CollectionList({ collectionSlug, config, onNavigate }: Collectio
|
|
|
254
255
|
nextCursor?: string | null
|
|
255
256
|
}>(endpoint)
|
|
256
257
|
const docs = data?.docs ?? []
|
|
258
|
+
const listScrollRef = useRef<HTMLDivElement>(null)
|
|
259
|
+
const listWindow = useVirtualWindow({
|
|
260
|
+
count: docs.length,
|
|
261
|
+
scrollRef: listScrollRef,
|
|
262
|
+
estimateSize: 52,
|
|
263
|
+
})
|
|
264
|
+
const listColumns = 5 + facets.length
|
|
257
265
|
const total = data?.total ?? 0
|
|
258
266
|
const totalPages = Math.max(1, Math.ceil(total / LIST_PAGE_SIZE))
|
|
259
267
|
const pageNumber = priorCursors.length + 1
|
|
@@ -543,6 +551,7 @@ export function CollectionList({ collectionSlug, config, onNavigate }: Collectio
|
|
|
543
551
|
<SplitPaneLayout className="min-h-0">
|
|
544
552
|
<SplitPaneList>
|
|
545
553
|
<div
|
|
554
|
+
ref={listScrollRef}
|
|
546
555
|
className="h-full min-h-0 overflow-auto rounded-lg border"
|
|
547
556
|
style={{ borderColor: 'var(--prenta-border, #d1d5db)' }}
|
|
548
557
|
>
|
|
@@ -590,7 +599,10 @@ export function CollectionList({ collectionSlug, config, onNavigate }: Collectio
|
|
|
590
599
|
</tr>
|
|
591
600
|
</thead>
|
|
592
601
|
<tbody>
|
|
593
|
-
{
|
|
602
|
+
<VirtualTablePadding height={listWindow.paddingTop} columns={listColumns} />
|
|
603
|
+
{listWindow.rows.map((row) => {
|
|
604
|
+
const doc = docs[row.index]
|
|
605
|
+
if (!doc) return null
|
|
594
606
|
const isDetail = detailId === String(doc.id)
|
|
595
607
|
return (
|
|
596
608
|
<tr
|
|
@@ -669,6 +681,7 @@ export function CollectionList({ collectionSlug, config, onNavigate }: Collectio
|
|
|
669
681
|
</tr>
|
|
670
682
|
)
|
|
671
683
|
})}
|
|
684
|
+
<VirtualTablePadding height={listWindow.paddingBottom} columns={listColumns} />
|
|
672
685
|
</tbody>
|
|
673
686
|
</table>
|
|
674
687
|
</div>
|
|
@@ -30,6 +30,7 @@ import { parseRouteQuery } from '../router/index.js'
|
|
|
30
30
|
import { sortByRelevance, type SortConfig, toggleSort } from '../lib/search.js'
|
|
31
31
|
import { useApiData } from '../lib/useApiData.js'
|
|
32
32
|
import { cmsApi } from '../lib/api.js'
|
|
33
|
+
import { useVirtualWindow, VirtualTablePadding } from '../components/virtual-window.js'
|
|
33
34
|
import { FolderTree, type FolderSelection } from '../components/FolderTree.js'
|
|
34
35
|
import { Button } from '../components/ui/Button.js'
|
|
35
36
|
import { StatusBadge } from '../components/ui/StatusBadge.js'
|
|
@@ -113,6 +114,32 @@ export interface MediaBrowserProps {
|
|
|
113
114
|
currentPath?: string
|
|
114
115
|
}
|
|
115
116
|
|
|
117
|
+
function gridColumnCount(width: number, panelOpen: boolean): number {
|
|
118
|
+
if (panelOpen) {
|
|
119
|
+
if (width >= 1024) return 4
|
|
120
|
+
if (width >= 768) return 3
|
|
121
|
+
return 2
|
|
122
|
+
}
|
|
123
|
+
if (width >= 1536) return 8
|
|
124
|
+
if (width >= 1280) return 6
|
|
125
|
+
if (width >= 1024) return 5
|
|
126
|
+
if (width >= 768) return 4
|
|
127
|
+
if (width >= 640) return 3
|
|
128
|
+
return 2
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function useGridColumns(panelOpen: boolean): number {
|
|
132
|
+
const [width, setWidth] = useState(() =>
|
|
133
|
+
typeof window === 'undefined' ? 1280 : window.innerWidth,
|
|
134
|
+
)
|
|
135
|
+
useEffect(() => {
|
|
136
|
+
const onResize = () => setWidth(window.innerWidth)
|
|
137
|
+
window.addEventListener('resize', onResize)
|
|
138
|
+
return () => window.removeEventListener('resize', onResize)
|
|
139
|
+
}, [])
|
|
140
|
+
return gridColumnCount(width, panelOpen)
|
|
141
|
+
}
|
|
142
|
+
|
|
116
143
|
function buildMediaApiUrl(folderSel: FolderSelection): string {
|
|
117
144
|
const base = '/media?pageSize=100'
|
|
118
145
|
if (folderSel.type === 'smart') {
|
|
@@ -184,6 +211,22 @@ export function MediaBrowser({ onNavigate, currentPath }: MediaBrowserProps) {
|
|
|
184
211
|
return results
|
|
185
212
|
}, [mediaItems, searchQuery, filterType, sortConfig])
|
|
186
213
|
|
|
214
|
+
const gridColumns = useGridColumns(activeItem !== null)
|
|
215
|
+
const gridScrollRef = useRef<HTMLDivElement>(null)
|
|
216
|
+
const listScrollRef = useRef<HTMLDivElement>(null)
|
|
217
|
+
const gridRowCount = gridColumns > 0 ? Math.ceil(filteredAndSorted.length / gridColumns) : 0
|
|
218
|
+
const gridWindow = useVirtualWindow({
|
|
219
|
+
count: gridRowCount,
|
|
220
|
+
scrollRef: gridScrollRef,
|
|
221
|
+
estimateSize: 220,
|
|
222
|
+
overscan: 3,
|
|
223
|
+
})
|
|
224
|
+
const listWindow = useVirtualWindow({
|
|
225
|
+
count: filteredAndSorted.length,
|
|
226
|
+
scrollRef: listScrollRef,
|
|
227
|
+
estimateSize: 64,
|
|
228
|
+
})
|
|
229
|
+
|
|
187
230
|
// Header stat pills (design `.mc-stats`): counts over the current folder
|
|
188
231
|
// scope, before search/type filters, so the numbers describe the library.
|
|
189
232
|
const mediaStats = useMemo(() => {
|
|
@@ -752,101 +795,119 @@ export function MediaBrowser({ onNavigate, currentPath }: MediaBrowserProps) {
|
|
|
752
795
|
className={`border-border bg-card overflow-hidden rounded-lg border transition-all duration-200 ${panelOpen ? 'min-w-0 flex-1' : 'w-full'}`}
|
|
753
796
|
>
|
|
754
797
|
{viewMode === 'grid' ? (
|
|
755
|
-
<div
|
|
756
|
-
className={
|
|
757
|
-
|
|
758
|
-
? 'grid-cols-2 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4'
|
|
759
|
-
: 'grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-8'
|
|
760
|
-
}`}
|
|
761
|
-
>
|
|
762
|
-
{filteredAndSorted.map((item) => {
|
|
763
|
-
const isActive = activeItem?.id === item.id
|
|
764
|
-
const hasIssues =
|
|
765
|
-
!hasAccessibleAlt(item) || !item.title || item.usedOn?.length === 0
|
|
766
|
-
const typeLabel = (
|
|
767
|
-
item.name.includes('.')
|
|
768
|
-
? item.name.split('.').pop()!
|
|
769
|
-
: item.format || item.type || ''
|
|
770
|
-
)
|
|
771
|
-
.replace(/^.*\//, '')
|
|
772
|
-
.toUpperCase()
|
|
773
|
-
return (
|
|
798
|
+
<div ref={gridScrollRef} className="h-full overflow-y-auto p-2 sm:p-3">
|
|
799
|
+
<div className="relative" style={{ height: gridRowCount * 220 }}>
|
|
800
|
+
{gridWindow.rows.map((virtualRow) => (
|
|
774
801
|
<div
|
|
775
|
-
key={
|
|
776
|
-
className=
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
onDragStart={(e) => handleDragStart(e, item.id)}
|
|
802
|
+
key={virtualRow.key}
|
|
803
|
+
className="grid gap-3 sm:gap-4"
|
|
804
|
+
style={{
|
|
805
|
+
position: 'absolute',
|
|
806
|
+
top: 0,
|
|
807
|
+
left: 0,
|
|
808
|
+
right: 0,
|
|
809
|
+
transform: `translateY(${virtualRow.start}px)`,
|
|
810
|
+
gridTemplateColumns: `repeat(${gridColumns}, minmax(0, 1fr))`,
|
|
811
|
+
}}
|
|
786
812
|
>
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
813
|
+
{filteredAndSorted
|
|
814
|
+
.slice(
|
|
815
|
+
virtualRow.index * gridColumns,
|
|
816
|
+
virtualRow.index * gridColumns + gridColumns,
|
|
817
|
+
)
|
|
818
|
+
.map((item) => {
|
|
819
|
+
const isActive = activeItem?.id === item.id
|
|
820
|
+
const hasIssues =
|
|
821
|
+
!hasAccessibleAlt(item) ||
|
|
822
|
+
!item.title ||
|
|
823
|
+
item.usedOn?.length === 0
|
|
824
|
+
const typeLabel = (
|
|
825
|
+
item.name.includes('.')
|
|
826
|
+
? item.name.split('.').pop()!
|
|
827
|
+
: item.format || item.type || ''
|
|
828
|
+
)
|
|
829
|
+
.replace(/^.*\//, '')
|
|
830
|
+
.toUpperCase()
|
|
831
|
+
return (
|
|
832
|
+
<div
|
|
833
|
+
key={item.id}
|
|
834
|
+
className={`group relative flex cursor-pointer flex-col overflow-hidden rounded-lg border-2 transition-all ${
|
|
835
|
+
isActive
|
|
836
|
+
? 'border-primary ring-primary/30 ring-2'
|
|
837
|
+
: selectedMedia.includes(item.id)
|
|
838
|
+
? 'border-primary/60 ring-primary/20 ring-1'
|
|
839
|
+
: 'border-border hover:border-input'
|
|
840
|
+
}`}
|
|
841
|
+
onClick={() => openDetail(item)}
|
|
842
|
+
draggable
|
|
843
|
+
onDragStart={(e) => handleDragStart(e, item.id)}
|
|
844
|
+
>
|
|
845
|
+
<div className="relative aspect-square w-full overflow-hidden">
|
|
846
|
+
<div className="bg-muted flex h-full w-full items-center justify-center">
|
|
847
|
+
<MediaPreview item={item} />
|
|
848
|
+
</div>
|
|
849
|
+
{hasIssues && (
|
|
850
|
+
<div
|
|
851
|
+
className="bg-warning absolute top-1.5 left-1.5 flex h-5 w-5 items-center justify-center rounded-full"
|
|
852
|
+
title="Needs attention"
|
|
853
|
+
>
|
|
854
|
+
<AlertTriangle className="text-warning-foreground h-3 w-3" />
|
|
855
|
+
</div>
|
|
856
|
+
)}
|
|
857
|
+
<div
|
|
858
|
+
className="absolute top-1.5 right-1.5 opacity-0 transition-opacity group-hover:opacity-100"
|
|
859
|
+
onClick={(e) => handleCheckbox(e, item.id)}
|
|
860
|
+
>
|
|
861
|
+
<div
|
|
862
|
+
className={`flex h-5 w-5 items-center justify-center rounded border-2 transition-colors ${
|
|
863
|
+
selectedMedia.includes(item.id)
|
|
864
|
+
? 'border-primary bg-primary'
|
|
865
|
+
: 'border-input bg-card/80'
|
|
866
|
+
}`}
|
|
867
|
+
>
|
|
868
|
+
{selectedMedia.includes(item.id) && (
|
|
869
|
+
<svg
|
|
870
|
+
className="text-primary-foreground h-3 w-3"
|
|
871
|
+
fill="none"
|
|
872
|
+
viewBox="0 0 12 12"
|
|
873
|
+
>
|
|
874
|
+
<path
|
|
875
|
+
d="M10 3L4.5 8.5L2 6"
|
|
876
|
+
stroke="currentColor"
|
|
877
|
+
strokeWidth="2"
|
|
878
|
+
strokeLinecap="round"
|
|
879
|
+
strokeLinejoin="round"
|
|
880
|
+
/>
|
|
881
|
+
</svg>
|
|
882
|
+
)}
|
|
883
|
+
</div>
|
|
884
|
+
</div>
|
|
885
|
+
</div>
|
|
886
|
+
<div className="border-border bg-card flex items-center gap-2 border-t px-2 py-1.5">
|
|
887
|
+
<span
|
|
888
|
+
className={`mt-0.5 h-2 w-2 shrink-0 self-start rounded-full ${
|
|
889
|
+
hasIssues ? 'bg-warning' : 'bg-success'
|
|
890
|
+
}`}
|
|
891
|
+
title={hasIssues ? 'Needs attention' : 'SEO complete'}
|
|
822
892
|
/>
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
<div className="min-w-0 flex-1">
|
|
836
|
-
<p className="text-foreground truncate text-xs font-medium">
|
|
837
|
-
{item.name}
|
|
838
|
-
</p>
|
|
839
|
-
<p className="text-muted-foreground truncate text-xs">
|
|
840
|
-
{[typeLabel, item.size].filter(Boolean).join(' · ')}
|
|
841
|
-
</p>
|
|
842
|
-
</div>
|
|
843
|
-
</div>
|
|
893
|
+
<div className="min-w-0 flex-1">
|
|
894
|
+
<p className="text-foreground truncate text-xs font-medium">
|
|
895
|
+
{item.name}
|
|
896
|
+
</p>
|
|
897
|
+
<p className="text-muted-foreground truncate text-xs">
|
|
898
|
+
{[typeLabel, item.size].filter(Boolean).join(' · ')}
|
|
899
|
+
</p>
|
|
900
|
+
</div>
|
|
901
|
+
</div>
|
|
902
|
+
</div>
|
|
903
|
+
)
|
|
904
|
+
})}
|
|
844
905
|
</div>
|
|
845
|
-
)
|
|
846
|
-
|
|
906
|
+
))}
|
|
907
|
+
</div>
|
|
847
908
|
</div>
|
|
848
909
|
) : (
|
|
849
|
-
<div className="h-full overflow-auto">
|
|
910
|
+
<div ref={listScrollRef} className="h-full overflow-auto">
|
|
850
911
|
{/* min-w keeps the list columns readable; narrow viewports scroll sideways. */}
|
|
851
912
|
<table className="w-full min-w-[640px]">
|
|
852
913
|
<thead className="border-border bg-muted sticky top-0 border-b">
|
|
@@ -881,7 +942,10 @@ export function MediaBrowser({ onNavigate, currentPath }: MediaBrowserProps) {
|
|
|
881
942
|
</tr>
|
|
882
943
|
</thead>
|
|
883
944
|
<tbody className="divide-border divide-y">
|
|
884
|
-
{
|
|
945
|
+
<VirtualTablePadding height={listWindow.paddingTop} columns={7} />
|
|
946
|
+
{listWindow.rows.map((row) => {
|
|
947
|
+
const item = filteredAndSorted[row.index]
|
|
948
|
+
if (!item) return null
|
|
885
949
|
const isActive = activeItem?.id === item.id
|
|
886
950
|
const hasIssues =
|
|
887
951
|
!hasAccessibleAlt(item) || !item.title || item.usedOn?.length === 0
|
|
@@ -938,6 +1002,7 @@ export function MediaBrowser({ onNavigate, currentPath }: MediaBrowserProps) {
|
|
|
938
1002
|
</tr>
|
|
939
1003
|
)
|
|
940
1004
|
})}
|
|
1005
|
+
<VirtualTablePadding height={listWindow.paddingBottom} columns={7} />
|
|
941
1006
|
</tbody>
|
|
942
1007
|
</table>
|
|
943
1008
|
</div>
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
X,
|
|
20
20
|
} from 'lucide-react'
|
|
21
21
|
import { toast } from 'sonner'
|
|
22
|
+
import { useVirtualWindow, VirtualTablePadding } from '../../components/virtual-window.js'
|
|
22
23
|
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
|
|
23
24
|
import { adminPortalContainer } from '../../lib/portal-container.js'
|
|
24
25
|
import {
|
|
@@ -206,6 +207,31 @@ export function PagesListView({ onNavigate }: PagesListViewProps) {
|
|
|
206
207
|
}, [tags])
|
|
207
208
|
|
|
208
209
|
const displayGroups = useMemo(() => groupPages(pages, groupBy, tags), [pages, groupBy, tags])
|
|
210
|
+
const tableRows = useMemo(() => {
|
|
211
|
+
const rows: Array<
|
|
212
|
+
| { kind: 'header'; key: string; label: string; color: string | null; count: number }
|
|
213
|
+
| { kind: 'page'; page: Page }
|
|
214
|
+
> = []
|
|
215
|
+
for (const group of displayGroups) {
|
|
216
|
+
if (grouped && group.pages.length > 0) {
|
|
217
|
+
rows.push({
|
|
218
|
+
kind: 'header',
|
|
219
|
+
key: group.key,
|
|
220
|
+
label: group.label,
|
|
221
|
+
color: group.color,
|
|
222
|
+
count: group.pages.length,
|
|
223
|
+
})
|
|
224
|
+
}
|
|
225
|
+
for (const pageRow of group.pages) rows.push({ kind: 'page', page: pageRow })
|
|
226
|
+
}
|
|
227
|
+
return rows
|
|
228
|
+
}, [displayGroups, grouped])
|
|
229
|
+
const listScrollRef = useRef<HTMLDivElement>(null)
|
|
230
|
+
const listWindow = useVirtualWindow({
|
|
231
|
+
count: tableRows.length,
|
|
232
|
+
scrollRef: listScrollRef,
|
|
233
|
+
estimateSize: 56,
|
|
234
|
+
})
|
|
209
235
|
|
|
210
236
|
// ─── Tree mode ─────────────────────────────────────────────────────
|
|
211
237
|
|
|
@@ -676,7 +702,7 @@ export function PagesListView({ onNavigate }: PagesListViewProps) {
|
|
|
676
702
|
<>
|
|
677
703
|
{/* Both-axis scroll: tall lists scroll vertically; narrow viewports
|
|
678
704
|
keep column widths and scroll sideways instead of crushing. */}
|
|
679
|
-
<div className="min-h-0 flex-1 overflow-auto">
|
|
705
|
+
<div ref={listScrollRef} className="min-h-0 flex-1 overflow-auto">
|
|
680
706
|
<table
|
|
681
707
|
className="w-full table-fixed"
|
|
682
708
|
style={{ minWidth: tableMinWidth }}
|
|
@@ -806,34 +832,48 @@ export function PagesListView({ onNavigate }: PagesListViewProps) {
|
|
|
806
832
|
onNewPage={() => onNavigate('/pages/new')}
|
|
807
833
|
/>
|
|
808
834
|
)}
|
|
835
|
+
{!loading && !error && (
|
|
836
|
+
<VirtualTablePadding height={listWindow.paddingTop} columns={columnCount} />
|
|
837
|
+
)}
|
|
809
838
|
{!loading &&
|
|
810
839
|
!error &&
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
840
|
+
listWindow.rows.map((row) => {
|
|
841
|
+
const entry = tableRows[row.index]
|
|
842
|
+
if (!entry) return null
|
|
843
|
+
if (entry.kind === 'header') {
|
|
844
|
+
return (
|
|
845
|
+
<GroupBlock
|
|
846
|
+
key={entry.key}
|
|
847
|
+
showHeader
|
|
848
|
+
label={entry.label}
|
|
849
|
+
color={entry.color}
|
|
850
|
+
count={entry.count}
|
|
851
|
+
columns={columnCount}
|
|
852
|
+
>
|
|
853
|
+
{null}
|
|
854
|
+
</GroupBlock>
|
|
855
|
+
)
|
|
856
|
+
}
|
|
857
|
+
const p = entry.page
|
|
858
|
+
return (
|
|
859
|
+
<PageRow
|
|
860
|
+
key={p.id}
|
|
861
|
+
page={p}
|
|
862
|
+
selected={selected.has(p.id)}
|
|
863
|
+
showTags={showTags}
|
|
864
|
+
showAuthor={showAuthor}
|
|
865
|
+
showSeo={showSeo}
|
|
866
|
+
resolveTag={(slug) => tagBySlug.get(slug)}
|
|
867
|
+
onSelect={() => toggleOne(p.id)}
|
|
868
|
+
onOpen={() => onNavigate(`/pages/${p.id}/edit`)}
|
|
869
|
+
onDuplicate={() => void runDuplicate(p)}
|
|
870
|
+
onDelete={() => setRowDelete(p)}
|
|
871
|
+
/>
|
|
872
|
+
)
|
|
873
|
+
})}
|
|
874
|
+
{!loading && !error && (
|
|
875
|
+
<VirtualTablePadding height={listWindow.paddingBottom} columns={columnCount} />
|
|
876
|
+
)}
|
|
837
877
|
</tbody>
|
|
838
878
|
</table>
|
|
839
879
|
</div>
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
X,
|
|
17
17
|
} from 'lucide-react'
|
|
18
18
|
import { toast } from 'sonner'
|
|
19
|
+
import { useVirtualWindow, VirtualTablePadding } from '../../components/virtual-window.js'
|
|
19
20
|
import {
|
|
20
21
|
bulkDeletePosts,
|
|
21
22
|
bulkUpdatePosts,
|
|
@@ -161,6 +162,12 @@ export function PostsListView({ onNavigate }: PostsListViewProps) {
|
|
|
161
162
|
}, [data?.types])
|
|
162
163
|
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
|
|
163
164
|
const posts = data?.posts ?? []
|
|
165
|
+
const listScrollRef = useRef<HTMLDivElement>(null)
|
|
166
|
+
const listWindow = useVirtualWindow({
|
|
167
|
+
count: posts.length,
|
|
168
|
+
scrollRef: listScrollRef,
|
|
169
|
+
estimateSize: 56,
|
|
170
|
+
})
|
|
164
171
|
const types = data?.types ?? []
|
|
165
172
|
const authors = data?.authors ?? []
|
|
166
173
|
|
|
@@ -345,7 +352,7 @@ export function PostsListView({ onNavigate }: PostsListViewProps) {
|
|
|
345
352
|
<div className="border-border bg-card flex min-h-0 flex-1 flex-col overflow-hidden rounded-xl border shadow-sm">
|
|
346
353
|
{/* Both-axis scroll: tall lists scroll vertically; narrow viewports
|
|
347
354
|
keep column widths and scroll sideways instead of crushing. */}
|
|
348
|
-
<div className="min-h-0 flex-1 overflow-auto">
|
|
355
|
+
<div ref={listScrollRef} className="min-h-0 flex-1 overflow-auto">
|
|
349
356
|
<table
|
|
350
357
|
className="w-full table-fixed"
|
|
351
358
|
style={{ minWidth: TABLE_MIN_WIDTH }}
|
|
@@ -454,19 +461,29 @@ export function PostsListView({ onNavigate }: PostsListViewProps) {
|
|
|
454
461
|
onNewPost={() => onNavigate('/posts/new')}
|
|
455
462
|
/>
|
|
456
463
|
)}
|
|
464
|
+
{!loading && !error && (
|
|
465
|
+
<VirtualTablePadding height={listWindow.paddingTop} columns={8} />
|
|
466
|
+
)}
|
|
457
467
|
{!loading &&
|
|
458
468
|
!error &&
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
469
|
+
listWindow.rows.map((row) => {
|
|
470
|
+
const p = posts[row.index]
|
|
471
|
+
if (!p) return null
|
|
472
|
+
return (
|
|
473
|
+
<PostRow
|
|
474
|
+
key={p.id}
|
|
475
|
+
post={p}
|
|
476
|
+
selected={selected.has(p.id)}
|
|
477
|
+
onSelect={() => toggleOne(p.id)}
|
|
478
|
+
onOpen={() => onNavigate(`/posts/${p.postTypeId}/${p.id}/edit`)}
|
|
479
|
+
onDuplicate={() => void runDuplicate(p)}
|
|
480
|
+
onDelete={() => setRowDelete(p)}
|
|
481
|
+
/>
|
|
482
|
+
)
|
|
483
|
+
})}
|
|
484
|
+
{!loading && !error && (
|
|
485
|
+
<VirtualTablePadding height={listWindow.paddingBottom} columns={8} />
|
|
486
|
+
)}
|
|
470
487
|
</tbody>
|
|
471
488
|
</table>
|
|
472
489
|
</div>
|