Jake Vanderwerf
5 days ago 0dfe1d8afafc59c4a5559c498342668d5a58d6ef
inc/blocks/FeedBlock.php
@@ -2,6 +2,7 @@
namespace JVBase\blocks;
use JVBase\managers\Cache;
use JVBase\meta\Meta;
use JVBase\registrar\Registrar;
use JVBase\base\Site;
use JVBase\forms\TaxonomySelector;
@@ -25,6 +26,9 @@
   protected ?int $contextID = null;
   protected bool $isGallery = false;
   protected array $args;
   protected bool $isContentTax = false;
   protected bool $hasMore = false;
   public function __construct()
   {
@@ -33,6 +37,7 @@
      if (JVB_TESTING) {
         $this->cache->flush();
      }
      $this->cache->flush();
      add_action('init', [$this, 'registerBlock']);
   }
@@ -78,15 +83,26 @@
   protected function determineContent(array $attrs):void
   {
      if (array_key_exists('inheritQuery', $attrs) && $attrs['inheritQuery'] === true) {
         if (is_post_type_archive()) {
         $args = [
            'posts_per_page'  => 36,
            'fields'       => 'ids',
         ];
         if (is_post_type_archive() &&!is_tax()) {
            $obj = get_queried_object();
            $registrar = Registrar::getInstance($obj->name);
            if ($registrar && $registrar->hasFeature('is_timeline')) {
               $args['post_parent'] = 0;
            }
            $this->content = [jvbNoBase($obj->name)];
            $args['post_type'] = $obj->name;
            $this->args = $args;
            return;
         } elseif (!empty(Registrar::getProfileTypes()) && is_singular(Registrar::getProfileTypes())) {
            global $post;
            $author = $post->post_author;
            $role = jvbUserRole($author);
            $args['post_author'] = $author;
            $this->context = jvbNoBase($role);
            $this->contextID = $author;
            $registrar = Registrar::getInstance($role);
@@ -94,38 +110,80 @@
               return;
            }
            $this->content = $registrar->getCreatable();
            $args['post_type'] = array_map('jvbCheckBase', $this->content);
            foreach($args['post_type'] as $post_type) {
               $reg = Registrar::getInstance($post_type);
               if ($reg && $reg->hasFeature('is_timeline')) {
                  $args['post_parent'] = 0;
               }
            }
            $this->args = $args;
            return;
         } elseif (is_tax()) {
            $obj = get_queried_object();
            $this->context = jvbNoBase($obj->taxonomy);
            $this->contextID = $obj->term_id;
            $args['tax_query'] = [];
            $args['tax_query'][] = [
               'taxonomy'  => $obj->taxonomy,
               'terms'     => $obj->term_id,
            ];
            $registrar = Registrar::getInstance($obj->taxonomy);
            if (!$registrar) {
               return;
            }
            if ($registrar->hasFeature('is_content')) {
               //example: tattoo shop, etc TODO
               $this->isContentTax = true;
               $this->content = [$registrar->getBased()];
               return;
            }
            $this->content = array_map(function ($item) { return jvbNoBase($item); }, $registrar->registrar->for);
            $args['post_type'] = array_map('jvbCheckBase', $registrar->registrar->for);
            foreach($args['post_type'] as $post_type) {
               $reg = Registrar::getInstance($post_type);
               if ($reg && $reg->hasFeature('is_timeline')) {
                  $args['post_parent'] = 0;
               }
            }
            $this->args = $args;
            return;
         }
      }
      // not inheriting, getting from config
      $this->content = $attrs['contentTypes']??[];
   }
      protected function getContent():string
      {
         return implode(',', $this->content);
      }
      protected function determineTaxonomies():void
      {
         $taxonomies = [];
         $ignore = [];
         foreach ($this->content as $content) {
            $registrar = Registrar::getInstance($content);
            if (!$registrar) continue;
      $args['post_type'] = array_map('jvbCheckBase', $attrs['contentTypes']);
      $this->args = $args;
   }
   protected function getContent():string
   {
      return implode(',', $this->content);
   }
   protected function determineTaxonomies():void
   {
      $taxonomies = [];
      $ignore = [];
      foreach ($this->content as $content) {
         $registrar = Registrar::getInstance($content);
         if (!$registrar) continue;
         if ($registrar->hasFeature('is_content')) {
            foreach ($registrar->registrar->for as $c) {
               $contentRegistrar = Registrar::getInstance($c);
               if (!$contentRegistrar) continue;
               $theTax = $contentRegistrar->registrar->taxonomies;
               foreach ($theTax as $tax) {
                  if (!in_array($tax, $ignore) && !in_array($tax, $taxonomies)) {
                     $taxReg = Registrar::getInstance($tax);
                     if ($taxReg->hasFeature('show_feed')) {
                        $taxonomies[] = $tax;
                     } else {
                        $ignore[] = $tax;
                     }
                  }
               }
            }
         } else {
            $theTax = $registrar->registrar->taxonomies;
            foreach ($theTax as $tax) {
               if (!in_array($tax, $ignore) && !in_array($tax, $taxonomies)) {
@@ -138,8 +196,9 @@
               }
            }
         }
         $this->taxonomies = array_unique($taxonomies);
      }
      $this->taxonomies = array_unique($taxonomies);
   }
   protected function renderFiltersAndControls():string
   {
@@ -158,42 +217,42 @@
         jvbIcon('x')
      );
   }
      protected function renderSearch():string
      {
         return sprintf(
            '<div class="search row left nowrap">
   protected function renderSearch():string
   {
      return sprintf(
         '<div class="search row left nowrap">
            <span class="label">Search:</span>
            %s
            </div>',
            jvbSearch()
         );
      }
      protected function renderContentLabels():string
      {
         return sprintf(
            '<span class="label">Showing: <span class="current">%s</span></span>',
            $this->content[0]??''
         );
         jvbSearch()
      );
   }
   protected function renderContentLabels():string
   {
      return sprintf(
         '<span class="label">Showing: <span class="current">%s</span></span>',
         $this->content[0]??''
      );
      }
      protected function renderContent():string
      {
         $favourites = '';
         if (Site::has('favourites')) {
            $favourites = sprintf(
               '<input type="checkbox" id="favourites" class="btn" name="favourites" value="on" data-filter="favourites">
   }
   protected function renderContent():string
   {
      $favourites = '';
      if (Site::has('favourites')) {
         $favourites = sprintf(
            '<input type="checkbox" id="favourites" class="btn" name="favourites" value="on" data-filter="favourites">
               <label for="favourites" title="Show Favourites">%s%s<span class="screen-reader-text">Show Favourites Only</span></label>',
               jvbIcon('heart'),
               jvbIcon('heart', ['style' => 'fill'])
            );
         }
         if (count($this->content) === 1) {
            return empty($favourites)
               ? sprintf(
                  '<input type="hidden" data-filter="content" name="content" value="%s">',
                  implode(',', $this->content)
               )
               : sprintf(
            jvbIcon('heart'),
            jvbIcon('heart', ['style' => 'fill'])
         );
      }
      if (count($this->content) === 1) {
         return empty($favourites)
            ? sprintf(
               '<input type="hidden" data-filter="content" name="content" value="%s">',
               implode(',', $this->content)
            )
            : sprintf(
               '<div class="content row right">
                  <input type="hidden" name="content" value="%s">
                  %s
@@ -201,42 +260,42 @@
               implode(',', $this->content),
               $favourites
            );
      }
      $i = 0;
      $content = implode('', array_map(function($type) use (&$i) {
         $registrar = Registrar::getInstance($type);
         $args = [
            'post_type' => $registrar->getBased(),
            'posts_per_page'  => 1,
            'fields' => 'ids',
         ];
         if (!is_null($this->context)) {
            $context = Registrar::getInstance($this->context);
            switch ($context->getType()) {
               case 'term':
                  $args['tax_query'] = [];
                  $args['tax_query'][] = [
                     'taxonomy'  => $context->getBased(),
                     'terms'     => $this->contextID
                  ];
                  break;
               case 'user':
                  $args['author'] = $this->contextID;
                  break;
            }
         }
         $i = 0;
         $content = implode('', array_map(function($type) use (&$i) {
            $registrar = Registrar::getInstance($type);
         $check = new WP_Query($args);
         $hasPosts = !empty($check->posts);
         wp_reset_postdata();
            $args = [
               'post_type' => $registrar->getBased(),
               'posts_per_page'  => 1,
               'fields' => 'ids',
            ];
            if (!is_null($this->context)) {
               $context = Registrar::getInstance($this->context);
               switch ($context->getType()) {
                  case 'term':
                     $args['tax_query'] = [];
                     $args['tax_query'][] = [
                        'taxonomy'  => $context->getBased(),
                        'terms'     => $this->contextID
                     ];
                     break;
                  case 'user':
                     $args['author'] = $this->contextID;
                     break;
               }
            }
            $check = new WP_Query($args);
            $hasPosts = !empty($check->posts);
            $disabled = !$hasPosts;
            $checked = $i === 0 && $hasPosts;
            if ($hasPosts) {
               $i++;
            }
            return sprintf(
               '<input type="radio"
         $disabled = !$hasPosts;
         $checked = $i === 0 && $hasPosts;
         if ($hasPosts) {
            $i++;
         }
         return sprintf(
            '<input type="radio"
               id="filter-%s"
               class="btn"
               name="content"
@@ -244,60 +303,60 @@
               data-label="%s"
               value="%s"%s%s>
               <label for="filter-%s" title="Show %s">%s<span class="label">%s</span></label>',
               $type,
               $registrar->getSingular(),
               $type,
               $checked ? ' checked' : '',
               $disabled ? ' disabled' : '',
               $type,
               $registrar->getSingular(),
               jvbIcon($registrar->getIcon()),
               $registrar->getSingular()
            );
         }, $this->content));
            $type,
            $registrar->getSingular(),
            $type,
            $checked ? ' checked' : '',
            $disabled ? ' disabled' : '',
            $type,
            $registrar->getSingular(),
            jvbIcon($registrar->getIcon()),
            $registrar->getSingular()
         );
      }, $this->content));
         return sprintf(
            '<div class="content row left nowrap"><span class="label">Showing:</span>
      return sprintf(
         '<div class="content row left nowrap"><span class="label">Showing:</span>
               %s%s
            </div>',
            $content,
            $favourites
         );
         $content,
         $favourites
      );
   }
   protected function renderFilters():string
   {
      if (empty ($this->taxonomies)) {
         return '';
      }
      $inside = implode('', array_filter(array_map(function($tax) {
         $registrar = Registrar::getInstance($tax);
         if (!$registrar) return '';
      protected function renderFilters():string
      {
         if (empty ($this->taxonomies)) {
            return '';
         }
         $inside = implode('', array_filter(array_map(function($tax) {
            $registrar = Registrar::getInstance($tax);
            if (!$registrar) return '';
         $current = BASE.$this->content[0];
         $contentFor = $registrar->registrar->for;
         $hidden = in_array($current, $contentFor) ? '' : ' hidden';
            $current = BASE.$this->content[0];
            $contentFor = $registrar->registrar->for;
            $hidden = in_array($current, $contentFor) ? '' : ' hidden';
         $selector = new TaxonomySelector(
            'feed-'.$tax,
            $tax,
            [
               'icon'   => $registrar->getIcon(),
               'update'=> '.selected-items-section .selected-items',
               'types'  => $contentFor,
               'autocomplete' => false,
               'hidden' => $hidden,
               'output' => 'minimal',
               'search' => true
            ]
         );
         return $selector->render();
            $selector = new TaxonomySelector(
               'feed-'.$tax,
               $tax,
               [
                  'icon'   => $registrar->getIcon(),
                  'update'=> '.selected-items-section .selected-items',
                  'types'  => $contentFor,
                  'autocomplete' => false,
                  'hidden' => $hidden,
                  'output' => 'minimal',
                  'search' => true
               ]
            );
            return $selector->render();
         }, $this->taxonomies)));
         return sprintf(
            '<div class="taxonomies row left">
      }, $this->taxonomies)));
      return sprintf(
         '<div class="taxonomies row left">
            <div class="row top wrap">
               <span class="label">Filter By:</span>
               %s
@@ -313,156 +372,157 @@
               </div>
            </div>
         </div>',
            $inside,
            str_replace('class="toggle-text"', 'class="toggle-text" hidden', jvbRenderToggleTextField('match', 'Match', 'Filters', 'ALL', 'ANY', false, ['filter' => 'match'])),
            jvbIcon('x')
         );
      }
         $inside,
         str_replace('class="toggle-text"', 'class="toggle-text" hidden', jvbRenderToggleTextField('match', 'Match', 'Filters', 'ALL', 'ANY', false, ['filter' => 'match'])),
         jvbIcon('x')
      );
   }
      protected function renderOrderControls():string
      {
         $orderby = [
            [
               'slug'   => 'title',
               'icon'   => 'alphabetical',
               'label'  => 'Name'
            ],
            [
               'slug'   => 'date',
               'icon'   => 'calendar',
               'label'  => 'Date Created',
            ],
            [
               'slug'   => 'date_modified',
               'icon'   => 'clock-clockwise',
               'label'  => 'Date Modified'
            ]
         ];
         $custom = $this->getCustomOrdering();
         $orderby = $orderby + $custom;
         $orderby[] = [
            'slug'   => 'random',
            'icon'   => 'shuffle',
            'label'  => 'Randomly'
         ];
         $custom = implode(',', array_map(function($ord) {
            return $ord['slug'];
         }, $custom));
   protected function renderOrderControls():string
   {
      $orderby = [
         [
            'slug'   => 'title',
            'icon'   => 'alphabetical',
            'label'  => 'Name'
         ],
         [
            'slug'   => 'date',
            'icon'   => 'calendar',
            'label'  => 'Date Created',
         ],
         [
            'slug'   => 'date_modified',
            'icon'   => 'clock-clockwise',
            'label'  => 'Date Modified'
         ]
      ];
      $custom = $this->getCustomOrdering();
      $orderby = $orderby + $custom;
      $orderby[] = [
         'slug'   => 'random',
         'icon'   => 'shuffle',
         'label'  => 'Randomly'
      ];
         $i = 0;
         $orderby = sprintf(
            '<div class="orderby row left">
      $custom = implode(',', array_map(function($ord) {
         return $ord['slug'];
      }, $custom));
      $i = 0;
      $orderby = sprintf(
         '<div class="orderby row left">
            <span class="label">Order by:</span>%s
            </div>',
            implode('', array_map(function ($by) use (&$i){
               $checked = $i === 0 ? ' checked' : '';
               $i++;
               return sprintf(
                  '<input type="radio" id="order-%s" class="btn" name="orderby" value="%s" data-filter="orderby"%s%s>
         implode('', array_map(function ($by) use (&$i){
            $checked = $i === 0 ? ' checked' : '';
            $i++;
            return sprintf(
               '<input type="radio" id="order-%s" class="btn" name="orderby" value="%s" data-filter="orderby"%s%s>
                     <label for="order-%s" title="Order %s">%s<span class="label">%s</span></label>',
                  $by['slug'],
                  $by['slug'],
                  $checked,
                  empty($by['for']??[]) ? '' : ' data-for="'.implode($by['for']).'"',
                  $by['slug'],
                  $by['slug'] === 'random' ? $by['label'] : 'by '.$by['label'],
                  jvbIcon($by['icon']),
                  $by['label']
               );
            }, $orderby))
         );
               $by['slug'],
               $by['slug'],
               $checked,
               empty($by['for']??[]) ? '' : ' data-for="'.implode($by['for']).'"',
               $by['slug'],
               $by['slug'] === 'random' ? $by['label'] : 'by '.$by['label'],
               jvbIcon($by['icon']),
               $by['label']
            );
         }, $orderby))
      );
         $order = [
            [
               'slug'   => 'desc',
               'icon'   => 'sort-descending',
               'label'  => 'Descending (A-Z, 1-10)'
            ],
            [
               'slug'   => 'asc',
               'icon'   => 'sort-ascending',
               'label'  => 'Ascending (Z-A, 10-1)'
            ]
         ];
      $order = [
         [
            'slug'   => 'desc',
            'icon'   => 'sort-descending',
            'label'  => 'Descending (A-Z, 1-10)'
         ],
         [
            'slug'   => 'asc',
            'icon'   => 'sort-ascending',
            'label'  => 'Ascending (Z-A, 10-1)'
         ]
      ];
         $i = 0;
         $order = sprintf(
            '<div class="order-direction row left" data-for-order="date,date_modified,title%s">
      $i = 0;
      $order = sprintf(
         '<div class="order-direction row left" data-for-order="date,date_modified,title%s">
            <span class="label">Order:</span>
            %s
            </div>',
            $custom === '' ? '' : ','.$custom,
            implode('', array_map(function ($ord) use (&$i) {
               $checked = $i=== 0 ? ' checked' : '';
               $i++;
               return sprintf(
                  '<input type="radio" id="order-%s" class="btn" name="order" value="%s" data-filter="order"%s>
         $custom === '' ? '' : ','.$custom,
         implode('', array_map(function ($ord) use (&$i) {
            $checked = $i=== 0 ? ' checked' : '';
            $i++;
            return sprintf(
               '<input type="radio" id="order-%s" class="btn" name="order" value="%s" data-filter="order"%s>
                  <label for="order-%s" title="Sort %s">
                     %s
                     <span class="label">%s</span>
                  </label>',
                  $ord['slug'],
                  $ord['slug'],
                  $checked,
                  $ord['slug'],
                  $ord['label'],
                  jvbIcon($ord['icon']),
                  $ord['label']
               );
            }, $order))
         );
               $ord['slug'],
               $ord['slug'],
               $checked,
               $ord['slug'],
               $ord['label'],
               jvbIcon($ord['icon']),
               $ord['label']
            );
         }, $order))
      );
         return sprintf(
            '<div class="ordering row top left nowrap">%s%s</div>',
            $orderby,
            $order
         );
      }
         protected function getCustomOrdering():array
         {
            $custom = [];
            foreach ($this->content as $content) {
               $registrar = Registrar::getInstance($content);
               if (!$registrar || empty($registrar->config('feed')->getCustomOrder())) {
                  continue;
               }
               $custom = array_merge_recursive($custom, $registrar->config('feed')->getCustomOrder());
            }
            return $custom;
      return sprintf(
         '<div class="ordering row top left nowrap">%s%s</div>',
         $orderby,
         $order
      );
   }
   protected function getCustomOrdering():array
   {
      $custom = [];
      foreach ($this->content as $content) {
         $registrar = Registrar::getInstance($content);
         if (!$registrar || empty($registrar->config('feed')->getCustomOrder())) {
            continue;
         }
      protected function renderViewControls():string
      {
         $views = [
            'grid'   => ['slug' => 'grid', 'icon' => 'squares-four', 'label' => 'Grid View'],
            'list'   => ['slug' => 'list', 'icon' => 'rows', 'label' => 'List View']
         ];
         $custom = array_merge_recursive($custom, $registrar->config('feed')->getCustomOrder());
      }
      return $custom;
   }
   protected function renderViewControls():string
   {
      $views = [
         'grid'   => ['slug' => 'grid', 'icon' => 'squares-four', 'label' => 'Grid View'],
         'list'   => ['slug' => 'list', 'icon' => 'rows', 'label' => 'List View']
      ];
         $i = 0;
         return sprintf(
            '<div class="view row left nowrap"><span class="label">Switch View:</span>%s</div>',
            implode('', array_map(function ($view) use (&$i) {
               $checked = $i === 0 ? ' checked' : '';
               $i++;
               return sprintf(
                  '<input type="radio"
      $i = 0;
      return sprintf(
         '<div class="view row left nowrap"><span class="label">Switch View:</span>%s</div>',
         implode('', array_map(function ($view) use (&$i) {
            $checked = $i === 0 ? ' checked' : '';
            $i++;
            return sprintf(
               '<input type="radio"
                  data-view="%s" value="%s" class="btn" name="view" id="view-%s"%s>
                  <label for="view-%s" title="%s">
                  %s<span class="label">%s</span>
</label>',
                  $view['slug'],
                  $view['slug'],
                  $view['slug'],
                  $checked,
                  $view['slug'],
                  $view['label'],
                  jvbIcon($view['icon']),
                  $view['label'],
               );
            }, $views))
         );
      }
               $view['slug'],
               $view['slug'],
               $view['slug'],
               $checked,
               $view['slug'],
               $view['label'],
               jvbIcon($view['icon']),
               $view['label'],
            );
         }, $views))
      );
   }
   protected function renderGrid():string
   protected function renderPlaceholders():string
   {
      $placeholders = '';
      $total = count($this->content) - 1;
@@ -491,13 +551,58 @@
      );
   }
   protected function renderGrid():string
   {
      if (empty($this->args)) {
         return $this->renderPlaceholders();
      }
      $items = $this->getItems();
      $out = '<div class="item-grid">';
      $out .= implode('',array_map(function ($ID) {
         $content = $this->isContentTax
            ? jvbNoBase($this->content[0])
            : jvbNoBase(get_post_type($ID));
         return $this->renderItem($content, $ID);
      }, $items));
      $out .= '</div>';
      return $out;
   }
   protected function getItems():array
   {
      if ($this->isContentTax) {
         $items = get_terms([
            'taxonomy'  => $this->content,
            'fields' => 'ids',
            'meta_key'  => BASE.'date_modified',
            'meta_type' => 'DATETIME',
            'orderby'   => 'meta_value',
            'order'     => 'desc',
            'number' => $this->args['posts_per_page']
         ]);
         $items = $items && !is_wp_error($items) ? $items : [];
      } else {
         $items = new WP_Query($this->args);
         $this->hasMore = $items->found_posts > $this->args['posts_per_page'];
         $items = $items->posts;
         wp_reset_postdata();
      }
      return $items;
   }
   protected function renderLoader():string
   {
      return sprintf(
         '<button type="button" class="load-more">%s<span>Show Me More</span>%s</button>
         %s%s',
      $button = sprintf(
         '<button type="button" class="load-more"%s>%s<span>Show Me More</span>%s</button>',
         $this->hasMore ? '' : ' hidden',
         jvbIcon('arrow-elbow-left-down'),
         jvbIcon('arrow-elbow-right-down'),
      );
      return sprintf(
         '%s%s%s',
         $button,
         jvbLoadingScreen(),
         $this->isGallery ? jvbRenderGallery(false) : '',
      );
@@ -541,10 +646,10 @@
   protected function renderActions():string
   {
      return sprintf(
      return is_user_logged_in() ? sprintf(
         '<button data-action="refresh" data-ignore>%s<span>Hard Refresh</span></span></button>',
         jvbIcon('arrows-clockwise')
      );
      ) : '';
   }
   public static function getFavouritesButton(string $content):string
@@ -553,10 +658,11 @@
      if (!$registrar || !Site::has('favourites') || !$registrar->hasFeature('favouritable')) {
         return '';
      }
      return '<button class="favourite" type="button" title="Add to favourites" data-action="favourite">
         '.jvbIcon('heart')
         .jvbIcon('heart', ['style'=>'fill']).'
      </button>';
      return sprintf(
         '<button class="favourite" type="button" title="Add to favourites" data-action="favourite">%s%s</button>',
         jvbIcon('heart'),
         jvbIcon('heart', ['style'=>'fill'])
      );
   }
   public static function getUpvotesButton(string $content):string
   {
@@ -564,98 +670,239 @@
      if (!Site::has('karma') || !$registrar || !$registrar->hasFeature('karma')){
         return '';
      }
      return '<div class="karma row">
      return sprintf(
         '<div class="karma row">
         <button type="button" class="vote" data-action="upvote">
            '.jvbIcon('arrow-fat-up')
            .jvbIcon('arrow-fat-up', ['style'=>'fill']).
         '</button>
            %s%s
         </button>
         <button type="button" class="vote" data-action="downvote">
            '.jvbIcon('arrow-fat-down')
            .jvbIcon('arrow-fat-down', ['style'=>'fill']).
         '</button>
            %s%s
         </button>
         <span class="score"></span>
      </div>';
      </div>',
         jvbIcon('arrow-fat-up'),
         jvbIcon('arrow-fat-up', ['style'=>'fill']),
         jvbIcon('arrow-fat-down'),
         jvbIcon('arrow-fat-down', ['style'=>'fill'])
      );
   }
   protected function getDefaultTemplate(string $content): string
   {
      $template = apply_filters('jvbFeedItem', '', $content);
      if (empty($template)) {
         $config = Registrar::getInstance($content)->getConfig('feed');
         $allFields = Registrar::getFieldsFor($content);
         $images = $config['images']??['post_thumbnail'];
         $fields = $config['fields']??['post_title','post_date','post_excerpt'];
         $fields = array_filter($fields, function($field) use($images) {
            return !in_array($field, $images);
         });
         $fields = array_filter($allFields, function($field) use($fields) {
            return in_array($field, $fields);
         }, ARRAY_FILTER_USE_KEY);
         $template = sprintf(
            '<div class="feed item col %s">%s%s',
            $content,
            self::getFavouritesButton($content),
            self::getUpvotesButton($content)
         );
         //Add all defined images, but allow for filtering
         $imageTemplate = '<a>';
         foreach ($images as $image) {
            $imageTemplate .= sprintf(
               '<img data-field="%s" width="300px" height="300px" sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 800px" loading="lazy" decoding="async">',
               $image
            );
         }
         $imageTemplate .= '</a>';
         $template .= sprintf(
            '<div class="images">%s</div>',
            apply_filters('jvbFeedImages', $imageTemplate, $content, $images)
         );
         //Output default fields, but allow for filtering
         $template .= sprintf(
            '<details>
      <summary>%s</summary>',
            apply_filters('jvbFeedItemSummary', jvbIcon('dots-three'), $content)
         );
         $fieldsTemplate = '';
         foreach ($fields as $fieldName => $config) {
            $fieldsTemplate .= apply_filters('jvbFeedItemField', $this->defaultFieldTemplate($config['type'], $fieldName), $content, $fieldName, $config['type']);
         }
         $template .= sprintf(
            '<div class="item-info">%s</div>',
            apply_filters('jvbFeedItemFields', $fieldsTemplate, $content, $fields)
         );
         $template .= '</details></div>';
      }
      return sprintf(
         '<template class="feedItem%s">%s</template>',
         ucfirst($content),
         $template
         $this->renderItem($content)
      );
   }
   protected function defaultFieldTemplate(string $fieldType, string $fieldName):string
   protected function renderItem(string $content, ?int $ID = null):string
   {
      /**
       * Allow plugins to replace the content within the feed item
       */
      $function = BASE.'render_'.$content.'_feed_item';
      if (function_exists($function)) {
         $out = $function($ID);
      } else {
         $out = $this->buildFeedItem($content, $ID);
      }
      $registrar = Registrar::getInstance($content);
      $data = [];
      if ($registrar && $registrar->hasFeature('is_timeline')) {
         $data[] = 'data-timeline';
      }
      $data = !empty($data) ? ' '.implode(' ', $data) : '';
      return sprintf(
         '<div class="feed item col %s"%s>%s%s%s</div>',
         $content,
         $data,
         self::getFavouritesButton($content),
         self::getUpvotesButton($content),
         $out
      );
   }
   protected function buildFeedItem(string $content, ?int $ID = null):string
   {
      $registrar = Registrar::getInstance($content);
      $meta = is_null($ID) ? false :
         match ($registrar->getType()) {
            'post'   => Meta::forPost($ID),
            'term'   => Meta::forTerm($ID),
            'user'   => Meta::forUser($ID),
            default => false
         };
      [$images, $fields] = $registrar->getFeedFields();
      /**
       * Get the main image for the feed item.
       * Output can be overridden with the $imagesFn
       */
      $imagesFn = BASE.'render_'.$content.'_feed_item_images';
      if (function_exists($imagesFn)) {
         $img = $imagesFn($ID, $images);
      } else {
         $img = '';
         foreach ($images as $config) {
            $field = $config['name'];
            $img .= $meta ? jvbFormatImage($meta->get($field), 'tiny', 'medium')
               : $this->defaultFieldTemplate($config['type'], $field);
         }
      }
      $img = sprintf(
         '<div class="images"><a href="%s">%s</a></div>',
         $ID ? get_the_permalink($ID) : '',
         $img
      );
      /**
       * Start the Details with the fields
       * Plugins can modify the summary title with the 'jvbFeedItemSummary' filter
       */
      $summary = sprintf(
         '<details><summary>%s</summary>',
         apply_filters('jvbFeedItemSummary', jvbIcon('dots-three'), $content)
      );
      /**
       * Work through the fields
       * Each field output can be overridden with $field or $fieldType
       */
      foreach ($fields as $config) {
         $f = $config['name'];
         $functions = [
            BASE.'render_'.$content.'_field_'.$f,  //Overrides field for this content
            BASE.'render_field_'.$f,            //Overrides field with this name
            BASE.'render_field_type_'.$config['type'] //Overrides field of this type
         ];
         $didIt = false;
         foreach ($functions as $func) {
            if (!$didIt && function_exists($func)) {
               $didIt = true;
               $summary .= $func($ID, is_null($ID));
            }
         }
         if (!$didIt) {
            $summary .= $this->defaultFieldTemplate($config['type'], $f, is_null($ID), $meta, $config);
         }
      }
      $summary .= '</details>';
      return $img.$summary;
   }
   protected function defaultFieldTemplate(string $fieldType, string $fieldName, bool $isTemplate = true, bool|Meta $meta = false, array $config = []):string
   {
      $data = ' data-field="'.$fieldName.'"';
      $value = $meta ? $meta->get($fieldName) : '';
      if (!$isTemplate && empty($value)) {
         return '';
      }
      switch ($fieldName) {
         case 'post_title':
            return '<h3'.$data.'></h3>';
            return sprintf(
               '<h3%s>%s</h3>',
               $data,
               $meta && !empty($value) ? $value : '',
            );
         case 'post_date':
         case 'post_modified':
            return '<time'.$data.'></time>';
            return sprintf(
               '<time%s%s>%s</time>',
               $data,
               $meta && !empty($value) ? date('c', strtotime($value)) : '',
               $meta && !empty($value) ? date('F j, Y', strtotime($value)) : '',
            );
      }
      return match($fieldType) {
         'date','datetime','time' => '<time'.$data.'></time>',
         'upload' => '<img'.$data.' width="300px" height="300px" sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 800px" loading="lazy" decoding="async">',
         'taxonomy'  => '<ul'.$data.'><li><a><i></i></a></li></ul>',
         default  =>  '<p'.$data.'></p>',
      return match ($fieldType) {
         'date' => sprintf(
            '<time%s%s>%s</time>',
            $data,
            $meta && !empty($value) ? date('c', strtotime($value)) : '',
            $meta && !empty($value) ? date('F j, Y', strtotime($value)) : '',
         ),
         'datetime' => sprintf(
            '<time%s%s>%s</time>',
            $data,
            $meta && !empty($value) ? date('c', strtotime($value)) : '',
            $meta && !empty($value) ? date('F j, Y at h:iA', strtotime($value)) : '',
         ),
         'time' => sprintf(
            '<time%s%s>%s</time>',
            $data,
            $value,
            $value
         ),
         'upload' => empty($value) ? sprintf(
            '<img%s width="300px" height="300px" loading="lazy" decoding="async">',
            $data
         ) :
            str_replace('<img', '<img' . $data, jvbFormatImage($value)),
         'selector' => sprintf(
            '<ul%s class="terms %s">%s</ul>',
            $data,
            $config['taxonomy']??$config['post_type']??$config['role']??'',
            empty($value) ? sprintf('<li><a>%s</a></li>',
               $this->iconFor($config)
            ) :
               implode('', array_filter(array_map(function ($ID) use ($config) {
                  $type = $config['subtype'];
                  $taxonomy = isset($config['taxonomy']) ? jvbCheckBase($config['taxonomy']) : '';
                  $item = match ($type) {
                     'taxonomy' => get_term($ID, $taxonomy),
                     'user' => get_userdata($ID),
                     'post' => get_post($ID),
                     default => false,
                  };
                  if (!$item || is_wp_error($item)) {
                     return '';
                  }
                  $icon = $this->iconFor($config);
                  $name = match ($type) {
                     'taxonomy' => $item->name,
                     'user' => $item->display_name,
                     'post' => $item->post_title,
                     default => '',
                  };
                  $url = match ($type) {
                     'taxonomy' => get_term_link((int)$ID, $taxonomy),
                     'user' => get_the_permalink(get_user_meta($ID, BASE . 'userLink', true)),
                     'post' => get_the_permalink($ID),
                     default => ''
                  };
                  return sprintf(
                     '<li><a href="%s">%s%s</a></li>',
                     $url,
                     !empty($icon) ? jvbIcon($icon) : '',
                     $name
                  );
               }, explode(',', $value))))
         ),
         default => sprintf(
            '<p%s>%s</p>',
            $data,
            $value
         ),
      };
   }
   protected function iconFor(array $fieldConfig):string
   {
      $icon = match ($fieldConfig['type']??'') {
         'taxonomy' => Registrar::getInstance($fieldConfig['taxonomy'])->getIcon(),
         'user' => isset($fieldConfig['role']) ? Registrar::getInstance($fieldConfig['role'])->getIcon() : 'user',
         'post' => Registrar::getInstance($fieldConfig['post_type'])->getIcon(),
         default => ''
      };
      return empty($icon) ? $icon : jvbIcon($icon);
   }
}