PluginProbe ʕ •ᴥ•ʔ
Presto Player / trunk
Presto Player vtrunk
4.3.1 4.3.0 4.2.4 4.2.3 4.2.2 4.2.0 4.2.1 trunk 1.10.0 1.10.1 1.10.2 1.11.0 1.12.0 1.13.0 1.14.0 1.14.1 1.5.10 1.5.11 1.5.12 1.5.13 1.5.14 1.5.15 1.5.5 1.5.6 1.5.7 1.5.8 1.5.9 1.6.0 1.6.1 1.6.10 1.6.11 1.6.12 1.6.13 1.6.2 1.6.3 1.6.4 1.6.5 1.6.6 1.6.7 1.6.8 1.6.9 1.7.0 1.7.1 1.7.2 1.8.0 1.8.1 1.8.2 1.8.3 1.8.4 1.8.5 1.8.6 1.9.0 1.9.1 1.9.10 1.9.11 1.9.12 1.9.13 1.9.14 1.9.2 1.9.3 1.9.4 1.9.5 1.9.6 1.9.7 1.9.8 1.9.9 2.0.0 2.0.1 2.0.10 2.0.11 2.0.12 2.0.13 2.0.14 2.0.15 2.0.16 2.0.2 2.0.3 2.0.4 2.0.5 2.0.6 2.0.7 2.0.8 2.0.9 2.1.0 2.2.0 2.2.1 2.2.2 2.2.3 2.2.3-beta1 2.3.0 2.3.1 2.3.2 2.3.3 3.0.0 3.0.0-beta1 3.0.1 3.0.2 3.0.3 3.0.4 3.0.5 3.0.6 3.0.7 3.0.8 3.1.0 3.1.1 3.1.2 3.1.3 4.0.0 4.0.1 4.0.2 4.0.3 4.0.4 4.0.5 4.0.6 4.0.7 4.0.8 4.1.0 4.1.1 4.1.2 4.1.3 4.1.4
presto-player / src / admin / dashboard / pages / MediaHub.js
presto-player / src / admin / dashboard / pages Last commit date
learn 1 month ago settings 1 month ago Analytics.js 1 month ago AnalyticsDetail.js 1 month ago Dashboard.js 1 month ago Emails.js 1 month ago Learn.js 1 month ago MediaHub.js 3 weeks ago Onboarding.js 3 weeks ago Settings.js 1 month ago UserAnalyticsDetail.js 1 month ago
MediaHub.js
1115 lines
1 import React, { useState, useRef, useMemo, useEffect } from 'react';
2 const { __, _n, sprintf } = wp.i18n;
3 import {
4 Container,
5 Table,
6 DropdownMenu,
7 Pagination,
8 Tooltip,
9 toast,
10 } from '@bsf/force-ui';
11 import apiFetch from '@wordpress/api-fetch';
12 import {
13 ChevronsUpDown,
14 FolderArchive,
15 Files,
16 Trash,
17 ArchiveRestore,
18 Trash2,
19 CheckCheck,
20 Plus,
21 Info,
22 AlertTriangle,
23 RefreshCw,
24 } from 'lucide-react';
25 import useMediaList from '../hooks/useMediaList.ts';
26 import {
27 MediaRow,
28 BulkActions,
29 PostSettings,
30 Filters,
31 ConfirmPopup,
32 } from '../components/MediaHub';
33 import {
34 statusOptions as filterStatusOptions,
35 getBadge,
36 formatPublishDate,
37 } from '../components/Emails/Utils';
38 import PageHeader from '../components/PageHeader';
39 import MediaHubPageSkeleton from '../components/Skeletons/MediaHubPageSkeleton';
40 import MediaHubRowSkeleton from '../components/Skeletons/MediaHubRowSkeleton';
41 import NoFound from '../components/NoFound';
42 import mediaHubEmptyState from '../../../../img/media-hub-empty-state.svg';
43
44 const DEFAULT_PER_PAGE = 10;
45 const ALLOWED_PER_PAGE = [ 10, 25, 50, 75, 100 ];
46 const EDITED_MEDIA_KEY = 'presto_edited_media_id';
47 const SEARCH_DEBOUNCE_MS = 300;
48 // "all" in the status dropdown means "everything except trash" — matches the
49 // previous client-side filter behaviour and WordPress core's own admin lists.
50 const ACTIVE_STATUSES = 'publish,draft,pending,private,future';
51
52 // Stable empty-tags reference. `selectedMediaForSettings.tags || []` would
53 // create a fresh array on every parent render when the item has no tags,
54 // which invalidates PostSettings' reset effect and clobbers the user's
55 // in-progress edits each time the parent re-renders (e.g. after save).
56 const EMPTY_MEDIA_TAGS = [];
57
58 // sessionStorage can throw in private browsing, sandboxed iframes, or when
59 // site data is disabled — fail silently so the dashboard keeps working.
60 const editedMediaSession = {
61 get: () => {
62 try {
63 return window.sessionStorage.getItem( EDITED_MEDIA_KEY );
64 } catch ( err ) {
65 return null;
66 }
67 },
68 set: ( value ) => {
69 try {
70 window.sessionStorage.setItem( EDITED_MEDIA_KEY, value );
71 } catch ( err ) {
72 // ignore
73 }
74 },
75 remove: () => {
76 try {
77 window.sessionStorage.removeItem( EDITED_MEDIA_KEY );
78 } catch ( err ) {
79 // ignore
80 }
81 },
82 };
83
84 const MediaHub = () => {
85 // Filter / sort / pagination state owned by the page. The hook reads these
86 // and fires one request per change; we keep the *input* of the search box
87 // separate from the *committed* search term so typing doesn't fire a
88 // request on every keystroke.
89 const [ searchInput, setSearchInput ] = useState( '' );
90 const [ searchTerm, setSearchTerm ] = useState( '' );
91 const [ selectedStatus, setSelectedStatus ] = useState( 'all' );
92 const [ selectedTag, setSelectedTag ] = useState( '' );
93 const [ sortField, setSortField ] = useState( 'date' );
94 const [ sortOrder, setSortOrder ] = useState( 'desc' );
95 const [ currentPage, setCurrentPage ] = useState( 1 );
96 const [ postCount, setPostCount ] = useState( DEFAULT_PER_PAGE );
97
98 // Local UI state.
99 const [ selected, setSelected ] = useState( [] );
100 const [ showSettingsPopup, setShowSettingsPopup ] = useState( false );
101 const [ selectedMediaForSettings, setSelectedMediaForSettings ] =
102 useState( null );
103 const searchInputRef = useRef( null );
104 const [ showFilter, setShowFilter ] = useState( false );
105 const [ openActionPopup, setOpenActionPopup ] = useState( false );
106
107 // Debounce the search input → committed search term. Triggers one request
108 // ~300ms after the user stops typing instead of one per keystroke. The
109 // committed change also resets the active page in one batched update so
110 // useMediaList sees `{ search: 'foo', page: 1 }` in a SINGLE render —
111 // without this, a separate page-reset effect produces TWO fetches per
112 // filter change.
113 useEffect( () => {
114 if ( searchInput === searchTerm ) {
115 return undefined;
116 }
117 const t = setTimeout( () => {
118 setSearchTerm( searchInput );
119 setCurrentPage( 1 );
120 setSelected( [] );
121 }, SEARCH_DEBOUNCE_MS );
122 return () => clearTimeout( t );
123 }, [ searchInput, searchTerm ] );
124
125 // Translate the UI status filter ("all" | "trash" | status slug) into the
126 // comma-separated `status` param the server expects.
127 const serverStatus =
128 selectedStatus === 'all' ? ACTIVE_STATUSES : selectedStatus;
129
130 const {
131 items,
132 totalItems,
133 totalPages,
134 counts,
135 allTags,
136 loading,
137 error,
138 hasLoadedOnce,
139 refetch,
140 } = useMediaList( {
141 search: searchTerm,
142 status: serverStatus,
143 tag: selectedTag ? parseInt( selectedTag, 10 ) : 0,
144 orderby: sortField === 'title' ? 'title' : 'date',
145 order: sortOrder,
146 page: currentPage,
147 perPage: postCount,
148 } );
149
150 // "Library is empty" is independent of the current filter — derived from
151 // server-side counts so an active search returning zero results does NOT
152 // show the empty-state CTA.
153 const isLibraryEmpty = useMemo(
154 () =>
155 ( counts.publish || 0 ) +
156 ( counts.draft || 0 ) +
157 ( counts.pending || 0 ) +
158 ( counts.private || 0 ) +
159 ( counts.future || 0 ) +
160 ( counts.trash || 0 ) ===
161 0,
162 [ counts ]
163 );
164
165 const handleCheckboxChange = ( checked, value ) => {
166 if ( checked ) {
167 setSelected( [ ...selected, value.id ] );
168 } else {
169 setSelected( selected.filter( ( item ) => item !== value.id ) );
170 }
171 };
172
173 // Select-all selects the current visible page (matches WP's own list
174 // tables; selecting items off-page would be surprising).
175 const toggleSelectAll = ( checked ) => {
176 if ( checked ) {
177 setSelected( items.map( ( item ) => item.id ) );
178 } else {
179 setSelected( [] );
180 }
181 };
182
183 // Centralised "filter changed → reset paging + selection" so every
184 // filter-mutating handler stays in sync without a separate useEffect (the
185 // effect-based approach lets useMediaList fire ONE request with the stale
186 // page before the reset commits, doubling backend load per change).
187 const resetForFilterChange = () => {
188 setCurrentPage( 1 );
189 setSelected( [] );
190 };
191
192 const handleSort = ( field ) => {
193 if ( sortField === field ) {
194 setSortOrder( ( prev ) => ( prev === 'asc' ? 'desc' : 'asc' ) );
195 } else {
196 setSortField( field );
197 // Date defaults to newest-first; alphabetical defaults to A-Z.
198 setSortOrder( field === 'date' ? 'desc' : 'asc' );
199 }
200 resetForFilterChange();
201 };
202
203 const handleStatusChange = ( value ) => {
204 setSelectedStatus( value );
205 resetForFilterChange();
206 };
207
208 const handleTagChange = ( value ) => {
209 setSelectedTag( value );
210 resetForFilterChange();
211 };
212
213 const handlePostCountChange = ( value ) => {
214 setPostCount( value );
215 resetForFilterChange();
216 };
217
218 const onEditClick = ( e, mediaId ) => {
219 if ( e.metaKey || e.ctrlKey ) {
220 return;
221 }
222 e.preventDefault();
223
224 editedMediaSession.set( mediaId );
225 const editUrl = `post.php?post=${ mediaId }&action=edit`;
226 window.open( editUrl, '_self' );
227 };
228
229 const handleOpenSettings = ( event, mediaData ) => {
230 event.preventDefault();
231 event.stopPropagation();
232 setSelectedMediaForSettings( mediaData );
233 setShowSettingsPopup( true );
234 };
235
236 const handleSettingsSuccess = () => {
237 refetch();
238 };
239
240 const actionMenus = [
241 {
242 label: __( 'Duplicate', 'presto-player' ),
243 value: 'duplicate',
244 icon: <Files width="15" height="15" />,
245 },
246 {
247 label: __( 'Save as Draft', 'presto-player' ),
248 value: 'draft',
249 icon: <FolderArchive width="15" height="15" />,
250 },
251 {
252 label: __( 'Mark as Publish', 'presto-player' ),
253 value: 'publish',
254 icon: <CheckCheck width="15" height="15" />,
255 },
256 {
257 label: __( 'Move to Trash', 'presto-player' ),
258 value: 'trash',
259 icon: <Trash width="15" height="15" />,
260 },
261 {
262 label: __( 'Delete Permanently', 'presto-player' ),
263 value: 'delete',
264 icon: <Trash2 width="15" height="15" />,
265 },
266 {
267 label: __( 'Restore', 'presto-player' ),
268 value: 'restore',
269 icon: <ArchiveRestore width="15" height="15" />,
270 },
271 ];
272
273 const defaultPopupState = {
274 title: __( 'Are you sure?', 'presto-player' ),
275 description: __( 'Are you sure you want to proceed?', 'presto-player' ),
276 confirmText: __( 'Confirm', 'presto-player' ),
277 cancelText: __( 'Cancel', 'presto-player' ),
278 confirmCallback: () => {},
279 cancelCallback: () => {},
280 };
281 const [ actionPopupData, setActionPopupData ] = useState( defaultPopupState );
282
283 // Drop a single id from the bulk selection set. Used by the per-row
284 // action handlers so a row the user just acted on can't be silently
285 // re-targeted by the next bulk operation (it might have been deleted, or
286 // moved to a status no longer in the current filter).
287 const dropFromSelection = ( mediaId ) => {
288 setSelected( ( prev ) => prev.filter( ( id ) => id !== mediaId ) );
289 };
290
291 // Single-row status change wrapped so every action goes through the same
292 // request → refetch → toast flow.
293 const updateStatus = ( mediaId, newStatus, successText, errorText ) =>
294 apiFetch( {
295 path: `/wp/v2/presto-videos/${ mediaId }`,
296 method: 'POST',
297 data: { status: newStatus },
298 } )
299 .then( () => {
300 setOpenActionPopup( false );
301 dropFromSelection( mediaId );
302 refetch();
303 toast.success( successText );
304 } )
305 .catch( ( err ) => {
306 console.error( 'Error updating media status:', err );
307 toast.error( errorText );
308 } );
309
310 const deleteOne = ( mediaId, force, successText, errorText ) =>
311 apiFetch( {
312 path: `/wp/v2/presto-videos/${ mediaId }${ force ? '?force=true' : '' }`,
313 method: 'DELETE',
314 } )
315 .then( () => {
316 setOpenActionPopup( false );
317 dropFromSelection( mediaId );
318 refetch();
319 toast.success( successText );
320 } )
321 .catch( ( err ) => {
322 console.error( 'Error deleting media:', err );
323 toast.error( errorText );
324 } );
325
326 const duplicateOne = ( mediaId ) => {
327 const formData = new window.FormData();
328 formData.append( 'media_id', mediaId );
329
330 apiFetch( {
331 path: '/presto-player/v1/duplicate-media',
332 method: 'POST',
333 body: formData,
334 } )
335 .then( ( response ) => {
336 if ( response.success ) {
337 setOpenActionPopup( false );
338 dropFromSelection( mediaId );
339 refetch();
340 toast.success( response?.data?.message );
341 }
342 } )
343 .catch( ( err ) => {
344 console.error( 'Error:', err );
345 } );
346 };
347
348 const handleMenuActions = ( mediaId, mediaAction ) => {
349 const actionWisePopupData = {
350 duplicate: {
351 title: __( 'Duplicate?', 'presto-player' ),
352 description: __(
353 'Are you sure you want to duplicate this media?',
354 'presto-player'
355 ),
356 confirmText: __( 'Duplicate', 'presto-player' ),
357 confirmCallback: () => duplicateOne( mediaId ),
358 cancelCallback: () => setOpenActionPopup( false ),
359 },
360 draft: {
361 title: __( 'Save as Draft?', 'presto-player' ),
362 description: __(
363 'Are you sure you want to mark this media as a draft?',
364 'presto-player'
365 ),
366 confirmText: __( 'Save as Draft', 'presto-player' ),
367 confirmCallback: () =>
368 updateStatus(
369 mediaId,
370 'draft',
371 __( 'Successfully drafted.', 'presto-player' ),
372 __( 'Failed to draft.', 'presto-player' )
373 ),
374 cancelCallback: () => setOpenActionPopup( false ),
375 },
376 publish: {
377 title: __( 'Publish?', 'presto-player' ),
378 description: __(
379 'Are you sure you want to publish this media?',
380 'presto-player'
381 ),
382 confirmText: __( 'Publish', 'presto-player' ),
383 confirmCallback: () =>
384 updateStatus(
385 mediaId,
386 'publish',
387 __( 'Successfully published.', 'presto-player' ),
388 __( 'Failed to publish.', 'presto-player' )
389 ),
390 cancelCallback: () => setOpenActionPopup( false ),
391 },
392 trash: {
393 title: __( 'Move to Trash?', 'presto-player' ),
394 description: __(
395 'Are you sure you want to move this media to the trash?',
396 'presto-player'
397 ),
398 confirmText: __( 'Move to Trash', 'presto-player' ),
399 destructive: true,
400 // Native DELETE without ?force=true = wp_trash_post.
401 confirmCallback: () =>
402 deleteOne(
403 mediaId,
404 false,
405 __( 'Successfully trashed.', 'presto-player' ),
406 __( 'Failed to trash.', 'presto-player' )
407 ),
408 cancelCallback: () => setOpenActionPopup( false ),
409 },
410 delete: {
411 title: __( 'Delete?', 'presto-player' ),
412 description: __(
413 'This will permanently delete this media. It cannot be recovered. To remove it temporarily instead, use Trash.',
414 'presto-player'
415 ),
416 confirmText: __( 'Delete', 'presto-player' ),
417 destructive: true,
418 // Native DELETE with ?force=true = wp_delete_post (permanent).
419 confirmCallback: () =>
420 deleteOne(
421 mediaId,
422 true,
423 __( 'Media deleted successfully.', 'presto-player' ),
424 __( 'Failed to delete media.', 'presto-player' )
425 ),
426 cancelCallback: () => setOpenActionPopup( false ),
427 },
428 restore: {
429 title: __( 'Restore?', 'presto-player' ),
430 description: __(
431 'Are you sure you want to restore this media?',
432 'presto-player'
433 ),
434 confirmText: __( 'Restore', 'presto-player' ),
435 // Restore = set status to draft via native PUT.
436 confirmCallback: () =>
437 updateStatus(
438 mediaId,
439 'draft',
440 __( 'Successfully restored.', 'presto-player' ),
441 __( 'Failed to restore.', 'presto-player' )
442 ),
443 cancelCallback: () => setOpenActionPopup( false ),
444 },
445 };
446
447 setActionPopupData( actionWisePopupData[ mediaAction ] );
448 setOpenActionPopup( true );
449 };
450
451 const renderActionMenu = ( mediaItem ) => {
452 let actions = actionMenus.filter(
453 ( item ) => item.value !== mediaItem?.status
454 );
455
456 if ( mediaItem?.status === 'trash' ) {
457 actions = actionMenus.filter(
458 ( item ) => item.value === 'restore' || item.value === 'delete'
459 );
460 } else {
461 actions = actions.filter(
462 ( item ) => item.value !== 'restore' && item.value !== 'delete'
463 );
464 }
465
466 return actions.map( ( action ) => (
467 <DropdownMenu.Item
468 key={ action.value }
469 onClick={ () => handleMenuActions( mediaItem.id, action.value ) }
470 className="text-sm"
471 >
472 <div className="flex items-center gap-2">
473 { action.icon }
474 { action.label }
475 </div>
476 </DropdownMenu.Item>
477 ) );
478 };
479
480 // Bulk operations all share the same shape: fan out per-id requests,
481 // collect successes/failures, refetch once, surface a single toast.
482 // `successCopy(n)` / `errorCopy(n)` return the FULL formatted message so
483 // each caller can use as many placeholders as it needs.
484 const runBulk = ( selectedIds, path, method, successCopy, errorCopy ) => {
485 if ( ! selectedIds || selectedIds.length === 0 ) {
486 return Promise.resolve();
487 }
488
489 const promises = selectedIds.map( ( mediaId ) =>
490 apiFetch( {
491 path: path( mediaId ),
492 method: method.verb,
493 data: method.data,
494 } )
495 .then( () => ( { success: true, mediaId } ) )
496 .catch( ( err ) => {
497 console.error( `Bulk ${ method.verb } ${ mediaId }:`, err );
498 return { success: false, mediaId };
499 } )
500 );
501
502 return Promise.all( promises ).then( ( results ) => {
503 const successCount = results.filter( ( r ) => r.success ).length;
504 const failedCount = results.length - successCount;
505
506 setSelected( [] );
507 if ( successCount > 0 ) {
508 refetch();
509 }
510
511 if ( successCount > 0 ) {
512 toast.success( successCopy( successCount ) );
513 }
514 if ( failedCount > 0 ) {
515 toast.error( errorCopy( failedCount ) );
516 }
517 } );
518 };
519
520 const handleBulkDelete = ( selectedIds ) =>
521 runBulk(
522 selectedIds,
523 ( id ) => `/wp/v2/presto-videos/${ id }?force=true`,
524 { verb: 'DELETE' },
525 ( n ) =>
526 sprintf(
527 /* translators: %d: number of media items deleted */
528 _n(
529 '%d media item deleted successfully.',
530 '%d media items deleted successfully.',
531 n,
532 'presto-player'
533 ),
534 n
535 ),
536 ( n ) =>
537 sprintf(
538 /* translators: %d: number of media items that failed to delete */
539 _n(
540 'Failed to delete %d media item.',
541 'Failed to delete %d media items.',
542 n,
543 'presto-player'
544 ),
545 n
546 )
547 );
548
549 const handleBulkTrash = ( selectedIds ) =>
550 runBulk(
551 selectedIds,
552 ( id ) => `/wp/v2/presto-videos/${ id }`,
553 { verb: 'DELETE' },
554 ( n ) =>
555 sprintf(
556 /* translators: %d: number of media items moved to trash */
557 _n(
558 '%d media item moved to trash.',
559 '%d media items moved to trash.',
560 n,
561 'presto-player'
562 ),
563 n
564 ),
565 ( n ) =>
566 sprintf(
567 /* translators: %d: number of media items that failed to move to trash */
568 _n(
569 'Failed to trash %d media item.',
570 'Failed to trash %d media items.',
571 n,
572 'presto-player'
573 ),
574 n
575 )
576 );
577
578 const handleBulkStatusChange = ( selectedIds, status ) =>
579 runBulk(
580 selectedIds,
581 ( id ) => `/wp/v2/presto-videos/${ id }`,
582 { verb: 'POST', data: { status } },
583 ( n ) =>
584 sprintf(
585 /* translators: 1: number of items updated, 2: new status (e.g. "publish") */
586 _n(
587 '%1$d item updated to %2$s.',
588 '%1$d items updated to %2$s.',
589 n,
590 'presto-player'
591 ),
592 n,
593 status
594 ),
595 ( n ) =>
596 sprintf(
597 /* translators: %d: number of items that failed to update */
598 _n(
599 'Failed to update %d item.',
600 'Failed to update %d items.',
601 n,
602 'presto-player'
603 ),
604 n
605 )
606 );
607
608 const handleBulkCancel = () => {
609 setSelected( [] );
610 };
611
612 const handleSearchResult = ( value ) => {
613 setSearchInput( value );
614 };
615
616 const onAddNewClick = ( e ) => {
617 e.preventDefault();
618 e.stopPropagation();
619 const newPostUrl = `post-new.php?post_type=pp_video_block`;
620 window.open( newPostUrl, '_self' );
621 };
622
623 const tagOptions = useMemo(
624 () => [ { id: '', name: __( 'All Tags', 'presto-player' ) }, ...allTags ],
625 [ allTags ]
626 );
627
628 const handleClearFilters = () => {
629 setSelectedStatus( 'all' );
630 setSelectedTag( '' );
631 setPostCount( DEFAULT_PER_PAGE );
632 resetForFilterChange();
633 };
634
635 // Guard against an invalid per-page value from a stale stored preference.
636 useEffect( () => {
637 if ( ! ALLOWED_PER_PAGE.includes( postCount ) ) {
638 setPostCount( DEFAULT_PER_PAGE );
639 }
640 }, [ postCount ] );
641
642 // Clear selection on page navigation. Without this, ids checked on page N
643 // stay in the bulk-selection set after the user moves to page N+1 — and
644 // the next bulk action targets rows the user can no longer see.
645 useEffect( () => {
646 setSelected( [] );
647 }, [ currentPage ] );
648
649 // Clamp currentPage when the server reports fewer pages than we're on
650 // (bulk delete on the last page, filter narrows results, etc.). Without
651 // this the user lands on an orphan page with no rows and no pagination
652 // footer to navigate back.
653 useEffect( () => {
654 if ( totalPages > 0 && currentPage > totalPages ) {
655 setCurrentPage( totalPages );
656 }
657 }, [ totalPages, currentPage ] );
658
659 // Surface refetch errors (initial-load errors are handled by a dedicated
660 // retry screen below). Toast once per error transition; the hook clears
661 // `error` at the start of each new fetch so repeated identical failures
662 // re-toast.
663 useEffect( () => {
664 if ( error && hasLoadedOnce ) {
665 toast.error( error );
666 }
667 }, [ error, hasLoadedOnce ] );
668
669 // Scroll-to-edited-item after returning from the post editor. With server
670 // pagination the edited row may not be on the current page; if it's not in
671 // the visible items we simply drop the marker silently — the user lands on
672 // the default sorted view (newest first) where the just-edited item will
673 // usually be at the top anyway.
674 useEffect( () => {
675 const editedId = editedMediaSession.get();
676 if ( ! editedId || ! items.length ) {
677 return;
678 }
679
680 const found = items.some( ( item ) => String( item.id ) === editedId );
681 if ( ! found ) {
682 editedMediaSession.remove();
683 return;
684 }
685
686 const timer = setTimeout( () => {
687 const el = document.querySelector( `[data-id="${ editedId }"]` );
688 if ( el ) {
689 el.scrollIntoView( { behavior: 'smooth', block: 'center' } );
690 el.classList.add(
691 'bg-brand-background-hover-100',
692 'transition-all',
693 'duration-300'
694 );
695 setTimeout( () => {
696 el.classList.remove( 'bg-brand-background-hover-100' );
697 setTimeout(
698 () => el.classList.remove( 'transition-all', 'duration-300' ),
699 300
700 );
701 }, 1300 );
702 }
703 editedMediaSession.remove();
704 }, 200 );
705
706 return () => clearTimeout( timer );
707 }, [ items ] );
708
709 const renderPagination = () => {
710 if ( totalPages <= 1 ) {
711 return null;
712 }
713
714 const pages = [];
715
716 const renderPageItem = ( i ) => (
717 <Pagination.Item
718 key={ i }
719 isActive={ i === currentPage }
720 onClick={ () => setCurrentPage( i ) }
721 >
722 { i }
723 </Pagination.Item>
724 );
725
726 const showEllipsis = ( key ) => <Pagination.Ellipsis key={ key } />;
727
728 // Always show first page.
729 pages.push( renderPageItem( 1 ) );
730
731 if ( currentPage > 3 ) {
732 pages.push( showEllipsis( 'left-ellipsis' ) );
733 }
734
735 for (
736 let i = Math.max( 2, currentPage - 1 );
737 i <= Math.min( totalPages - 1, currentPage + 1 );
738 i++
739 ) {
740 pages.push( renderPageItem( i ) );
741 }
742
743 if ( currentPage < totalPages - 2 ) {
744 pages.push( showEllipsis( 'right-ellipsis' ) );
745 }
746
747 if ( totalPages > 1 ) {
748 pages.push( renderPageItem( totalPages ) );
749 }
750
751 return (
752 <>
753 <Pagination.Previous
754 onClick={ () =>
755 setCurrentPage( ( prev ) => Math.max( prev - 1, 1 ) )
756 }
757 disabled={ currentPage === 1 }
758 />
759 { pages }
760 <Pagination.Next
761 onClick={ () =>
762 setCurrentPage( ( prev ) => Math.min( prev + 1, totalPages ) )
763 }
764 disabled={ currentPage === totalPages }
765 />
766 </>
767 );
768 };
769
770 const containerClassName = 'p-8';
771
772 const startIndex = totalItems === 0 ? 0 : ( currentPage - 1 ) * postCount + 1;
773 const endIndex = Math.min( currentPage * postCount, totalItems );
774
775 // Skeleton blocks the initial paint (until the first response settles).
776 // Subsequent refetches keep the existing rows visible so the table doesn't
777 // flash to a skeleton on every mutation. Driven by hasLoadedOnce from the
778 // hook — counts-based "isLibraryEmpty" can't be used here because the
779 // hook's initial counts are all zero before the first response.
780 if ( ! hasLoadedOnce && loading ) {
781 return <MediaHubPageSkeleton />;
782 }
783
784 // First-load failure: dedicated retry screen instead of silently rendering
785 // the new-install empty-state CTA. Subsequent refetch errors are surfaced
786 // via toast (see the effect above) so the table stays usable.
787 if ( ! loading && error && ( ! hasLoadedOnce || items.length === 0 ) ) {
788 return (
789 <Container
790 className={ `${ containerClassName } flex-1 items-center justify-start pt-16` }
791 direction="column"
792 >
793 <NoFound
794 icon={ <AlertTriangle width={ 56 } height={ 56 } /> }
795 title={ __( 'Couldn’t load your media.', 'presto-player' ) }
796 description={ error }
797 buttonText={ __( 'Try again', 'presto-player' ) }
798 buttonIcon={ <RefreshCw aria-label="icon" role="img" /> }
799 onButtonClick={ refetch }
800 />
801 </Container>
802 );
803 }
804
805 if ( ! loading && ! error && isLibraryEmpty ) {
806 return (
807 <Container
808 className={ `${ containerClassName } flex-1 items-center justify-start pt-16` }
809 direction="column"
810 >
811 <NoFound
812 icon={
813 <img src={ mediaHubEmptyState } width={ 70 } height={ 53 } alt="" />
814 }
815 title={ __( 'Your media will be displayed here.', 'presto-player' ) }
816 description={ __(
817 'Click the "Add Media" button to create a new Media Hub item',
818 'presto-player'
819 ) }
820 buttonText={ __( 'Add Media', 'presto-player' ) }
821 buttonIcon={ <Plus aria-label="icon" role="img" /> }
822 onButtonClick={ onAddNewClick }
823 />
824 </Container>
825 );
826 }
827
828 return (
829 <>
830 { selectedMediaForSettings && (
831 <PostSettings
832 open={ showSettingsPopup }
833 onClose={ () => {
834 setShowSettingsPopup( false );
835 setSelectedMediaForSettings( null );
836 } }
837 onSuccess={ handleSettingsSuccess }
838 mediaId={ selectedMediaForSettings.id }
839 initialTitle={ selectedMediaForSettings.title || '' }
840 initialStatus={ selectedMediaForSettings.status || 'publish' }
841 initialSlug={ selectedMediaForSettings.post_name || '' }
842 initialDate={ selectedMediaForSettings.date || '' }
843 initialPassword={ selectedMediaForSettings.post_password || '' }
844 initialTags={ selectedMediaForSettings.tags || EMPTY_MEDIA_TAGS }
845 availableTags={ allTags }
846 />
847 ) }
848
849 <Container className={ containerClassName } gap="md" direction="column">
850 <PageHeader
851 title={ __( 'Media Hub', 'presto-player' ) }
852 showFilter={ showFilter }
853 setShowFilter={ setShowFilter }
854 showFilterWhen={ ! isLibraryEmpty }
855 searchPlaceholder={ __( 'Search media…', 'presto-player' ) }
856 searchValue={ searchInput }
857 onSearchChange={ ( value ) => handleSearchResult( value ) }
858 searchInputRef={ searchInputRef }
859 primaryButtonText={ __( 'New Media', 'presto-player' ) }
860 onPrimaryClick={ onAddNewClick }
861 />
862
863 <Container gap="lg" direction="column">
864 <BulkActions
865 selected={ selected }
866 onTrash={ ( selectedIds ) => {
867 setActionPopupData( {
868 title: __( 'Move Selected to Trash?', 'presto-player' ),
869 description: sprintf(
870 /* translators: %d: number of selected items being moved to trash */
871 _n(
872 'Are you sure you want to move %d item to the trash?',
873 'Are you sure you want to move %d items to the trash?',
874 selectedIds.length,
875 'presto-player'
876 ),
877 selectedIds.length
878 ),
879 confirmText: __( 'Move to Trash', 'presto-player' ),
880 destructive: true,
881 confirmCallback: () => handleBulkTrash( selectedIds ),
882 cancelCallback: () => setOpenActionPopup( false ),
883 } );
884 setOpenActionPopup( true );
885 } }
886 onDelete={ ( selectedIds ) => {
887 setActionPopupData( {
888 title: __( 'Delete Selected Items?', 'presto-player' ),
889 description: sprintf(
890 /* translators: %d: number of selected items being permanently deleted */
891 _n(
892 'This will permanently delete %d item. It cannot be recovered. To remove it temporarily instead, use Trash.',
893 'This will permanently delete %d items. They cannot be recovered. To remove them temporarily instead, use Trash.',
894 selectedIds.length,
895 'presto-player'
896 ),
897 selectedIds.length
898 ),
899 confirmText: __( 'Delete', 'presto-player' ),
900 destructive: true,
901 confirmCallback: () => handleBulkDelete( selectedIds ),
902 cancelCallback: () => setOpenActionPopup( false ),
903 } );
904 setOpenActionPopup( true );
905 } }
906 onStatusChange={ handleBulkStatusChange }
907 onCancel={ handleBulkCancel }
908 />
909
910 <div className="gap-0">
911 { showFilter && (
912 <Filters
913 postCount={ postCount }
914 setPostCount={ handlePostCountChange }
915 perPageLabel={ __( 'Posts', 'presto-player' ) }
916 selects={ [
917 {
918 options: filterStatusOptions,
919 value: selectedStatus,
920 onChange: handleStatusChange,
921 },
922 {
923 options: tagOptions.map( ( t ) => ( {
924 value: t.id,
925 label: t.name,
926 } ) ),
927 value: selectedTag,
928 onChange: handleTagChange,
929 },
930 ] }
931 onClear={ handleClearFilters }
932 />
933 ) }
934
935 <Table checkboxSelection={ true }>
936 <Table.Head
937 selected={
938 selected.length > 0 && selected.length === items.length
939 }
940 onChangeSelection={ toggleSelectAll }
941 indeterminate={
942 selected.length > 0 && selected.length < items.length
943 }
944 className="bg-background-primary items-center"
945 >
946 <Table.HeadCell
947 onClick={ () => handleSort( 'title' ) }
948 style={ { width: '300px' } }
949 className="cursor-pointer items-center gap-2 text-text-secondary"
950 >
951 { __( 'Media', 'presto-player' ) }
952 <ChevronsUpDown
953 width="15"
954 height="15"
955 className={ `text-icon-secondary align-middle ml-2${
956 sortField === 'title' && sortOrder === 'asc'
957 ? ' rotate-180'
958 : ''
959 }` }
960 />
961 </Table.HeadCell>
962
963 <Table.HeadCell
964 style={ { width: '100px' } }
965 className="text-text-secondary items-center"
966 >
967 { __( 'Status', 'presto-player' ) }
968 </Table.HeadCell>
969
970 <Table.HeadCell
971 style={ { width: '260px' } }
972 className="text-text-secondary"
973 >
974 <span className="inline-flex items-center gap-1.5">
975 { __( 'Tags', 'presto-player' ) }
976 <Tooltip
977 content={ __(
978 'Tags help organize and filter your media library.',
979 'presto-player'
980 ) }
981 arrow
982 placement="top"
983 >
984 <Info className="size-3.5 text-icon-secondary cursor-help shrink-0" />
985 </Tooltip>
986 </span>
987 </Table.HeadCell>
988
989 <Table.HeadCell
990 style={ { width: '220px' } }
991 className="text-text-secondary"
992 >
993 <span className="inline-flex items-center gap-1.5">
994 { __( 'Shortcode', 'presto-player' ) }
995 <Tooltip
996 content={ __(
997 'Copy the shortcode to embed this media in any post, page, or widget.',
998 'presto-player'
999 ) }
1000 arrow
1001 placement="top"
1002 >
1003 <Info className="size-3.5 text-icon-secondary cursor-help shrink-0" />
1004 </Tooltip>
1005 </span>
1006 </Table.HeadCell>
1007
1008 <Table.HeadCell
1009 onClick={ () => handleSort( 'date' ) }
1010 style={ { width: '180px' } }
1011 className="cursor-pointer items-center gap-2 text-text-secondary"
1012 >
1013 { __( 'Published on', 'presto-player' ) }
1014 <ChevronsUpDown
1015 width="15"
1016 height="15"
1017 className={ `text-icon-secondary align-middle ml-2${
1018 sortField === 'date' && sortOrder === 'asc'
1019 ? ' rotate-180'
1020 : ''
1021 }` }
1022 />
1023 </Table.HeadCell>
1024
1025 <Table.HeadCell
1026 style={ { width: '140px' } }
1027 className="items-center justify-center"
1028 >
1029 <span className="sr-only">{ __( 'Actions', 'presto-player' ) }</span>
1030 </Table.HeadCell>
1031 </Table.Head>
1032
1033 <Table.Body>
1034 { loading ? (
1035 // Refetch in flight (page change, filter, sort, search).
1036 // Swap to skeleton rows so the user gets immediate
1037 // feedback instead of stale rows with no indicator.
1038 Array.from( { length: postCount } ).map( ( _, idx ) => (
1039 <MediaHubRowSkeleton key={ `row-skeleton-${ idx }` } />
1040 ) )
1041 ) : items.length > 0 ? (
1042 items.map( ( item ) => (
1043 <MediaRow
1044 key={ item.id }
1045 // MediaRow reads `post_date` and `poster_image` field
1046 // names from a long-standing API shape — alias here so
1047 // the new server-driven payload stays focused on the
1048 // canonical `date` / `poster` keys.
1049 item={ {
1050 ...item,
1051 post_date: item.date,
1052 poster_image: item.poster,
1053 } }
1054 selected={ selected.includes( item.id ) }
1055 onChangeSelection={ handleCheckboxChange }
1056 onEditClick={ onEditClick }
1057 renderActionMenu={ renderActionMenu }
1058 getBadge={ getBadge }
1059 formatPublishDate={ formatPublishDate }
1060 handleOpenSettings={ handleOpenSettings }
1061 />
1062 ) )
1063 ) : (
1064 <tr>
1065 <td
1066 colSpan="7"
1067 className="px-6 py-8 text-center text-sm text-text-secondary"
1068 >
1069 { __( 'No media found.', 'presto-player' ) }
1070 </td>
1071 </tr>
1072 ) }
1073 </Table.Body>
1074
1075 { ( loading || items.length > 0 ) && (
1076 <Table.Footer className="bg-background-primary">
1077 <div className="flex items-center justify-between w-full">
1078 <span className="text-sm font-normal leading-5 text-text-secondary">
1079 { `${ startIndex }${ endIndex } ${ __(
1080 'of',
1081 'presto-player'
1082 ) } ${ totalItems } ${ __( 'items', 'presto-player' ) }` }
1083 </span>
1084 <Pagination className="w-fit">
1085 <Pagination.Content>
1086 { renderPagination() }
1087 </Pagination.Content>
1088 </Pagination>
1089 </div>
1090 </Table.Footer>
1091 ) }
1092 </Table>
1093 </div>
1094 </Container>
1095 </Container>
1096
1097 <ConfirmPopup
1098 openConfirmPopup={ openActionPopup }
1099 setOpenConfirmPopup={ setOpenActionPopup }
1100 title={ actionPopupData?.title || '' }
1101 description={ actionPopupData?.description || '' }
1102 confirmText={ actionPopupData?.confirmText || '' }
1103 cancelText={
1104 actionPopupData?.cancelText || __( 'Cancel', 'presto-player' )
1105 }
1106 confirmCallback={ actionPopupData?.confirmCallback }
1107 cancelCallback={ actionPopupData?.cancelCallback }
1108 destructive={ actionPopupData?.destructive || false }
1109 />
1110 </>
1111 );
1112 };
1113
1114 export default MediaHub;
1115