Jake Vanderwerf
2025-10-18 b0194e10a87e16797a568d8a30d53ebecd27d8a4
inc/meta/MetaForm.php
@@ -1298,6 +1298,396 @@
        <?php
    }
   private function renderUploadField(string $name, mixed $value, array $field): void
   {
      $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,
      ];
      $config = array_merge($defaultConfig, $field);
      // Validate destination config
      if (in_array($config['destination'], ['post', 'post_group']) && empty($config['content'])) {
         error_log("Upload field '{$name}' has destination '{$config['destination']}' but no content defined");
         return;
      }
      // 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;
      }
      // 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 ?>>
         <div class="file-upload-container">
            <div class="file-upload-wrapper">
               <input type="file"
                     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) ?>"
                     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->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>
         <?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 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
   }
   protected function getAllowedTypes(array $config):array
   {
      $typeMap = [
         'image' => [
            'image/jpeg',
            'image/png',
            'image/gif',
            'image/webp'
         ],
         'video' => [
            'video/mp4',
            'video/webm',
            'video/ogg',
            'video/ogv',
            'video/quicktime'
         ],
         'document' => [
            'application/pdf',
            'application/msword',
            'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
            'application/vnd.ms-excel',
            'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
            'text/plain',
            'text/csv'
         ],
         'any' => [] // Will be merged from all types
      ];
      // If specific types are defined, use those
      if (!empty($config['accepted_types'])) {
         return is_array($config['accepted_types'])
            ? $config['accepted_types']
            : [$config['accepted_types']];
      }
      // Otherwise use subtype defaults
      $subtype = $config['subtype'] ?? 'image';
      if ($subtype === 'any') {
         return array_merge(
            $typeMap['image'],
            $typeMap['video'],
            $typeMap['document']
         );
      }
      return $typeMap[$subtype] ?? $typeMap['image'];
   }
   /**
    * Parse attachment IDs from value
    */
   private function parseAttachmentIds(mixed $value): array
   {
      if (empty($value)) return [];
      if (is_array($value)) {
         return array_filter(array_map('absint', $value));
      }
      return array_filter(array_map('absint', explode(',', $value)));
   }
   /**
    * Get file extensions for MIME types
    */
   private function getMimeExtensions(array $mimeTypes): array
   {
      $extensionMap = [
         'image/jpeg' => ['.jpg', '.jpeg'],
         'image/png' => ['.png'],
         'image/gif' => ['.gif'],
         'image/webp' => ['.webp'],
         'video/mp4' => ['.mp4'],
         'video/webm' => ['.webm'],
         'video/ogg' => ['.ogg'],
         'video/ogv' => ['.ogv'],
         'video/quicktime' => ['.mov'],
         'application/pdf' => ['.pdf'],
         'application/msword' => ['.doc'],
         'application/vnd.openxmlformats-officedocument.wordprocessingml.document' => ['.docx'],
         'application/vnd.ms-excel' => ['.xls'],
         'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' => ['.xlsx'],
         'text/plain' => ['.txt'],
         'text/csv' => ['.csv'],
      ];
      $extensions = [];
      foreach ($mimeTypes as $mime) {
         if (isset($extensionMap[$mime])) {
            $extensions = array_merge($extensions, $extensionMap[$mime]);
         }
      }
      return array_unique($extensions);
   }
   /**
    * 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'];
   }
   /**
    * Get human-readable file size label
    */
   private function getMaxFileSizeLabel(string $subtype): string
   {
      $bytes = $this->getMaxFileSize($subtype);
      $mb = round($bytes / 1048576);
      return "{$mb}MB";
   }
   /**
    * 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))));
   }
   /**
    * Render existing attachment
    */
   private function renderExistingAttachment(int $attachmentId, string $subtype): string
   {
      $attachment = get_post($attachmentId);
      if (!$attachment) return '';
      $url = wp_get_attachment_url($attachmentId);
      $thumbUrl = $subtype === 'image'
         ? wp_get_attachment_image_url($attachmentId, 'medium')
         : $url;
      ob_start();
      ?>
      <div class="upload-item existing" data-attachment-id="<?= esc_attr($attachmentId) ?>" data-subtype="<?= esc_attr($subtype) ?>">
         <div class="preview">
            <?php if ($subtype === 'image') : ?>
               <img src="<?= esc_url($thumbUrl) ?>" alt="<?= esc_attr(get_post_meta($attachmentId, '_wp_attachment_image_alt', true)) ?>">
            <?php elseif ($subtype === 'video') : ?>
               <video src="<?= esc_url($url) ?>" controls></video>
            <?php else : ?>
               <div class="document-preview">
                  <?= jvbIcon('document') ?>
                  <span><?= esc_html(basename($url)) ?></span>
               </div>
            <?php endif; ?>
            <div class="overlay">
               <div class="actions">
                  <button type="button" class="remove" title="Remove">
                     <span class="screen-reader-text">Remove <?= esc_attr($subtype) ?></span>
                     ×
                  </button>
               </div>
            </div>
         </div>
         <?php if ($subtype === 'image') {
            echo jvbImageMeta();
         } ?>
      </div>
      <?php
      return ob_get_clean();
   }
    private function renderImageField(string $name, mixed $value, array $field):void
    {
      $image_url = $title = $alt = $caption = false;
@@ -1327,8 +1717,6 @@
          <?=$dataContent?>
         <?= ' data-type="'.$dataType.'"'?>>
            <div class="file-upload-container">
                <div class="file-upload-wrapper">
                    <input type="file"
@@ -1401,7 +1789,7 @@
               </div>
               <?php if ($groupable) : ?>
               <p class="hint"><?= jvbIcon('elbow-left-up') ?>&emsp;These will become individual <?= $plural ?>&emsp;<?= jvbIcon('elbow-right-up')?></p>
               <p class="hint"><?= jvbIcon('elbow-left-up') ?>  These will become individual <?= $plural ?>  <?= jvbIcon('elbow-right-up')?></p>
            </div>
            <div class="sidebar">
               <div class="header">
@@ -1418,7 +1806,7 @@
                     <p>Drag here to create a new <?= $singular ?>!</p>
                  </div>
               </div>
               <p class="hint"><?= jvbIcon('elbow-left-up') ?>&emsp;Each group will become its own <?= $singular ?>&emsp;<?= jvbIcon('elbow-right-up')?></p>
               <p class="hint"><?= jvbIcon('elbow-left-up') ?>  Each group will become its own <?= $singular ?>  <?= jvbIcon('elbow-right-up')?></p>
            </div>
         </div>
      <?php endif; ?>