Jake Vanderwerf
2025-11-04 42fa8304ddb811b0f725f245130f70c0f5e86a6c
inc/meta/MetaForm.php
@@ -15,6 +15,7 @@
class MetaForm
{
   protected int $max_file_size = 5242880;
   protected ?MetaTypeManager $type_manager = null;
   /* ========== MAIN RENDER METHOD ========== */
   public function return(string $name, mixed $value, array $config, bool $showHidden = false)
@@ -24,7 +25,6 @@
   public function render(string $name, mixed $value, array $config, bool $showHidden = false, bool $return = false): mixed
   {
      $out = '';
      if (jvbCheck('hidden', $config) && !$showHidden) {
         return $out;
      }
@@ -32,6 +32,9 @@
      if (!array_key_exists('type', $config)) {
         return $out;
      }
      if (!$value) {
         $value = $this->getDefaultValue($config['type']);
      }
      // Handle hidden display type
      if (array_key_exists('display', $config) && $config['display'] === 'hidden') {
@@ -71,6 +74,18 @@
      return $out;
   }
   public function getDefaultValue(string $type):mixed {
      if (!$this->type_manager) {
         $this->type_manager = new MetaTypeManager();
      }
      return match ($this->type_manager->getMetaType($type)) {
         'object', 'array' => [],
         'boolean' => false,
         'integer' => 0,
         default => '',
      };
   }
   /* ========== HELPER METHODS ========== */
   /**
@@ -164,6 +179,7 @@
      if (!empty($field['validation_message'])) {
         $attrs['data-validation-message'] = $field['validation_message'];
      }
      $attrs['data-type'] = $field['type'];
      $attrString = '';
@@ -217,44 +233,6 @@
      <?php
   }
   protected function renderComplexFieldWrapper(string $name, array $field, callable $renderContent): void
   {
      $data = $this->prepareFieldData($name, $field['value'] ?? '', $field);
      $validationAttrs = $this->buildValidationAttributes($field);
      $conditional = array_key_exists('condition', $field) ? $this->handleConditionalField($field) : '';
      $describedBy = (!empty($field['description'])) ? ' aria-describedby="' . $name . '-help"' : '';
      // Additional data attributes for complex fields
      $dataAttrs = '';
      if (array_key_exists('data', $field) && !empty($field['data'])) {
         foreach ($field['data'] as $key => $val) {
            $dataAttrs .= ($val === '') ? ' data-' . $key : ' data-' . $key . '="' . esc_attr($val) . '"';
         }
      }
      ?>
      <div class="field <?= esc_attr($field['type']) ?> <?= esc_attr($name) ?>"
         <?= $conditional ?>
          data-field="<?= esc_attr($name) ?>"
         <?= $validationAttrs ?>
         <?= $dataAttrs ?>
         <?= $describedBy ?>>
         <?php if (!empty($field['label']) && (!isset($field['show_label']) || $field['show_label'])) : ?>
            <h3 class="field-label"><?= esc_html($field['label']) ?></h3>
         <?php endif; ?>
         <?php $this->renderHintAndDescription($field, $name); ?>
         <div class="field-content">
            <?php $renderContent($name, $data, $field); ?>
         </div>
         <span class="validation-message" hidden role="alert"></span>
      </div>
      <?php
   }
   /**
    * Render field label with optional character count
    */
@@ -280,20 +258,36 @@
    */
   protected function renderHintAndDescription(array $field, string $name): void
   {
      if (array_key_exists('hint', $field)) {
      if (!empty($field['hint'])) {
         $this->renderHint($field['hint']);
      }
      if (array_key_exists('description', $field)) {
      if (!empty($field['description'])) {
         $this->renderDescription($field['description'], $name);
      }
   }
   protected function renderHint(string $hint): void
   {
      ?>
      <span class="hint"><?= esc_html($hint) ?></span>
      <?php
   }
   protected function renderDescription(string $description, string $name): void
   {
      ?>
      <p class="description" id="<?= esc_attr($name) ?>-help">
         <?= wp_kses_post($description) ?>
      </p>
      <?php
   }
   /* ========== SIMPLE INPUT FIELD TYPES ========== */
   public function renderTextField(string $name, mixed $value, array $field): void
   {
      $this->renderStandardInput($name, $value, $field, $field['input_type'] ?? 'text');
      $this->renderStandardInput($name, $value, $field, $field['subtype'] ?? 'text');
   }
   public function renderEmailField(string $name, mixed $value, array $field): void
@@ -524,15 +518,16 @@
            </legend>
            <?php foreach ($field['options'] as $key => $label) : ?>
               <label class="radio-option">
                  <input
                     type="radio"
                     name="<?= esc_attr($data['name']) ?>"
                     value="<?= esc_attr($key) ?>"
                     <?php checked($value, $key); ?>
                     <?= !empty($field['required']) ? 'required' : '' ?>
                  >
                  <span><?= esc_html($label) ?></span>
               <input
                  type="radio"
                  id="<?= esc_attr($data['name']) ?>-<?= esc_attr($key)?>"
                  name="<?= esc_attr($data['name']) ?>"
                  value="<?= esc_attr($key) ?>"
                  <?php checked($value, $key); ?>
                  <?= !empty($field['required']) ? 'required' : '' ?>
               >
               <label class="radio-option" for="<?= esc_attr($data['name']) ?>-<?= esc_attr($key)?>">
                  <span><?= $label ?></span>
               </label>
            <?php endforeach; ?>
         </fieldset>
@@ -627,20 +622,33 @@
   /* ========== REPEATER FIELD ========== */
   private function renderRepeaterField(string $name, mixed $value, array $field): void
   private function renderRepeaterField(string $name, mixed $value, array $field):void
   {
      error_log('Rendering Repeater Field!');
      $values = is_array($value) ? $value : array();
      $conditional = $this->handleConditionalField($field);
      $row_label = isset($field['row_label']) ? $field['row_label'] : '';
      $rowTitle = (array_key_exists('new_row', $field)) ? $field['new_row'] : 'New Item';
      if (array_key_exists('group', $field)) {
         $name = $field['group'] . '::' . $name;
         $name = $field['group'].'::'.$name;
      }
      $this->renderComplexFieldWrapper($name, $field, function($name, $data, $field) use ($value) {
         $values = is_array($value) ? $value : [];
         $rowLabel = $field['row_label'] ?? '';
         $rowTitle = $field['new_row'] ?? 'New Item';
         $addLabel = $field['add_label'] ?? 'Add Item';
      $describedBy = (!empty($field['description'])) ? ' aria-describedby="'.$name.'-help"' : '';
      ?>
      <div class="field repeater <?=$name?>"
          data-field="<?= esc_attr($name); ?>"
         <?= $describedBy ?>
         <?= $row_label ? 'data-label="' . esc_attr($row_label) . '"' : ''; ?>
         <?=$conditional?>>
         <?php
         if (!array_key_exists('label', $field)) {
            error_log('No label for: '.print_r($name, true));
         }
         ?>
         <div class="repeater-items" data-label="<?= esc_attr($rowLabel) ?>">
         <h3><?= esc_html($field['label']); ?></h3>
         <div class="repeater-items">
            <?php
            if (!empty($values)) {
               foreach ($values as $index => $row) {
@@ -650,39 +658,45 @@
            ?>
         </div>
         <template class="<?= uniqid('repeaterTemplate') ?>">
            <?php $this->renderRepeaterRow($field['fields'], [], '', $name, $rowTitle); ?>
         <template class="<?=uniqid('repeaterTemplate')?>">
            <?php $this->renderRepeaterRow($field['fields'], array(), '', '', $rowTitle); ?>
         </template>
         <button type="button" class="add-repeater-row button secondary">
            <?= jvbIcon('plus', ['title' => 'Add']) ?>
            <span><?= esc_html($addLabel) ?></span>
         <button type="button" class="add-repeater-row">
            <?= jvbIcon('plus', ['title'=> 'Add']); ?> <?= (array_key_exists('add_label', $field)) ? $field['add_label'] : 'Add Item'; ?>
         </button>
         <?php
      });
         <?php if (array_key_exists('hint', $field)) { $this->renderHint($field['hint']); }  ?>
         <?php if (array_key_exists('description', $field)) { $this->renderDescription($field['description'], $name); }  ?>
      </div>
      <?php
   }
   private function renderRepeaterRow(array $fields, array $values, int|string $index, string $base_name, string $rowTitle): void
   private function renderRepeaterRow(array $fields, array $values, int|string $index, string $base_name, string $rowTitle = 'New Item'):void
   {
      $display_number = is_string($index) ? $index : ($index + 1);
      $display_number = (is_string($index)) ? $index : ($index + 1);
      ?>
      <div class="repeater-row" data-index="<?= esc_attr($index) ?>">
         <details <?= is_string($index) ? 'open' : '' ?>>
      <div class="repeater-row" data-index="<?= esc_attr($index); ?>">
         <details <?= (is_string($index)) ? 'open' : ''; ?>>
            <summary class="repeater-row-header row btw">
               <span class="drag-handle"><?= jvbIcon('grab') ?></span>
               <span class="row-number">#<?= esc_html($display_number) ?></span>
               <span class="row-title"><?= esc_html($this->getRowTitle($fields, $values, $rowTitle)) ?></span>
               <span class="drag-handle"><?= jvbIcon('grab'); ?></span>
               <span class="row-number">#<?= esc_html($display_number); ?></span>
               <span class="row-title"><?= esc_html($this->getRowTitle($fields, $values, $rowTitle)); ?></span>
               <button type="button" class="remove-row" title="Remove">
                  <?= jvbIcon('delete', ['title' => 'Remove']) ?>
                  <?= jvbIcon('delete', ['title'=>'Remove']); ?>
               </button>
            </summary>
            <div class="repeater-row-content">
               <?php
               foreach ($fields as $slug => $field) {
                  $field_name = ($base_name === '') ? $slug : sprintf('%s:%s:%s', $base_name, $index, $slug);
                  $field_value = $values[$slug] ?? '';
                  $this->render($field_name, $field_value, $field);
               }
               foreach ($fields as $slug => $field) :
                  if ($base_name === '') {
                     $field_name = $slug;
                  } else {
                     $field_name = sprintf('%s:%s:%s', $base_name, $index, $slug);
                  }
                  $field_value = isset($values[$slug]) ? $values[$slug] : '';
                  $name = $field_name;
                  $this->render($name, $field_value, $field);
               endforeach;
               ?>
            </div>
         </details>
@@ -776,22 +790,30 @@
   }
   /* ========== UPLOAD FIELD ========== */
   private function renderGalleryField(string $name, mixed $value, array $field):void
   {
      $field['multiple'] = true;
      $this->renderUploadField($name, $value, $field);
   }
   private function renderUploadField(string $name, mixed $value, array $field): void
   {
      // Merge with defaults
      $config = array_merge([
         'subtype' => 'image',
         'accepted_types' => null,
         'multiple' => false,
         'limit' => 0,
         'mode' => 'direct',
         'destination' => 'meta',
         'max_size' => null,
         'convert' => 'webp',
         'quality' => 80,
      $defaultConfig = [
         //File Type
         'subtype' => 'image', // 'image', 'video', 'document', 'any'
         'accepted_types' => null, // null = use subtype defaults, or array of specific MIME types
         //Upload Behaviour
         'multiple' => false, // Single or multiple uploads
         'limit' => 0, // Max number of uploads (0 = unlimited)
         'mode' => 'direct', // 'direct' or 'selection'
         //Destination
         'destination' => 'meta', // 'meta', 'post', 'post_group'
         //Processing Options
         'max_size' => null, // Override default size limits
         'convert' => 'webp', // Image conversion format
         'quality' => 80, // Conversion quality
         'create_thumbnails' => true,
      ], $field);
      ];
      $config = array_merge($defaultConfig, $field);
      // Validate destination config
      if (in_array($config['destination'], ['post', 'post_group']) && empty($config['content'])) {
@@ -799,69 +821,217 @@
         return;
      }
      if (array_key_exists('group', $field)) {
      // Get accepted types
      $acceptedTypes = $this->getAllowedTypes($config);
      // Build accept attribute for input
      $acceptExtensions = $this->getMimeExtensions($acceptedTypes);
      $acceptAttr = implode(',', $acceptExtensions);
      // Determine field attributes
      $subtype = $config['subtype'] ?? 'image';
      $multiple = $config['multiple'] ?? false;
      $limit = $config['limit'] ?? 0;
      $mode = $config['mode'] ?? 'direct';
      $destination = $config['destination'];
      // Get existing attachments
      $attachmentIds = $this->parseAttachmentIds($value);
      // Determine field type for UI
      $fieldType = $multiple ? 'gallery' : 'single';
      // Build data attributes
      $dataAttrs = [
         'data-field' => $name,
         'data-upload-field' => '',
         'data-mode' => $mode,
         'data-type' => $fieldType,
         'data-subtype' => $subtype,
         'data-destination' => $destination,
      ];
      if (!empty($field['content'])) {
         $dataAttrs['data-content'] = $field['content'];
      }
      if ($limit > 0) {
         $dataAttrs['data-limit'] = $limit;
      }
      // Build data attributes
      $conditional = $this->handleConditionalField($field);
      $describedBy = !empty($field['description']) ? ' aria-describedby="' . esc_attr($name) . '-help"' : '';
      if (!empty($field['group'])) {
         $name = $field['group'] . '::' . $name;
      }
      // Prepare upload configuration
      $acceptedTypes = $this->getAllowedTypes($config);
      $acceptExtensions = $this->getMimeExtensions($acceptedTypes);
      $acceptAttr = implode(',', $acceptExtensions);
      $attachmentIds = $this->parseAttachmentIds($value);
      $fieldType = $config['multiple'] ? 'gallery' : 'image';
      // Build data attributes for uploader.js
      $uploadData = [
         'data-subtype' => $config['subtype'],
         'data-mode' => $config['mode'],
         'data-destination' => $config['destination'],
         'data-multiple' => $config['multiple'] ? 'true' : 'false',
         'data-limit' => $config['limit'],
         'data-convert' => $config['convert'],
         'data-quality' => $config['quality'],
      ];
      if (!empty($config['content'])) {
         $uploadData['data-content'] = $config['content'];
      // Convert data attributes to string
      $dataAttrString = '';
      foreach ($dataAttrs as $attr => $val) {
         $dataAttrString .= ' ' . $attr . ($val !== '' ? '="' . esc_attr($val) . '"' : '');
      }
      ?>
      <div class="field upload <?= esc_attr($name) ?>"
         <?= $dataAttrString ?>
         <?= $conditional ?>>
      $this->renderComplexFieldWrapper($name, $field, function($name, $data, $field) use (
         $config, $acceptAttr, $attachmentIds, $fieldType, $uploadData, $value
      ) {
         ?>
         <div class="upload-field-wrapper <?= esc_attr($fieldType) ?>"
         <?php foreach ($uploadData as $attr => $val) : ?>
            <?= $attr ?>="<?= esc_attr($val) ?>"
         <?php endforeach; ?>>
         <!-- Preview Area -->
         <div class="upload-preview-area">
            <?php $this->renderUploadPreviews($attachmentIds, $config); ?>
         </div>
         <!-- Upload Area -->
         <div class="file-upload-container">
            <div class="file-upload-wrapper">
               <input type="file"
                     name="<?= esc_attr($data['name']) ?>_temp"
                     name="<?= !empty($field['base']) ? esc_attr($field['base']) : '' ?><?= esc_attr($name) ?>_temp"
                     id="<?= !empty($field['base']) ? esc_attr($field['base']) : '' ?><?= esc_attr($name) ?>_temp"
                     accept="<?= esc_attr($acceptAttr) ?>"
                  <?= $config['multiple'] ? 'multiple' : '' ?>>
                     data-max-size="<?= esc_attr($this->getMaxFileSize($subtype)) ?>"
                  <?= $multiple ? 'multiple' : '' ?>
                  <?= !empty($field['required']) ? 'required' : '' ?>>
               <h2><?= esc_html($field['label']) ?></h2>
               <?php if (!empty($field['description'])) : ?>
                  <p><?= esc_html($field['description']) ?></p>
               <?php endif; ?>
               <p class="file-upload-text">
                  <strong>Click to upload</strong> or drag and drop<br>
                  <?= esc_html($this->getUploadInstructions($config)) ?>
                  <?= esc_html($this->getAcceptedTypesLabel($subtype, $acceptExtensions)) ?>
                  (max. <?= esc_html($this->formatFileSize($this->getMaxFileSize($subtype))) ?>)
               </p>
               <?php if ($destination === 'post_group') {
                  $plural = (array_key_exists($field['content'], JVB_CONTENT)) ? JVB_CONTENT[$field['content']]['plural'] : (array_key_exists($field['content'], JVB_TAXONOMY) ? JVB_TAXONOMY[$field['content']]['plural'] : str_replace('_', ' ',$field['content']).'s');
                  $singular = (array_key_exists($field['content'], JVB_CONTENT)) ? JVB_CONTENT[$field['content']]['singular'] : (array_key_exists($field['content'], JVB_TAXONOMY) ? JVB_TAXONOMY[$field['content']]['singular'] : str_replace('_', ' ',$field['content']));
                  ?>
                  <p class="hint">You can group images to create separate <?= $plural ?>.</p>
                  <p class="hint">If a <?=$singular?> has multiple images, you can select the <?= jvbIcon('star')?> to set an image as the main one.</p>
               <?php }
               if (!empty($field['upload_description'])) : ?>
                  <p><?= esc_html($field['upload_description']); ?></p>
               <?php endif; ?>
               <div class="file-error"></div>
            </div>
            <div class="file-error"></div>
         </div>
         <!-- Hidden input for storing the IDs -->
         <input type="hidden"
               name="<?= esc_attr($data['name']) ?>"
               value="<?= esc_attr($value) ?>"
            <?= !empty($field['required']) ? 'required' : '' ?>>
         <?php if ($destination === 'post_group') : ?>
         <div class="group-display flex col" hidden>
            <div class="preview-wrap flex col">
               <div class="preview-actions">
                  <div class="selection-controls">
                     <div class="selected">
                        <div class="field">
                           <input type="checkbox" id="select-all-uploads" name="select-all-uploads">
                           <label for="select-all-uploads">
                              Select All
                           </label>
                        </div>
                        <div class="info" hidden>
                        </div>
                     </div>
                     <div class="selection-actions row btw" hidden>
                        <button type="button" data-action="add-to-group">
                           <?= jvbIcon('add') ?>
                           Group
                        </button>
                        <button type="button" data-action="delete-upload">
                           <?= jvbIcon('delete') ?>
                           Delete
                        </button>
                     </div>
                  </div>
                  <button type="button" data-action="upload" class="submit-uploads">
                     <?= jvbIcon('upload') ?> Upload <?= esc_html($plural ?? 'Content'); ?>
                  </button>
               </div>
               <?php endif; ?>
               <?php jvbRenderProgressBar('<span class="text">Processing files...</span>
               <span class="count">0/0</span>'); ?>
               <div class="item-grid preview">
                  <?php
                  // Render existing attachments
                  foreach ($attachmentIds as $attachmentId) {
                     echo $this->renderExistingAttachment($attachmentId, $subtype);
                  }
                  ?>
               </div>
               <?php if ($destination === 'post_group') : ?>
               <p class="hint"><?= jvbIcon('elbow-left-up') ?>  These will become individual <?= $plural ?>  <?= jvbIcon('elbow-right-up')?></p>
            </div>
            <div class="sidebar flex col">
               <div class="header">
                  <h4>New <?= $plural?></h4>
                  <p class="hint">Drag or select multiple images into groups to create separate <?= $plural ?>.</p>
               </div>
               <div class="item-grid groups">
                  <div class="empty-group">
                     <p>Drag here to create a new <?= $singular ?>!</p>
                  </div>
               </div>
               <p class="hint"><?= jvbIcon('elbow-left-up') ?>  Each group will become its own <?= $singular ?>  <?= jvbIcon('elbow-right-up')?></p>
            </div>
         </div>
         <?php
      });
      <?php endif; ?>
         <?php if ($destination === 'meta') : ?>
            <input type="hidden"
                  name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>"
                  value="<?= esc_attr($value); ?>"
               <?= !empty($field['required']) ? 'required' : ''; ?>>
         <?php endif; ?>
      </div>
      <?php
   }
   /**
    * Get max file size for subtype
    */
   private function getMaxFileSize(string $subtype): int
   {
      $sizes = [
         'image' => 5242880,    // 5MB
         'video' => 104857600,  // 100MB
         'document' => 10485760 // 10MB
      ];
      return $sizes[$subtype] ?? $sizes['image'];
   }
   /**
    * Format file size for display
    */
   private function formatFileSize(int $bytes): string
   {
      if ($bytes >= 1073741824) {
         return number_format($bytes / 1073741824, 1) . 'GB';
      }
      if ($bytes >= 1048576) {
         return number_format($bytes / 1048576, 1) . 'MB';
      }
      if ($bytes >= 1024) {
         return number_format($bytes / 1024, 1) . 'KB';
      }
      return $bytes . 'B';
   }
   /**
    * Get accepted types label
    */
   private function getAcceptedTypesLabel(string $subtype, array $extensions): string
   {
      $labels = [
         'image' => 'JPG, PNG, GIF, or WEBP',
         'video' => 'MP4, WEBM, or MOV',
         'document' => 'PDF, DOC, XLS, or TXT',
         'any' => 'Images, Videos, or Documents'
      ];
      return $labels[$subtype] ?? strtoupper(implode(', ', array_map(function($ext) {
         return ltrim($ext, '.');
      }, array_slice($extensions, 0, 3))));
   }
   /**
@@ -874,20 +1044,241 @@
      }
      foreach ($attachmentIds as $id) {
         if ($config['subtype'] === 'image') {
            $url = wp_get_attachment_image_url($id, 'thumbnail');
            if ($url) {
               echo '<div class="upload-item" data-id="' . esc_attr($id) . '">';
               echo '<img src="' . esc_url($url) . '" alt="">';
               echo '<button type="button" class="remove-preview">' .
                  jvbIcon('trash', ['title' => 'Remove']) . '</button>';
               echo '</div>';
            }
         switch ($config['subtype']) {
            case 'image':
               $this->renderImagePreview($id, $config);
               break;
            case 'video':
               $this->renderVideoPreview($id, $config);
               break;
            case 'file':
               $this->renderFilePreview($id, $config);
               break;
         }
         // Add other subtypes (video, document) as needed
      }
   }
   public function renderImagePreview(?int $id = null, array $config = []):void
   {
      $attachment = ($id) ? wp_get_attachment_image($id, 'thumbnail', false) : false;
      $caption = ($id) ? wp_get_attachment_caption($id) : '';
      $alt = ($id) ? get_post_meta($id, '_wp_attachment_image_alt',true) : '';
      $title = ($id) ? get_the_title($id) : '';
      $addID = ($id) ? '-'.$id : '';
      $dataID = ($id) ? ['id' => $id] : '';
      ?>
      <div class="item upload"<?= ($id) ? ' data-id="'.$id.'"' : '' ?>>
            <div class="preview">
               <?php jvbRenderProgressBar('',true) ?>
               <input type="checkbox" class="upload-select" name="select-item" id="select-item<?=$addID?>">
               <label for="select-item<?=$addID?>" aria-label="Select image">
                  <?= ($attachment) ? $attachment : '<img>
                  <video></video>
                  <span></span>' ?>
               </label>
               <div class="item-actions row btw">
                  <div class="radio-button">
                     <input type="radio" class="featured btn" name="featured" id="featured" hidden>
                     <label for="featured">
                        <?=jvbIcon('star')?>
                        <?=jvbIcon('star', ['style' => 'fill'])?>
                        <span class="screen-reader-text">Set as featured image</span>
                     </label>
                  </div>
                  <button type="button" data-action="delete-upload" title="Remove from Group">
                     <?=jvbIcon('delete')?>
                  </button>
               </div>
            </div>
            <details>';
               <summary class="row btw"><?=jvbIcon('edit')?><span>Edit Info</span></summary>
      <?php
      $fields = array_key_exists('fields', $config) ? $config['fields'] : [];
      $fields = array_merge([
         'upload_data'  => [
            'type'   => 'group',
            'wrap'   => 'details',
            'label'  => 'Image Info',
            'hint'   => 'These will be automatically generated if left blank.',
            'fields' => [
               'image-title'.$addID => [
                  'type'   => 'text',
                  'label'  => 'Image Title',
                  'value'  => $title,
                  'data'   => $dataID
               ],
               'image-alt-text'.$addID => [
                  'type'   => 'text',
                  'label'  => 'Alt Text',
                  'value'  => $alt,
                  'hint'   => 'Alt text helps the visually impaired, as well as some benefits for SEO.',
                  'data'   => $dataID
               ],
               'image-caption'.$addID => [
                  'type'   => 'textarea',
                  'value'  => $caption,
                  'label'  => 'Image Caption',
                  'data'   => $dataID
               ]
            ]
         ]
      ], $fields);
      $this->render('upload_data', null, $fields);
      ?>
            </details>
      </div>
      <?php
   }
   public function renderVideoPreview(?int $id = null, array $config = []):void
   {
      $attachment = ($id) ? wp_get_attachment_image($id, 'thumbnail', true) : false;
      $caption = ($id) ? wp_get_attachment_caption($id) : '';
      $description = ($id) ? get_the_content($id) : '';
      $title = ($id) ? get_the_title($id) : '';
      $addID = ($id) ? '-'.$id : '';
      $dataID = ($id) ? ['id' => $id] : '';
      ?>
      <div class="item upload"<?= ($id) ? ' data-id="'.$id.'"' : '' ?>>
            <div class="preview">
               <?php jvbRenderProgressBar('',true) ?>
               <input type="checkbox" class="upload-select" name="select-item" id="select-item<?=$addID?>">
               <label for="select-item<?=$addID?>" aria-label="Select image">
                  <?= ($attachment) ? $attachment : '<img>
                  <video></video>
                  <span></span>'; ?>
               </label>
               <div class="item-actions row btw">
                  <div class="radio-button">
                     <input type="radio" class="featured btn" name="featured" id="featured" hidden>
                     <label for="featured">
                        <?=jvbIcon('star')?>
                        <?=jvbIcon('star', ['style' => 'fill'])?>
                        <span class="screen-reader-text">Set as featured image</span>
                     </label>
                  </div>
                  <button type="button" data-action="delete-upload" title="Remove from Group">
                     <?=jvbIcon('delete')?>
                  </button>
               </div>
            </div>
            <details>';
               <summary class="row btw"><?=jvbIcon('edit')?><span>Edit Info</span></summary>
      <?php
      $fields = array_key_exists('fields', $config) ? $config['fields'] : [];
      $fields = array_merge([
         'upload_data'  => [
            'type'   => 'group',
            'wrap'   => 'details',
            'label'  => 'Video Info',
            'hint'   => 'These will be automatically generated if left blank.',
            'fields' => [
               'title' => [
                  'type'   => 'text',
                  'label'  => 'Video Title',
                  'value'  => $title,
                  'data'   => $dataID
               ],
               'caption' => [
                  'type'   => 'textarea',
                  'value'  => $caption,
                  'label'  => 'Video Caption',
                  'data'   => $dataID
               ],
               'description' => [
                  'type'   => 'textarea',
                  'value'  => $description,
                  'label'  => 'Video Description',
                  'data'   => $dataID
               ]
            ]
         ]
      ], $fields);
      $this->render('upload_data', null, $fields);
      ?>
            </details>
      </div>
      <?php
   }
   public function renderFilePreview(?int $id = null, array $config = []):void
   {
      $attachment = ($id) ? wp_get_attachment_image($id, 'thumbnail', true) : false;
      $caption = ($id) ? wp_get_attachment_caption($id) : '';
      $description = ($id) ? get_the_content($id) : '';
      $title = ($id) ? get_the_title($id) : '';
      $addID = ($id) ? '-'.$id : '';
      $dataID = ($id) ? ['id' => $id] : '';
      ?>
      <div class="item upload"<?= ($id) ? ' data-id="'.$id.'"' : '' ?>>
            <div class="preview">
               <?php jvbRenderProgressBar('',true) ?>
               <input type="checkbox" class="upload-select" name="select-item" id="select-item<?=$addID?>">
               <label for="select-item<?=$addID?>" aria-label="Select image">
                  <?= ($attachment) ? $attachment : '<img>
                  <video></video>
                  <span></span>'; ?>
               </label>
               <div class="item-actions row btw">
                  <div class="radio-button">
                     <input type="radio" class="featured btn" name="featured" id="featured" hidden>
                     <label for="featured">
                        <?=jvbIcon('star')?>
                        <?=jvbIcon('star', ['style' => 'fill'])?>
                        <span class="screen-reader-text">Set as featured image</span>
                     </label>
                  </div>
                  <button type="button" data-action="delete-upload" title="Remove from Group">
                     <?=jvbIcon('delete')?>
                  </button>
               </div>
            </div>
            <details>';
               <summary class="row btw"><?=jvbIcon('edit')?><span>Edit Info</span></summary>
      <?php
      $fields = array_key_exists('fields', $config) ? $config['fields'] : [];
      $fields = array_merge([
         'upload_data'  => [
            'type'   => 'group',
            'wrap'   => 'details',
            'label'  => 'File Info',
            'hint'   => 'These will be automatically generated if left blank.',
            'fields' => [
               'title' => [
                  'type'   => 'text',
                  'label'  => 'File Title',
                  'value'  => $title,
                  'data'   => $dataID
               ],
               'caption' => [
                  'type'   => 'textarea',
                  'value'  => $caption,
                  'label'  => 'File Caption',
                  'data'   => $dataID
               ],
               'description' => [
                  'type'   => 'textarea',
                  'value'  => $description,
                  'label'  => 'File Description',
                  'data'   => $dataID
               ]
            ]
         ]
      ], $fields);
      $this->render('upload_data', null, $fields);
      ?>
            </details>
      </div>
      <?php
   }
   /**
    * Get upload instruction text based on config
    */
@@ -903,7 +1294,7 @@
   /* ========== TAXONOMY/USER SELECTOR FIELDS ========== */
   private function renderTaxonomyField(string $name, mixed $value, array $field): void
   private function renderTaxonomyField(string $name, string $value, array $field): void
   {
      if (array_key_exists('group', $field)) {
         $name = $field['group'] . '::' . $name;
@@ -912,7 +1303,7 @@
      $this->renderSelectorField($name, $value, $field, 'taxonomy');
   }
   private function renderUserField(string $name, mixed $value, array $field): void
   private function renderUserField(string $name, string $value, array $field): void
   {
      if (array_key_exists('group', $field)) {
         $name = $field['group'] . '::' . $name;
@@ -931,55 +1322,64 @@
      $validationAttrs = $this->buildValidationAttributes($field);
      $describedBy = (!empty($field['description'])) ? ' aria-describedby="' . $name . '-help"' : '';
      $isSimple = (array_key_exists('mode', $field) && $field['mode']==='simple');
      // Parse selected values
      $value = (is_array($value)) ? array_filter(array_map('absint', $value)): $value;
      $selected = ($value === '') ? [] : (is_array($value) ? $value : explode(',', $value));
      // Create selector instance
      // Generate unique container ID
      $containerId = $name . '-' . $type . '-selector';
      // Create selector instance with proper parameters
      if ($type === 'taxonomy') {
         $taxonomy = $field['taxonomy'];
         $icon = JVB_TAXONOMY[$taxonomy]['icon']??'';
         // Map field config to selector config
         $selectorConfig = [
            'multiple' => $field['multiple'] ?? true,
            'placeholder' => $field['placeholder'] ?? 'Search terms...',
            'noResults' => 'No terms found',
            'onClose' => 'updateMetaFormTaxonomy'
            'max'    => $field['max'] ?? 0,  // 0 = unlimited
            'search' => $field['search'] ?? true,
            'label'     => $field['label'] ?? '',
            'createNew' => $field['createNew'] ?? false,
            'required'  => $field['required'] ?? false,
            'base'      => $field['base'] ?? '',
            'update' => $field['update'] ?? true,
            'name'      => $name,
            'autocomplete' => $field['autocomplete'] ?? false,
         ];
         $selector = new TaxonomySelector($taxonomy, $selectorConfig);
         if ($icon !== '') {
            $selectorConfig['icon'] = $icon;
         }
         $selector = new TaxonomySelector($containerId, $taxonomy, $selectorConfig);
         $icon = $taxonomy;
      } else {
         $postType = $field['post_type'];
         // Map field config to selector config
         $selectorConfig = [
            'multiple' => $field['multiple'] ?? true,
            'placeholder' => $field['placeholder'] ?? 'Search posts...',
            'noResults' => 'No posts found',
            'shop_id' => $field['shop_id'] ?? null,
            'onClose' => 'updateMetaFormPost'
            'max'    => $field['max'] ?? 0,
            'search' => $field['search'] ?? true,
            'label'     => $field['label'] ?? '',
            'required'  => $field['required'] ?? false,
            'base'      => $field['base'] ?? '',
            'update' => $field['update'] ?? true,
            'shop_id'   => $field['shop_id'] ?? null,
            'autocomplete'=> $field['autocomplete'] ?? true,
         ];
         $selector = new PostSelector($postType, $selectorConfig);
         $selector = new PostSelector($containerId, $postType, $selectorConfig);
         $icon = $postType;
      }
      $containerId = $name . '-' . $type . '-selector';
      ?>
      <div class="field <?= esc_attr($type) ?>-selector <?= esc_attr($name) ?>"
      <div class="field <?= esc_attr($type) ?> <?= esc_attr($name) ?>"
         <?= $conditional ?>
          data-field="<?= esc_attr($name) ?>"
         <?= $validationAttrs ?>
         <?= $describedBy ?>>
         <div class="field-group-header row btw">
            <label class="toggle row">
               <?= jvbIcon($icon) ?>
               <span><?= esc_html($field['label'] ?? ucfirst($type)) ?></span>
            </label>
            <button type="button"
                  class="add-item-btn button secondary"
                  title="Add <?= esc_attr(ucfirst($type)) ?>">
               <?= jvbIcon('add', ['title' => 'Add ' . ucfirst($type)]) ?>
            </button>
         </div>
         <?= $selector->render($selected, $containerId) ?>
         <?= $selector->render($selected) ?>
         <!-- Hidden input for form submission -->
         <input type="hidden"
@@ -1006,7 +1406,7 @@
         return;
      }
      // Parse stored data
      // Extract stored values
      if (is_string($value)) {
         $value = maybe_unserialize($value);
      }
@@ -1015,15 +1415,18 @@
      $address = $stored_data['address'] ?? '';
      $lat = $stored_data['lat'] ?? '';
      $lng = $stored_data['lng'] ?? '';
      $street = $stored_data['street'] ?? '';
      // Generate unique field ID
      $field_id = esc_attr($name);
      $map_id = $field_id . '_map';
      // Handle grouped fields
      if (array_key_exists('group', $field)) {
         $name = $field['group'] . '::' . $name;
      }
      $describedBy = (!empty($field['description'])) ? ' aria-describedby="'.$name.'-help"' : '';
      // Prepare JavaScript configuration
      $field_id = esc_attr($name);
      $map_id = $field_id . '_map';
      // Prepare configuration for JavaScript initialization
      $js_config = [
         'fieldId' => $field_id,
         'initialCoords' => (!empty($lat) && !empty($lng)) ? [
@@ -1032,42 +1435,67 @@
         ] : null
      ];
      $this->renderComplexFieldWrapper($name, $field, function($name, $data, $field) use (
         $stored_data, $street, $address, $lat, $lng, $map_id, $js_config
      ) {
         ?>
         <div class="location-field-wrapper"
             data-location-field-init="<?= esc_attr(json_encode($js_config)) ?>">
      // IMPORTANT: Properly escape the JSON for use in HTML attribute
      $json_config = htmlspecialchars(json_encode($js_config), ENT_QUOTES, 'UTF-8');
      ?>
            <?php if (!empty($street)) : ?>
               <p class="current-location">
                  <strong>Current location:</strong> <?= esc_html($street) ?>
               </p>
            <?php endif; ?>
      <div class="field location <?= esc_attr($field_id) ?>"
          data-field="<?= esc_attr($field_id) ?>"
          data-location-field-init="<?= $json_config ?>"<?=$describedBy?>>
            <label for="<?= esc_attr($data['id']) ?>">Address</label>
            <input type="text"
                  id="<?= esc_attr($data['id']) ?>"
                  name="<?= esc_attr($data['name']) ?>[address]"
                  value="<?= esc_attr($address) ?>"
                  placeholder="Enter an address"
                  class="location-search-input"
                  autocomplete="off"
               <?= !empty($field['required']) ? 'required' : '' ?>>
            <div id="<?= esc_attr($map_id) ?>" class="location-map" style="height: 300px;"></div>
            <!-- Hidden fields for lat/lng -->
            <input type="hidden" name="<?= esc_attr($data['name']) ?>[lat]" value="<?= esc_attr($lat) ?>" class="location-lat">
            <input type="hidden" name="<?= esc_attr($data['name']) ?>[lng]" value="<?= esc_attr($lng) ?>" class="location-lng">
            <input type="hidden" name="<?= esc_attr($data['name']) ?>[street]" value="<?= esc_attr($stored_data['street'] ?? '') ?>" class="location-street">
            <input type="hidden" name="<?= esc_attr($data['name']) ?>[city]" value="<?= esc_attr($stored_data['city'] ?? '') ?>" class="location-city">
            <input type="hidden" name="<?= esc_attr($data['name']) ?>[province]" value="<?= esc_attr($stored_data['province'] ?? '') ?>" class="location-province">
            <input type="hidden" name="<?= esc_attr($data['name']) ?>[postal]" value="<?= esc_attr($stored_data['postal'] ?? '') ?>" class="location-postal">
            <input type="hidden" name="<?= esc_attr($data['name']) ?>[country]" value="<?= esc_attr($stored_data['country'] ?? '') ?>" class="location-country">
         </div>
         <?php
      });
         if (!empty($stored_data['street'])) {
            echo '<p><b>Current location:</b> '.esc_html($stored_data['street']).'</p>';
            echo '<p class="hint"><b>Search below to change:</b></p>';
         }
         ?>
         <?php if (array_key_exists('hint', $field)) { $this->renderHint($field['hint']); }  ?>
         <?php if (array_key_exists('description', $field)) { $this->renderDescription($field['description'], $name); }  ?>
         <div class="location-search-wrapper">
            <div class="autocomplete-wrapper"></div>
            <!-- Map container -->
            <div class="location-preview">
               <div id="<?= esc_attr($map_id); ?>"
                   class="location-map">
               </div>
               <?php if (!empty($stored_data)):
                  jvbLocationLinks($stored_data);
               endif; ?>
            </div>
            <!-- Hidden inputs for data storage -->
            <input type="hidden"
                  name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>[address]"
                  value="<?= esc_attr($address); ?>"
                  data-location-field="address">
            <input type="hidden"
                  name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>[lat]"
                  value="<?= esc_attr($lat); ?>"
                  data-location-field="lat">
            <input type="hidden"
                  name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>[lng]"
                  value="<?= esc_attr($lng); ?>"
                  data-location-field="lng">
            <?php
            // Component fields
            $components = ['street', 'city', 'province', 'postal_code', 'country'];
            foreach ($components as $component):
               ?>
               <input type="hidden"
                     name="<?= array_key_exists('base', $field) ? esc_attr($field['base']) : ''?><?= esc_attr($name); ?>[<?= $component; ?>]"
                     value="<?= esc_attr($stored_data[$component] ?? ''); ?>"
                     data-location-field="<?= esc_attr($component); ?>">
            <?php endforeach; ?>
         </div>
      </div>
      <?php
   }
   /* ========== HTML FIELD ========== */
@@ -1109,22 +1537,6 @@
      );
   }
   protected function renderHint(string $hint): void
   {
      ?>
      <span class="hint"><?= esc_html($hint) ?></span>
      <?php
   }
   protected function renderDescription(string $description, string $name): void
   {
      ?>
      <p class="description" id="<?= esc_attr($name) ?>-help">
         <?= wp_kses_post($description) ?>
      </p>
      <?php
   }
   protected function getAllowedTypes(array $config): array
   {
      if (!empty($config['accepted_types'])) {