@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.
@@ -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
- {docs.map((doc: any) => {
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={`grid h-full auto-rows-min content-start gap-3 overflow-y-auto p-2 sm:gap-4 sm:p-3 ${
757
- panelOpen
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={item.id}
776
- className={`group relative flex cursor-pointer flex-col overflow-hidden rounded-lg border-2 transition-all ${
777
- isActive
778
- ? 'border-primary ring-primary/30 ring-2'
779
- : selectedMedia.includes(item.id)
780
- ? 'border-primary/60 ring-primary/20 ring-1'
781
- : 'border-border hover:border-input'
782
- }`}
783
- onClick={() => openDetail(item)}
784
- draggable
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
- <div className="relative aspect-square w-full overflow-hidden">
788
- <div className="bg-muted flex h-full w-full items-center justify-center">
789
- <MediaPreview item={item} />
790
- </div>
791
- {hasIssues && (
792
- <div
793
- className="bg-warning absolute top-1.5 left-1.5 flex h-5 w-5 items-center justify-center rounded-full"
794
- title="Needs attention"
795
- >
796
- <AlertTriangle className="text-warning-foreground h-3 w-3" />
797
- </div>
798
- )}
799
- <div
800
- className="absolute top-1.5 right-1.5 opacity-0 transition-opacity group-hover:opacity-100"
801
- onClick={(e) => handleCheckbox(e, item.id)}
802
- >
803
- <div
804
- className={`flex h-5 w-5 items-center justify-center rounded border-2 transition-colors ${
805
- selectedMedia.includes(item.id)
806
- ? 'border-primary bg-primary'
807
- : 'border-input bg-card/80'
808
- }`}
809
- >
810
- {selectedMedia.includes(item.id) && (
811
- <svg
812
- className="text-primary-foreground h-3 w-3"
813
- fill="none"
814
- viewBox="0 0 12 12"
815
- >
816
- <path
817
- d="M10 3L4.5 8.5L2 6"
818
- stroke="currentColor"
819
- strokeWidth="2"
820
- strokeLinecap="round"
821
- strokeLinejoin="round"
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
- </svg>
824
- )}
825
- </div>
826
- </div>
827
- </div>
828
- <div className="border-border bg-card flex items-center gap-2 border-t px-2 py-1.5">
829
- <span
830
- className={`mt-0.5 h-2 w-2 shrink-0 self-start rounded-full ${
831
- hasIssues ? 'bg-warning' : 'bg-success'
832
- }`}
833
- title={hasIssues ? 'Needs attention' : 'SEO complete'}
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
- {filteredAndSorted.map((item) => {
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
- displayGroups.map((group) => (
812
- <GroupBlock
813
- key={group.key}
814
- showHeader={grouped}
815
- label={group.label}
816
- color={group.color}
817
- count={group.pages.length}
818
- columns={columnCount}
819
- >
820
- {group.pages.map((p) => (
821
- <PageRow
822
- key={p.id}
823
- page={p}
824
- selected={selected.has(p.id)}
825
- showTags={showTags}
826
- showAuthor={showAuthor}
827
- showSeo={showSeo}
828
- resolveTag={(slug) => tagBySlug.get(slug)}
829
- onSelect={() => toggleOne(p.id)}
830
- onOpen={() => onNavigate(`/pages/${p.id}/edit`)}
831
- onDuplicate={() => void runDuplicate(p)}
832
- onDelete={() => setRowDelete(p)}
833
- />
834
- ))}
835
- </GroupBlock>
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
- posts.map((p) => (
460
- <PostRow
461
- key={p.id}
462
- post={p}
463
- selected={selected.has(p.id)}
464
- onSelect={() => toggleOne(p.id)}
465
- onOpen={() => onNavigate(`/posts/${p.postTypeId}/${p.id}/edit`)}
466
- onDuplicate={() => void runDuplicate(p)}
467
- onDelete={() => setRowDelete(p)}
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>