Jake Vanderwerf
2026-01-02 b5abd615697146beeca6dba4acd057d049554a30
inc/managers/CRUDManager.php
@@ -1,511 +1,242 @@
<?php
namespace JVBase\managers;
use JVBase\managers\UserTermsManager;
use JVBase\meta\MetaForm;
use JVBase\meta\MetaManager;
use JVBase\ui\CRUDSkeleton;
use JVBase\utility\Features;
use WP_User;
if (!defined('ABSPATH')) {
   exit; // Exit if accessed directly
   exit;
}
/**
 * WordPress CRUD Manager
 * Configures CRUDSkeleton for WordPress post types
 */
class CRUD {
   protected WP_User $user;
   protected int $user_id;
   protected CRUDSkeleton $skeleton;
   protected CacheManager $cache;
   protected array $config;
   protected string $content;
   protected string $singular;
   protected string $plural;
   protected array $filters;
   protected array $bulkActions;
   protected MetaManager $meta;
   protected MetaForm $form;
   protected array $taxonomies;
   protected array $statuses;
   protected array $fields;
   protected array $sections;
   protected array $stuck;
   protected array $taxonomies = [];
   protected int $user_id;
   protected ?string $type = null;
   protected ?array $constant = null;
   //For Timeline-specific posts
   protected bool $isTimeline = false;
   protected array $nonTimelineFields = [];
   protected array $timelineSharedFields = [];
   protected array $timelineUniqueFields = [];
   protected bool $userCanPublish = false;
   public function __construct(string $content)
   {
      //If we haven't defined this content, bail early
      if (!array_key_exists($content, JVB_CONTENT)) {
   public function __construct(string $content) {
      if (array_key_exists($content, JVB_CONTENT)) {
         $this->type = 'post';
         $this->constant = JVB_CONTENT;
      } elseif (array_key_exists($content, JVB_TAXONOMY)) {
         $this->type = 'term';
         $this->constant = JVB_TAXONOMY;
      } elseif (array_key_exists($content, JVB_USER)) {
         $this->type = 'user';
         $this->constant = JVB_USER;
      } else {
         return;
      }
      $this->user = wp_get_current_user();
      $this->user_id = $this->user->ID;
      $this->config = JVB_CONTENT[$content];
      $this->singular = $this->config['singular'];
      $this->plural = $this->config['plural'];
      $this->content = $content;
      $this->fields = jvbGetFields($this->content, 'post');
      $this->maybeSetupTimeline();
      $this->sections = jvbGetSections($this->content, 'post');
      $this->stuck = [
         'post_title',
         'term_name'
      ];
      $this->init();
      $this->user_id = get_current_user_id();
      $this->config = $this->constant[$content];
      $this->content = $content;
      $this->cache = CacheManager::for($content);
      // Create and configure skeleton
      $this->skeleton = new CRUDSkeleton();
      $this->configure();
   }
   /**
    * Configure CRUDSkeleton from WordPress config
    */
   protected function configure(): void {
      // Basic info
      $this->skeleton
         ->content($this->content, $this->config['singular'], $this->config['plural'])
         ->title(
            'Your ' . $this->config['plural'],
            $this->config['page_description'] ?? ''
         );
      // Initialize meta
      $this->skeleton->initMeta($this->type, $this->content);
      // Timeline if applicable
      if (Features::forContent($this->content)->has('is_timeline')) {
         $this->skeleton->setTimeline();
      }
      // Fields and sections
      $this->skeleton->setFields($this->config['fields']);
      $sections = array_key_exists('sections', $this->config) ? $this->config['sections'] : [];
      foreach ($sections as $id => $config) {
         $this->skeleton->addSection($id, $config);
      }
      // Taxonomies
      $this->initTaxonomies();
      // Statuses
      if (Features::forContent($this->content)->has('is_calendar')) {
         $this->skeleton->setCalendar();
      }
      $this->skeleton->setDefaultStatus();
      // Views
      $this->skeleton
         ->addViews()
         ->defaultView('grid');
      $this->skeleton->addItemActions();
      // Filters
      $this->skeleton->addDateFilter();
      $this->skeleton->addCustomDateRange($this->addDateRanges());
      if (!empty($this->taxonomies)) {
         $this->skeleton->addTaxonomyFilter(array_keys($this->taxonomies), 'user');
      }
      // Capabilities
      $this->skeleton->addCapabilities(['view', 'edit', 'create', 'delete']);
      $plural = strtolower($this->config['plural'] ?? $this->content . 's');
      $canPublish = jvbUserIsVerified() && user_can($this->user_id, "publish_{$plural}");
      $this->skeleton->userCanPublish($canPublish);
      // Bulk actions
      $this->skeleton->addBulkActions(['edit', 'publish', 'draft', 'trash']);
      // Uploader
      $this->setupUploader();
      // Sticky fields
      $stuck = ['post_title', 'term_name'];
      if ($this->skeleton->get('isTimeline')) {
         $stuck[] = 'post_thumbnail';
      }
      $this->skeleton->stickFields($stuck);
      // Hook for create button
      add_filter('jvbAdditionalActions', [$this, 'createItem']);
   }
   protected function init():void
   {
      $this->initStatuses();
      $this->initBulkActions();
      $this->initTaxonomies();
      $this->initFilters();
      $this->meta = new MetaManager(null, 'post', $this->content);
      $this->form = new MetaForm();
      $plural = strtolower($this->config['plural']??$this->content.'s');
      $this->userCanPublish = (jvbUserIsVerified()) ?
               user_can($this->user_id, "publish_{$plural}") : false;
   /**
    * Setup uploader configuration
    */
   protected function setupUploader(): void {
      $isSingleImage = jvbCheck('single_image', $this->config);
   }
      $config = [
         'type' => 'upload',
         'subtype' => 'image',
         'mode' => $isSingleImage ? 'direct' : 'selection',
         'create_new' => true,
         'label' => $this->config['upload_title'] ?? 'Bulk Upload ' . $this->config['plural'],
         'content' => $this->content,
         'singular' => $this->config['singular'],
         'plural' => $this->config['plural'],
         'multiple' => true,
         'destination' => $isSingleImage ? 'post' : 'post_group'
      ];
   protected function maybeSetupTimeline():void {
      $this->isTimeline = Features::forContent($this->content)->has('is_timeline');
      if (!$this->isTimeline) {
         return;
      if (!$isSingleImage) {
         $config['upload_text'] = '<p>Drag images into groups. Each group becomes its own ' . $this->config['singular'] . '.</p>
            <p>You can also select multiple images and click the "Add to Group" button.</p>
            <p>If a ' . $this->config['singular'] . ' has multiple images, you can select the ' . jvbDashIcon('star') . ' to set an image as the main one.</p>
            <p>Images left ungrouped will become individual ' . $this->config['plural'] . '</p>
            <p>Once finished, click the \'Save Changes\' button to send to server for processing.</p>';
      } else {
         $config['description'] = 'Each image will become its own ' . $this->config['singular'] . '.';
      }
      $this->timelineSharedFields = array_keys(array_filter($this->fields, function ($field) {
         if (!array_key_exists('for_all', $field) || $field['for_all'] === false){
            return true;
         }
         return false;
      }));
      array_unshift($this->timelineSharedFields, 'post_thumbnail');
      array_unshift($this->timelineSharedFields, 'post_title');
      $this->timelineUniqueFields = array_keys(array_filter($this->fields, function ($field) {
         if (array_key_exists('for_all', $field) && $field['for_all'] === true) {
            return true;
         }
         return false;
      }));
      $all = array_merge($this->timelineUniqueFields, $this->timelineSharedFields);
      $this->nonTimelineFields = array_filter($this->fields, function ($field) use ($all) {
         return !in_array($field, $all);
      }, ARRAY_FILTER_USE_KEY);
      $this->skeleton->addUploader($config);
   }
   protected function initTaxonomies():void
   {
   /**
    * Initialize taxonomies from WordPress config
    */
   protected function initTaxonomies(): void {
      $this->taxonomies = array_filter(JVB_TAXONOMY, function ($config) {
         return in_array($this->content, $config['for_content']);
      });
   }
   protected function initStatuses():void
   {
      $this->statuses = (array_key_exists('is_calendar', $this->config)) ?
   /**
    * Get statuses - calendar or standard
    */
   protected function getStatuses(): array {
      return array_key_exists('is_calendar', $this->config) ?
         [
            'all' => [
               'icon'  => 'calendar',
            'all' => [
               'icon' => 'calendar',
               'label' => 'Everything',
            ],
            'future'=> [
               'label'  => 'Upcoming',
               'icon'   => 'clock-clockwise',
            'future' => [
               'label' => 'Upcoming',
               'icon' => 'clock-clockwise',
            ],
            'past'   => [
               'label'  => 'Past',
               'icon'   => 'clock-counter-clockwise',
            'past' => [
               'label' => 'Past',
               'icon' => 'clock-counter-clockwise',
            ],
            'repeat'=> [
               'label'  => 'Recurring',
               'icon'   => 'repeat',
            'repeat' => [
               'label' => 'Recurring',
               'icon' => 'repeat',
            ],
            'draft'  => [
               'icon'   => 'eye-closed',
               'label'  => 'Hidden',
            'draft' => [
               'icon' => 'eye-closed',
               'label' => 'Hidden',
            ],
            'trash'  => [
               'label'  => 'Scrapped',
               'icon'   => 'trash',
            'trash' => [
               'label' => 'Scrapped',
               'icon' => 'trash',
            ],
         ] :
         [
            'all' => [
               'icon'  => 'infinity',
            'all' => [
               'icon' => 'infinity',
               'label' => 'Everything',
            ],
            'publish'=> [
               'icon'   => 'eye',
               'label'  => 'Live',
            'publish' => [
               'icon' => 'eye',
               'label' => 'Live',
            ],
            'draft'  => [
               'icon'   => 'eye-closed',
               'label'  => 'Hidden',
            'draft' => [
               'icon' => 'eye-closed',
               'label' => 'Hidden',
            ],
            'trash'  => [
               'label'  => 'Scrapped',
               'icon'   => 'trash',
            'trash' => [
               'label' => 'Scrapped',
               'icon' => 'trash',
            ],
         ];
   }
   protected function initBulkActions():void
   {
      $this->bulkActions = [
         'edit'   => 'Edit',
         'publish'   => 'Show',
         'draft'  => 'Hide',
//       'copy'   => 'Duplicate',
         'trash'  => 'Scrap'
      ];
   }
   protected function initFilters():void
   {
      $this->filters = [
         'status' => $this->statuses,
         'date'      => [
            'label'  => 'Date',
            'icon'   => 'calendar'
         ]
      ];
      foreach ($this->taxonomies as $taxonomy=> $config) {
         $this->filters['taxonomy'][$taxonomy] = [
            'label'  => $config['singular'],
            'icon'   => $config['icon']??'folder'
         ];
      }
   }
   public function render():void
   {
      ob_start();
      ?>
      <div class="dashboard-page <?= esc_attr($this->content) ?>">
         <?php
         $this->renderHeader();
         $this->renderContent();
         $this->renderModals();
         $this->renderTemplates();
         ?>
      </div>
      <?php
      echo ob_get_clean();
   }
   protected function renderHeader():void
   {
      ?>
      <h1>Your <?= $this->config['plural'] ?></h1>
      <?php
      if (array_key_exists('page_description', $this->config)) {
         ?>
         <p class="page-description"><?=$this->config['page_description']?></p>
         <?php
      }
      $this->renderHeaderActions();
   }
   protected function renderHeaderActions():void
   {
      $uploadConfig = [
         'type'         => 'upload',
         'subtype'      => 'image',
         'mode'         => (jvbCheck('single_image', $this->config)) ? 'direct' : 'selection',
         'create_new'   => true,
         'label'        => (array_key_exists('image_title', $this->config)) ? $this->config['image_title'] : 'Upload More '.$this->config['plural'],
         'content'      => $this->content,
         'singular'     => $this->singular,
         'plural'    => $this->plural,
         'multiple'     => true,
         'destination'  => 'post'
      ];
      if (!array_key_exists('single_image', $this->config) || $this->config['single_image'] === false) {
         $uploadConfig['destination'] = 'post_group';
      }
      $uploadConfig['destination'] = 'post_group';
      if (!jvbCheck('single_image', $this->config)) {
         $uploadConfig['label'] = 'Create '.$this->config['plural'];
         $uploadConfig['upload_text'] = '<p>Drag images into groups. Each group becomes its own '.$this->singular.'.</p>
                  <p>You can also select multiple images and click the "Add to Group" button.</p>
                  <p>If a '.$this->singular.' has multiple images, you can select the '.jvbIcon('star').' to set an image as the main one.</p>
                  <p>Images left ungrouped will become individual '.$this->plural.'</p>
                  <p>Once finished, click the \'Save Changes\' button to send to server for processing.</p>';
      } else {
         $uploadConfig['description'] = 'Each image will become its own '.$this->singular.'.';
      }
      ?>
      <details open class="uploader">
         <summary class="row btw"><?= $this->config['upload_title'] ?? 'Bulk Upload '.$this->plural?></summary>
         <?php
         $this->meta->render(
            'form',
            'new_'.$this->content,
            $uploadConfig
         );
         ?>
      </details>
      <?php
   }
   protected function renderContent():void
   {
      ?>
      <section class="items-list <?=$this->content?> crud" data-content="<?= $this->content ?>">
         <?php
         $this->renderFilters();
         $this->renderBulkControls();
         ?>
         <div class="<?= $this->content ?> item-grid" role="grid"></div>
         <div class="scroll-sentinel" aria-hidden="true"></div>
      </section>
      <?php
      $state = apply_filters('jvbEmptyState', $this->renderEmptyState(), $this->content);
      echo '<template class="emptyState">'.$state.'</template>';
      ?>
      <?php
   }
   protected function renderEmptyState():string
   {
      ob_start();
      ?>
      <div class="empty-state">
         <h3><?=jvbIcon($this->config['icon'])?>Nothing here<?=jvbIcon($this->config['icon'])?></h3>
         <p>It doesn't look like you have any <?=$this->config['plural'] ?> yet.</p>
         <p><small><i>Add many by uploading images above.</i>, or click the "<?=jvbIcon('plus-square')?>" button to add one at a time.</small></p>
      </div>
      <?php
      return ob_get_clean();
   }
   protected function renderFilters():void
   {
      ?>
      <div class="all-filters col start" data-ignore>
         <div class="search row start nowrap">
            <span class="label">Search:</span>
            <?= jvbSearch() ?>
         </div>
         <div class="controls col start">
            <?php
            $this->renderViewFilters();
            $this->renderStatusFilters();
            $this->renderOrderFilters();
            ?>
         </div>
         <div class="filters row start">
            <span class="label">Filters:</span>
            <?php
               $this->renderTaxonomyFilters();
               $this->renderDateFilters();
            ?>
            <button type="button" class="clear-filters row" hidden>
               <?= jvbIcon('x', ['title'    => 'Clear']); ?>
               Clear All Filters
            </button>
         </div>
         <?= $this->renderColumnSelector(); ?>
      </div>
      <?php
   }
   protected function renderOrderFilters():void
   {
      ?>
      <div class="radio-options order row btw w-full">
         <?php
            $order = [
               'orderby' => [
                  'calendar' => 'Order by date created',
                  'alphabetical' => 'Order alphabetically'
               ],
               'order' => [
                  'sort-ascending' => 'In ascending order (Z-A, oldest to newest)',
                  'sort-descending' => 'In descending order (A-Z, newest to oldest)'
               ]
            ];
            foreach ($order as $o => $option) {
               ?>
               <div class="row start">
                  <span class="label"><?= ucfirst($o)?>:</span>
               <?php
               $i = 0;
               foreach ($option as $opt => $label) {
                  ?>
                  <input id="<?=$opt?>" class="btn" type="radio" name="<?=$o?>" data-filter="<?=$o?>" value="<?=$opt?>"<?=$i===0 ? ' checked':''?>>
                  <label for="<?=$opt?>" title="<?=$label?>"><?=jvbIcon($opt)?></label>
                  <?php
                  $i++;
               }
               ?>
               </div>
               <?php
            }
         ?>
      </div>
      <?php
   }
   protected function renderStatusFilters():void
   {
      if (empty($this->statuses)) {
         return;
      }
      ?>
      <div class="radio-options status row">
         <span class="label">Status:</span>
         <?php
         $i = 1;
         foreach ($this->statuses as $status => $config) {
            $checked = ($i == 1) ? ' checked' : '';
            ?>
            <input type="radio" class="btn" data-filter="status" value="<?=$status?>" name="status" id="<?=$status?>"<?=$checked?>>
            <label for="<?=$status?>">
               <?= jvbIcon($config['icon']) ?>
               <span><?=$config['label']?><span class="count"></span></span>
            </label>
            <?php
            $i++;
         }
         ?>
      </div>
      <?php
   }
   protected function renderViewFilters():void
   {
      ?>
      <div class="radio-options view row">
         <span class="label">View:</span>
         <?php
         $views = [
            'grid' => ['icon' => 'squares-four', 'label' => 'Grid View'],
            'list' => ['icon' => 'rows', 'label' => 'List View'],
            'table' => ['icon' => 'table', 'label' => 'Table View']
         ];
         $first = true;
         foreach ($views as $view => $config):
            ?>
            <input type="radio"
                  data-view="<?= esc_attr($view) ?>"
                  value="<?= esc_attr($view) ?>"
                  class="btn"
                  name="view"
                  id="view-<?= esc_attr($view) ?>"
               <?= $first ? 'checked' : '' ?>>
            <label for="view-<?= esc_attr($view) ?>"
                  title="<?= esc_attr($config['label']) ?>">
               <?= jvbIcon($config['icon']) ?>
               <span class="screen-reader-text"><?= esc_html($config['label']) ?></span>
            </label>
            <?php
            $first = false;
         endforeach;
         ?>
      </div>
      <?php
   }
   /**
    * Render column selector for table view
    * Add create button to dashboard actions
    */
   protected function renderColumnSelector(): string {
      ob_start();
      ?>
      <details class="multi-select" title="Select columns" hidden>
         <summary class="row start nowrap">
            <?= jvbIcon('columns') ?>
            <span class="labels">Toggle Columns</span>
         </summary>
         <div class="column-list">
            <?php foreach ($this->fields as $fieldName => $config):
               if (array_key_exists('hidden', $config)){
                  continue;
               }
               ?>
               <input type="checkbox"
                     id="show-<?= esc_attr($fieldName) ?>"
                     class="column-toggle ch"
                     name="show-<?= esc_attr($fieldName) ?>"
                     checked>
               <label for="show-<?= esc_attr($fieldName) ?>">
                  <?= esc_html($config['label']) ?>
               </label>
            <?php endforeach; ?>
         </div>
      </details>
      <?php
      return ob_get_clean();
   public function createItem(array $actions): array {
      $actions[] = [
         'button' => '<button type="button" class="create-item row" title="Create New ' . $this->config['singular'] . '">'
            . jvbDashIcon('plus-square')
            . '<span class="screen-reader-text">Create New ' . $this->config['singular'] . '</span></button>',
         'content' => '', // Modal is rendered by skeleton
      ];
      return $actions;
   }
   protected function renderTaxonomyFilters():void
   protected function addDateRanges():array
   {
      if (empty($this->taxonomies)) {
         return;
      }
      $out = '';
      foreach ($this->taxonomies as $taxonomy => $config) {
         $terms = $this->getCommonTerms($taxonomy);
         if (!empty($terms)) {
            $out .= sprintf(
               '<div class="row nowrap"><label for="filter-%s">%s<span class="screen-reader-text">Filter by %s</span></label>
                <select id="filter-%s" class="filter %s" name="%s" data-filter="taxonomies" data-taxonomy="%s">
                <option value="">by %s</option>',
               $taxonomy,
               jvbIcon($config['icon'], ['title'    => $config['plural']]),
               esc_html($config['plural']),
               $taxonomy,
               $taxonomy,
               $taxonomy,
               $taxonomy,
               $config['plural']
            );
            foreach ($terms as $term) {
               $out .= sprintf(
                  '<option value="%s">%s</option>',
                  esc_attr($term['term_id']),
                  esc_html($term['name'])
               );
            }
            $out .= '</select></div>';
         }
      }
      echo $out;
   }
   /**
    * Get common terms for taxonomy
    * @param string $taxonomy
    * @return array
    */
   protected function getCommonTerms(string $taxonomy):array {
      $manager = new UserTermsManager();
      return $manager->getUserTerms($this->user_id, $taxonomy);
   }
   protected function renderDateFilters():void
   {
      $postType = jvbCheckBase($this->content);
      // Get available months
      global $wpdb;
      $months = $wpdb->get_results("
      return $this->cache->remember(
         'dateRanges',
         function() {
            $postType = jvbCheckBase($this->content);
            // Get available months
            global $wpdb;
            $months = $wpdb->get_results("
         SELECT DISTINCT
            YEAR(post_date) as year,
            MONTH(post_date) as month
@@ -514,657 +245,28 @@
         AND post_author = '{$this->user_id}'
         ORDER BY post_date DESC
      ");
      // Quick filters
      $out = '<div class="row nowrap">
        <label for="filter-date">'.
         jvbIcon('calendar',['title'=>'Date']).
         '<span class="screen-reader-text">by Date</span>
        </label>
        <select id="filter-date" class="date-filter" data-filter="date">
            <option value="">by Date</option>
            <option value="today">Today</option>
            <option value="week">Past Week</option>
            <option value="month">Past Month</option>
            <option value="year">Past Year</option>
            <option value="custom">Custom Range...</option>
        </select>
    </div>';
      $form = '<div class="custom-range row">
            <label for="date-start" class="col">
                From
            </label>
         <input type="date" id="date-start" class="date-start">
            <label for="date-end" class="col">
               To
            </label>
            <input type="date" id="date-end" class="date-end">
        </div>
        <div class="month-picker">
            <label>
                <span>Or select month</span>
                <select class="month-select">
                    <option value="">&emsp; . . . &emsp;</option>';
      foreach ($months as $date) {
         $month_name = date('F Y', mktime(0, 0, 0, $date->month, 1, $date->year));
         $value = $date->year . '-' . str_pad($date->month, 2, '0', STR_PAD_LEFT);
         $form .= sprintf(
            '<option value="%s">%s</option>',
            esc_attr($value),
            esc_html($month_name)
         );
      }
      $form .= '</select>
            </label>
        </div>';
      // Custom date range
      $out .= jvbNewModal(
         'date-range',
         'Filter Results by Date:',
         $form
      );
      echo $out;
   }
   protected function renderBulkControls():void
   {
      if (empty($this->bulkActions)) {
         return;
      }
      ?>
      <div class="bulk-controls row nowrap btw">
         <div class="bulk-select">
            <input type="checkbox" id="select-all" class="select-all">
            <label for="select-all" class="row"><span>Select All</span><span class="selected-count" hidden></span></label>
         </div>
         <div class="bulk-actions row nowrap" hidden>
            <label for="bulk-action-select" class="screen-reader-text">
               Select what to do with this selection.
            </label>
            <select class="bulk-action-select" id="bulk-action-select">
            </select>
         </div>
      </div>
      <template class="notTrashOptions">
         <select class="wrap">
            <option value="">Bulk Actions...</option>
            <?php
            foreach ($this->bulkActions as $control => $label) {
               $disabled = ($control === 'publish' && !$this->userCanPublish) ? ' disabled' : '';
               ?>
               <option value="<?=$control?>"<?=$disabled?>><?=$label?></option>
               <?php
            $ranges = [];
            foreach ($months as $date) {
               $month_name = date('F Y', mktime(0, 0, 0, $date->month, 1, $date->year));
               $value = $date->year . '-' . str_pad($date->month, 2, '0', STR_PAD_LEFT);
               $ranges[$value] = $month_name;
            }
            foreach ($this->taxonomies as $taxonomy => $config) {
            ?>
               <option value="tax-<?=$taxonomy?>">Add to <?= $config['singular'] ?></option>
            <?php
            }
            ?>
         </select>
      </template>
      <template class="trashOptions">
         <select class="wrap">
            <option value="">Bulk Actions...</option>
            <option value="restore">Restore</option>
            <option value="delete">Permanently Delete</option>
         </select>
      </template>
      <?php
   }
   protected function renderModals():void
   {
//    $this->renderCreateModal();
      $this->renderEditModal();
      $this->renderBulkEditModal();
   }
   protected function renderCreateModal():void
   {
      echo jvbNewModal(
         'create',
         'Creating <span class="count"></span> New '.$this->config['singular'],
         str_replace('edit-form"', 'create-form" data-noautosave', $this->editForm())
            return $ranges;
         }
      );
   }
   protected function editForm():string
   {
      ob_start();
      ?>
      <form class="edit-form" data-save="content" data-form-id="edit-<?=$this->content?>" data-autosave<?= ($this->isTimeline) ? ' data-timeline' : ''?>>
         <?= jvbFormStatus() ?>
         <input type="hidden" name="form-id" value="<?=uniqid('new-')?>" />
         <input type="hidden" name="content" value="<?=$this->content?>" />
         <div class="fields">
            <div class="field-group radio-options row">
               <span>Status:</span>
               <?php
               $this->getApplicableStatuses('edit');
               ?>
            </div>
            <?php if (!$this->userCanPublish) { ?>
                    <p class="description">Your account needs to be verified before you can publish content.</p>
                <?php }
            if (!empty($this->sections)) {
               $tabs = [];
               foreach ($this->sections as $slug => $title) {
                  $tabs[$slug] = [
                     'title'  => $title,
                     'content' => '',
                     'description' => jvbSectionDescription($slug)??'',
                  ];
                  $icon = jvbSectionIcon($slug);
                  if ($icon !== '') {
                     $tabs[$slug]['icon'] = $icon;
                  }
               }
            } else {
               $tabs = false;
            }
            $fields = $this->fields;
            if (!$this->isTimeline) {
               $first = ['post_thumbnail', 'post_title', 'price'];
               foreach ($first as $f) {
                  if (array_key_exists($f, $fields)) {
                     if ($tabs) {
                        $tabs['basic']['content'] .= $this->meta->render('form', $f, $fields[$f], false, true);
                     } else {
                        $this->meta->render('form', $f, $fields[$f]);
                     }
                     unset($fields[$f]);
                  }
               }
            }
            if ($this->isTimeline) {
               $temp = array_filter($fields, function ($field) {
                  return in_array($field, $this->timelineUniqueFields);
               }, ARRAY_FILTER_USE_KEY);
               $config = [
                  'type'      => 'gallery',
                  'subtype'   => 'timeline',
                  'data'      => 'timeline',
                  'label'     => 'Progression',
                  'fields' => $temp
               ];
               $content = '';
               foreach ($fields as $slug=> $field) {
                  if (in_array($slug, $this->timelineSharedFields)) {
                     $content .= $this->form->render($slug, null, $field, false, true);
                  }
               }
               $content .= $this->meta->render('form', 'timeline', $config, false,true);
               $tabs['progression']['content'] = $content;
               $fields = $this->nonTimelineFields;
            }
            foreach ($fields as $n => $config) {
               if ($tabs) {
                  $section = (array_key_exists('section', $config)) ? $config['section'] : 'basic';
                  $tabs[$section]['content'] .= $this->meta->render('form', $n, $config, false, true);
               } else {
                  $this->meta->render('form', $n, $config);
               }
            }
            if ($tabs) {
               jvbRenderTabs($tabs);
            }
            ?>
         </div>
      </form>
      <?php
      return ob_get_clean();
   }
   protected function renderTimelineFields():string
   {
      ob_start();
      ?>
      <div class="repeater-field timeline-repeater" data-timeline data-field="fields">
         <div class="repeater-rows" data-repeater-container>
            <!-- Parent row (non-draggable) -->
            <div class="repeater-row parent-row" data-row-index="0" data-id="">
               <div class="row-header">
                  <h4>Before (Starting Point)</h4>
               </div>
               <div class="row-fields">
                  <?php $this->renderRowFields(); ?>
               </div>
            </div>
            <!-- Child rows will be added dynamically -->
         </div>
         <button type="button" class="add-repeater-row btn secondary">
            <?= jvbIcon('plus-square') ?>
            <span>Add Progress Step</span>
         </button>
      </div>
      <?php
      return ob_get_clean();
   }
   protected function renderRowFields():void
   {
      $fields = $this->fields;
      // Render priority fields first
      $first = ['post_thumbnail', 'post_title', 'price'];
      foreach ($first as $f) {
         if (array_key_exists($f, $fields)) {
            $this->meta->render('form', $f, $fields[$f]);
            unset($fields[$f]);
         }
      }
      // Render remaining fields
      foreach ($fields as $name => $config) {
         if (!array_key_exists('hidden', $config) || !$config['hidden']) {
            $this->meta->render('form', $name, $config);
         }
      }
   }
   protected function getApplicableStatuses(string $prefix) {
      foreach ($this->statuses as $status => $config) {
         if ($status === 'all') {
            continue;
         }
         if (in_array($status, ['future', 'past'])) {
            if ($status === 'future') {
               $status = 'publish';
               $config = [
                  'icon'   => 'eye',
                  'label'  => 'Live',
               ];
            } else {
               continue;
            }
         }
         $disabled = ($status === 'publish' && !$this->userCanPublish) ? ' disabled' : '';
         ?>
         <input type ="radio"
               name="post_status"
               class="btn"
               value="<?= esc_attr($status)?>"
               id="<?=$prefix?>set-<?= esc_attr($status) ?>"
            <?= $disabled?>>
         <label for="<?=$prefix?>set-<?=esc_attr($status)?>">
            <?= jvbIcon($config['icon'], ['title' => $config['label']]) ?>
            <span><?= esc_html($config['label'])?></span>
         </label>
         <?php
      }
   }
   protected function renderEditModal():void
   {
      echo jvbNewModal(
         'edit',
         'Edit your '.$this->singular,
         $this->editForm()
      );
   }
   protected function renderBulkEditModal():void
   {
      if (empty($this->bulkActions)) return;
      ob_start();
      ?>
      <form class="bulk-edit-form" data-save="content" data-form-id="bulk-edit-<?=$this->content?>">
         <?= jvbFormStatus() ?>
         <div class="selected"></div>
         <p class="description">You can unselect items by clicking the image here.</p>
         <p class="hint"><strong>IMPORTANT: </strong> Whatever changes you make here will be applied to all selected <?=$this->plural?>.</p>
         <div class="fields">
            <div class="field-group radio-options row">
               <?php
               $this->getApplicableStatuses('bulk-');
                    ?>
            </div>
            <?php
            if (!empty($this->taxonomies)) {
            ?>
            <div class="taxonomies">
               <?php
               foreach ($this->taxonomies as $taxonomy => $config) {
                  $this->meta->render(
                     'form',
                     'bulk-edit-'.$taxonomy,
                     [
                        'type'      => 'taxonomy',
                        'label'     => $config['singular'],
                        'taxonomy'  => $taxonomy,
                        'createNew' => jvbUserIsVerified(),
                        'multiple'  => true,
                        'mode'      => 'append'
                     ]
                  );
               }
               ?>
            </div>
            <?php
            }
            $fields = $this->fields;
            $fields = array_filter($fields, function ($field) {
               return array_key_exists('bulkEdit', $field);
            });
            foreach ($fields as $fieldName => $config) {
               $this->meta->render('form', $fieldName, $config);
            }
            ?>
         </div>
      </form>
      <template class="bulkItem">
         <label>
            <input type="checkbox">
            <img>
         </label>
      </template>
      <?php
      $form = ob_get_clean();
      echo jvbNewModal(
         'bulkEdit',
         'Bulk Edit <span class="selected"></span> '.$this->config['plural'],
         $form
      );
   }
   protected function renderTemplates():void
   {
      $this->renderListView();
      $this->renderGridView();
      $this->renderTableView();
      $this->renderTableRow();
      if ($this->isTimeline) {
         $temp = array_filter($this->fields, function ($field) {
            return in_array($field, $this->timelineUniqueFields);
         }, ARRAY_FILTER_USE_KEY);
         $form = new MetaForm();
         echo '<template class="timelineItem">';
         $form->renderImagePreview(null,['fields' => $temp]);
         echo '</template>';
      }
      echo jvbGetEmptyStateTemplate();
      echo jvbGetGalleryPreviewTemplate();
   }
   protected function renderItemSelect():string
   {
      ob_start();
      ?>
      <div class="item-select">
         <input type="checkbox" class="select-item">
         <label class="select-item-label">
            <span class="screen-reader-text">Select this <?= $this->singular ?></span>
         </label>
      </div>
      <?php
      return ob_get_clean();
   }
   protected function renderImage():string
   {
      ob_start();
      ?>
      <img loading="lazy" alt="">
      <?php
      return ob_get_clean();
   }
   protected function renderItemActions():string
   {
      ob_start();
      ?>
      <div class="item-actions">
         <button type="button" class="action" data-action="edit" title="Edit <?= $this->singular ?>">
            <?=jvbIcon('pencil-simple')?>
            <span class="screen-reader-text">Edit <?= $this->singular ?></span>
         </button>
         <button type="button" class="action" data-action="trash" title="Scrap <?= $this->singular ?>">
            <?=jvbIcon('trash')?>
            <span class="screen-reader-text">Scrap <?= $this->singular ?></span>
         </button>
<!--        <button type="button" class="action" data-action="toggle-status">-->
<!--           <span class="screen-reader-text">Toggle --><?php //= $this->singular ?><!-- Visibility</span>-->
<!--        </button>-->
      </div>
      <?php
      return ob_get_clean();
   }
   protected function renderItemFields(bool $form = false):string
   {
      ob_start();
      foreach ($this->fields as $name => $config) {
         $renderMode = $form ? 'form' : 'render';
         $field = $this->meta->render($renderMode, $name, $config, false, true);
         // Special handling for title in grid view
         if ($name === 'post_title' && !$form) {
            $field = str_replace('<p', '<h3', str_replace('</p>', '</h3>', $field));
         }
         echo $field;
      }
      return ob_get_clean();
   }
   protected function renderGridView():void
   {
      ?>
      <template class="gridView">
         <div class="item <?= $this->content ?>">
            <input type="checkbox" class="select-item" name="select-item">
            <label title="Select this <?= $this->singular?>" class="select-item-label">
               <?= $this->renderImage() ?>
            </label>
            <?= $this->renderItemActions(); ?>
         </div>
      </template>
      <?php
   }
   protected function renderListView():void
   {
      ?>
      <template class="listView">
         <div class="item <?=esc_attr($this->content)?> row nowrap">
            <?= $this->renderItemSelect()?>
            <?=$this->renderImage() ?>
            <div class="col start w-full">
               <?= $this->renderItemActions()?>
               <h3 data-field="post_title"></h3>
               <p data-attr="date"></p>
               <p data-field="price"></p>
               <div data-field="post_excerpt"></div>
            </div>
         </div>
      </template>
      <?php
   }
   protected function renderTableView():void
   {
      ?>
      <template class="contentTable">
         <form class="table"
              data-save="content"
              data-content="<?= esc_attr($this->content) ?>"
              data-form-id="content-table-<?= esc_attr($this->content) ?>">
            <?= jvbFormStatus() ?>
            <?= $this->renderTableActions() ?>
            <table>
               <thead>
               <?= $this->renderTableHeader() ?>
               </thead>
               <tbody>
               <!-- Rows will be inserted here -->
               </tbody>
               <tfoot>
               <?= $this->renderTableHeader() ?>
               </tfoot>
            </table>
         </form>
      </template>
      <?php
   }
   /**
    * Render table row template
    * Render the interface
    */
   protected function renderTableRow(): void {
      ?>
      <template class="tableView">
         <tr class="item">
            <td class="select">
               <?= $this->renderItemSelect() ?>
            </td>
            <td class="status">
               <?= $this->renderStatusRadios() ?>
            </td>
            <?php
            $makeDetails = [
               'group',
               'repeater',
               'checkbox',
               'radio'
            ];
            foreach ($this->fields as $name => $config):
               if (array_key_exists('hidden', $config)){
                  continue;
               }
               $makeThisDetailed = (in_array($config['type'], $makeDetails));
               ?>
               <td class="field show-<?= esc_attr($name) ?>" data-field="<?= esc_attr($name) ?>" data-field-type="<?=$config['type']?>"<?=(in_array($name, $this->stuck)) ? ' data-stuck':''?>>
                  <?= $makeThisDetailed ? '<details><summary class="row btw">See Value</summary>' : '' ?>
                  <?php $this->meta->render('form', $name, $config); ?>
                  <?= $makeThisDetailed ? '</details>' : '' ?>
               </td>
            <?php endforeach; ?>
         </tr>
      </template>
      <?php
   }
   /**
    * Render status radio buttons
    */
   protected function renderStatusRadios(): string {
      ob_start();
      ?>
      <div class="radio-options status-options row">
         <?php foreach ($this->statuses as $status => $config):
            if ($status === 'all') continue;
            // Handle special cases
            if ($status === 'future') {
               $status = 'publish';
               $config = [
                  'icon' => 'eye',
                  'label' => 'Live'
               ];
            } elseif ($status === 'past') {
               continue;
            }
            ?>
            <input type="radio"
                  name="post_status"
                  id="status-<?= esc_attr($status) ?>"
                  value="<?= esc_attr($status) ?>">
            <label for="status-<?= esc_attr($status) ?>">
               <?= jvbIcon($config['icon']) ?>
               <span class="screen-reader-text"><?= esc_html($config['label']) ?></span>
            </label>
         <?php endforeach; ?>
      </div>
      <?php
      return ob_get_clean();
   }
   /**
    * Render table header
    */
   protected function renderTableHeader(): string {
      ob_start();
      ?>
      <tr>
         <th scope="col" class="select-header">
            <input type="checkbox" id="select-all" name="select-all">
            <label for="select-all">All</label>
         </th>
         <th scope="col" class="status-header">Status</th>
         <?php foreach ($this->fields as $name => $config):
            if (array_key_exists('hidden', $config)){
               continue;
            }
            ?>
            <th scope="col" class="show-<?= esc_attr($name) ?>"<?= (in_array($name, $this->stuck)) ? ' data-stuck':''?>>
               <?= esc_html($config['label']) ?>
            </th>
         <?php endforeach; ?>
      </tr>
      <?php
      return ob_get_clean();
   public function render(): void {
      $this->skeleton->render();
   }
   /**
    * Render table action controls
    * Get the skeleton instance for further customization
    */
   protected function renderTableActions(): string {
      ob_start();
      ?>
      <div class="table-actions row btw nowrap">
         <?= jvbRenderToggleTextField(
            'vertical',
            'TAB NAV:',
            '',
            jvbIcon('caret-double-down'),
            jvbIcon('caret-double-right')
         ) ?>
         <button type="button" class="add-row" title="Add new row">
            <?= jvbIcon('plus-square') ?>
            <span>Add Row</span>
         </button>
      </div>
      <?php
      return ob_get_clean();
   }
   public function createItem(array $actions):array
   {
      ob_start();
      $this->renderCreateModal();
      $content = ob_get_clean();
      $create = [
         'button' => '<button type="button" class="create-item row" title="Create New '.$this->singular.'">'.jvbIcon('plus-square').'<span class="screen-reader-text">Create New '.$this->singular.'</span></button>',
         'content'   => $content,
      ];
      $actions[] = $create;
      return $actions;
   public function getSkeleton(): CRUDSkeleton {
      return $this->skeleton;
   }
}