Jake Vanderwerf
2 days ago 235ce5716edc2f7cbe80fdccf26eac7269587839
=FavouritesManager.php and FavouritesRoutes.php fixes. Moving all logic to FavouritesManager.php. Still some left to do
11 files modified
571 ■■■■ changed files
JVBase.php 5 ●●●●● patch | view | raw | blame | history
assets/css/dash.min.css 2 ●●● patch | view | raw | blame | history
assets/js/concise/FrontendFavourites.js 40 ●●●● patch | view | raw | blame | history
assets/js/min/favourites.min.js 2 ●●● patch | view | raw | blame | history
inc/managers/CRUDManager.php 6 ●●●●● patch | view | raw | blame | history
inc/managers/CustomTable.php 49 ●●●● patch | view | raw | blame | history
inc/managers/FavouritesManager.php 109 ●●●●● patch | view | raw | blame | history
inc/registrar/Fields.php 12 ●●●● patch | view | raw | blame | history
inc/registrar/Registrar.php 11 ●●●● patch | view | raw | blame | history
inc/rest/routes/FavouritesRoutes.php 325 ●●●● patch | view | raw | blame | history
inc/ui/CRUDSkeleton.php 10 ●●●●● patch | view | raw | blame | history
JVBase.php
@@ -219,6 +219,11 @@
        return array_merge(array_keys($this->content), array_keys($this->taxonomies));
    }
    public function favourites(): FavouritesManager|false
    {
        return $this->managers['favourites'] ?? false;
    }
    public function dashboard(): DashboardManager|false
    {
        return $this->managers['dash'] ?? false;
assets/css/dash.min.css
@@ -1 +1 @@
.replace{margin-left:var(--btn_)!important}.dashboard aside.main.left{bottom:0}.dashboard .qtoggle{left:0;bottom:0;margin:0!important}nav.sidebar{--wrap:nowrap;--align:flex-start;position:fixed;bottom:0;top:var(--btn);z-index:var(--z-4);height:calc(100% - var(--btn));background-color:rgb(var(--base));box-shadow:rgba(var(--base),var(--op-45)) var(--shdw);width:var(--btn);transition:var(--trans-size);overflow:hidden auto;margin-left:0!important;padding-bottom:var(--btn)}nav.sidebar.left{left:0}nav.sidebar.right{right:0}nav.sidebar .icon{--w:var(--chip_);width:var(--w);transition:var(--trans-size)}nav.sidebar.open{width:fit-content;max-width:85vw}nav.sidebar.open .icon{--w:var(--chip)}nav.sidebar ul{height:max-content;width:100%;--gap:0;--dir:column}nav.sidebar li{--justify:center;--wrap:nowrap;--align:flex-start;overflow:hidden;height:max-content}nav.sidebar ul ul{max-height:0;overflow:hidden;transform:scaleY(0);transform-origin:top;transition:var(--trans-base)}nav.sidebar li.open>ul{max-height:max-content;transform:scaleY(1)}nav.sidebar.open li>div{width:100%;padding-right:var(--btn)}nav.sidebar.open li.has-submenu>div{padding-right:0}nav.sidebar.open li.has-submenu>ul{padding-left:var(--chip)}nav.sidebar .title{display:none}nav.sidebar.open .title{display:block;white-space:nowrap}nav.sidebar a{--justify:flex-start}nav.sidebar .a{gap:.5rem;display:flex;width:100%;justify-content:flex-start;align-items:center;min-height:var(--btn);padding:var(--padding)}nav.sidebar .toggle:not(.main){display:none;width:var(--btn);height:var(--chipchip)}nav.sidebar.open .toggle{display:flex}nav.sidebar .toggle.main{position:fixed;left:unset;bottom:0;right:0;width:var(--btn);height:var(--btn);z-index:var(--z-8);box-shadow:rgba(var(--base),var(--op-45)) var(--shdw)}.all-filters{font-size:var(--txt-x-small)}.all-filters[open]{border:2px solid rgb(var(--action-0));padding:0;border-radius:0 0 var(--radius-outer) var(--radius-outer)}.all-filters summary:hover,.all-filters[open] summary{background-color:rgb(var(--action-0));color:rgb(var(--action-contrast))}.all-filters summary:hover::after{background-color:rgb(var(--action-contrast))}.all-filters summary{width:100%}.all-filters>.row.row{padding:0 .75rem;width:var(--content);position:relative}.all-filters>.row>.label,.all-filters>.row>.row>.label{font-family:var(--heading);font-weight:var(--fw-h-bold);text-transform:uppercase}.all-filters>.row>.label{width:20%}.all-filters>.row>.row>.label{white-space:nowrap}.all-filters .btn+label,.all-filters button{width:var(--chipchip);min-height:var(--chipchip);padding:0}.all-filters .btn+label,.all-filters button{position:unset}.all-filters .row:has(>.btn:not(:checked)+label:hover) :checked+label .label,.all-filters button .label,.btn+label .label{position:absolute;top:2rem;left:1rem;width:max-content;white-space:nowrap;opacity:0;z-index:var(--z-4)}.all-filters .radio-options.order>.row{position:relative;padding-bottom:2rem}.all-filters .radio-options .row .btn+label .label{bottom:0;top:unset;left:0;height:max-content}.all-filters button:hover .label,.btn+label:hover .label,.btn:checked+label .label{opacity:1}.all-filters .radio-options.order>.row{max-width:49%}.all-filters .radio-options.order{margin-top:1rem}.all-filters .filters select,.all-filters .filters>.row{width:max-content}.search-container:not(.open) .clear-search,.search-container:not(.open) input[type=search]{transform:scaleX(0);transform-origin:left;width:0;padding:0;transition:transform var(--trans-base),width var(--trans-base),padding var(--trans-base)}.search-container button{padding:.5rem}.search-container .icon{--w:1.5rem}.search-container.open .clear-search,.search-container.open input[type=search]{transform:scaleX(1);transform-origin:left;transition:transform var(--trans-base),width var(--trans-base),padding var(--trans-base)}.all-filters>.search,.search-container,input[type=search]{width:100%}section.main-actions.main-actions{padding:0;position:absolute;left:var(--offScreen)}.dashboard main>footer{padding:0;margin:0;position:absolute;left:var(--offScreen)}.item .select-item-label{width:100%;height:100%;aspect-ratio:1}
.replace{margin-left:var(--btn_)!important}.dashboard aside.main.left{bottom:0}.dashboard .qtoggle{left:0;bottom:0;margin:0!important}nav.sidebar{--wrap:nowrap;--align:flex-start;position:fixed;bottom:0;top:var(--btn);z-index:var(--z-4);height:calc(100% - var(--btn));background-color:rgb(var(--base));box-shadow:rgba(var(--base),var(--op-45)) var(--shdw);width:var(--btn);transition:var(--trans-size);overflow:hidden auto;margin-left:0!important;padding-bottom:var(--btn)}nav.sidebar.left{left:0}nav.sidebar.right{right:0}nav.sidebar .icon{--w:var(--chip_);width:var(--w);transition:var(--trans-size)}nav.sidebar.open{width:fit-content;max-width:85vw}nav.sidebar.open .icon{--w:var(--chip)}nav.sidebar ul{height:max-content;width:100%;--gap:0;--dir:column}nav.sidebar li{--justify:center;--wrap:nowrap;--align:flex-start;overflow:hidden;height:max-content}nav.sidebar ul ul{max-height:0;overflow:hidden;transform:scaleY(0);transform-origin:top;transition:var(--trans-base)}nav.sidebar li.open>ul{max-height:max-content;transform:scaleY(1)}nav.sidebar.open li>div{width:100%;padding-right:var(--btn)}nav.sidebar.open li.has-submenu>div{padding-right:0}nav.sidebar.open li.has-submenu>ul{padding-left:var(--chip)}nav.sidebar .title{display:none}nav.sidebar.open .title{display:block;white-space:nowrap}nav.sidebar a{--justify:flex-start}nav.sidebar .a{gap:.5rem;display:flex;width:100%;justify-content:flex-start;align-items:center;min-height:var(--btn);padding:var(--padding)}nav.sidebar .toggle:not(.main){display:none;width:var(--btn);height:var(--chipchip)}nav.sidebar.open .toggle{display:flex}nav.sidebar .toggle.main{position:fixed;left:unset;bottom:0;right:0;width:var(--btn);height:var(--btn);z-index:var(--z-8);box-shadow:rgba(var(--base),var(--op-45)) var(--shdw)}.all-filters{font-size:var(--txt-x-small)}.all-filters[open]{border:2px solid rgb(var(--action-0));padding:0;border-radius:0 0 var(--radius-outer) var(--radius-outer)}.all-filters summary:hover,.all-filters[open] summary{background-color:rgb(var(--action-0));color:rgb(var(--action-contrast))}.all-filters summary:hover::after{background-color:rgb(var(--action-contrast))}.all-filters summary{width:100%}.all-filters>.row.row{padding:0 .75rem;width:var(--content);position:relative}.all-filters>.row>.label,.all-filters>.row>.row>.label{font-family:var(--heading);font-weight:var(--fw-h-bold);text-transform:uppercase}.all-filters>.row>.label{width:20%}.all-filters>.row>.row>.label{white-space:nowrap}.all-filters .btn+label,.all-filters button{width:var(--chipchip);min-height:var(--chipchip);padding:0}.all-filters .btn+label,.all-filters button{position:unset}.all-filters .row:has(>.btn:not(:checked)+label:hover) :checked+label .label,.all-filters button .label,.btn+label .label{position:absolute;top:2rem;left:1rem;width:max-content;white-space:nowrap;opacity:0;z-index:var(--z-4)}.all-filters .radio-options.order>.row{position:relative;padding-bottom:2rem}.all-filters .radio-options .row .btn+label .label{bottom:0;top:unset;left:0;height:max-content}.all-filters button:hover .label,.btn+label:hover .label,.btn:checked+label .label{opacity:1}.all-filters .radio-options.order>.row{max-width:49%}.all-filters .radio-options.order{margin-top:1rem}.all-filters .filters select,.all-filters .filters>.row{width:max-content}.search-container:not(.open) .clear-search,.search-container:not(.open) input[type=search]{transform:scaleX(0);transform-origin:left;width:0;padding:0;transition:transform var(--trans-base),width var(--trans-base),padding var(--trans-base)}.search-container button{padding:.5rem}.search-container .icon{--w:1.5rem}.search-container.open .clear-search,.search-container.open input[type=search]{transform:scaleX(1);transform-origin:left;transition:transform var(--trans-base),width var(--trans-base),padding var(--trans-base)}.all-filters>.search,.search-container,input[type=search]{width:100%}section.main-actions.main-actions{padding:0;position:absolute;left:var(--offScreen)}.dashboard main>footer{padding:0;margin:0;position:absolute;left:var(--offScreen)}.item .select-item-label{width:100%;height:100%;aspect-ratio:1}dialog form nav.tabs{--wrap:nowrap!important;--gap:0!important;overflow-x:auto;touch-action:pan-x;position:absolute;top:0;left:0;right:0;max-width:100%;height:var(--chipchip);padding:0;background-color:rgba(var(--base),var(--op-5))}dialog form nav.tabs button{min-height:var(--chipchip);padding:0 1rem}dialog nav.tabs button:hover{background-color:rgb(var(--action-0));color:rgb(var(--action-contrast))}dialog:has(nav.tabs) .wrap{padding-top:3rem}dialog{height:100%!important}
assets/js/concise/FrontendFavourites.js
@@ -71,12 +71,40 @@
        // Update button icon
        button.innerHTML = jvbSettings.icons[button.classList.contains('favourited') ? 'heart-filled' : 'heart'];
        this.store.setItem(button.dataset.id, {
            target_id: button.dataset.id,
            action: action,
            type: button.dataset.type,
            artist: button.dataset.artist,
        });
        window.debouncer.schedule(
            `favourite-${button.dataset.id}`,
            this.postFavourite({
                user: window.auth.getUser(),
                target_id: button.dataset.id,
                action: action,
                type: button.dataset.type
            }),
            100
        );
    }
    async postFavourite(args) {
        await window.auth.fetch(
            `${jvbSettings.api}favourites`,
            {
                method: 'POST',
                headers: {
                    'X-Action-Nonce': window.auth.getNonce('favourites')
                },
                body: args
            }
        );
        if (args.action === 'add') {
            await this.store.setItem(button.dataset.id, {
                target_id: button.dataset.id,
                action: action,
                type: button.dataset.type,
            }).then(()=>{});
        } else {
            await this.store.delete(button.dataset.id).then(()=>{});
        }
    }
    // async toggleFavourite(itemType, itemId) {
assets/js/min/favourites.min.js
@@ -1 +1 @@
(()=>{class t{constructor(){let t=window.jvbStore.register("favourites",{storeName:"favourites",keyPath:"id",endpoint:"favourites",headers:{"X-Action-Nonce":window.auth.getNonce("favourites")},indexes:[{name:"content",keyPath:"content"},{name:"listId",keyPath:"listId"}],TTL:36e4,showLoading:!1,filters:{user:window.auth.getUser(),content:"all",order:"desc",orderby:"date",page:1,all:!0}});this.store=t.favourites,this.store.subscribe(((t,e)=>{t}))}toggleFavourite(t){if(!window.auth.getUser())return void(window.location.href=jvbSettings.redirect+"&action=register&type=favourites");t.classList.toggle("favourited");const e=t.classList.contains("favourited")?"add":"remove",o=t.classList.contains("favourited")?`Added ${t.dataset.type} to favourites.`:`Removed ${t.dataset.type} from favourites.`;window.jvbA11y.announce(o),t.innerHTML=jvbSettings.icons[t.classList.contains("favourited")?"heart-filled":"heart"],this.store.setItem(t.dataset.id,{target_id:t.dataset.id,action:e,type:t.dataset.type,artist:t.dataset.artist})}isFavourited(t,e){const o=`${this.userId}_${t}_${e}`;return void 0!==this.store.get(o)}}document.addEventListener("DOMContentLoaded",(function(){window.jvbFavourites=!1,window.auth.subscribe((e=>{"auth-loaded"===e&&(window.jvbFavourites=new t)}))})),window.toggleFavourite=function(t){window.jvbFavourites()?window.jvbFavourites.toggleFavourite(t):console.log("No Favourites Loaded")},window.isFavourited=function(t,e){if(window.jvbFavourites())return window.jvbFavourites.isFavourited(t,e);console.log("No Favourites Loaded")}})();
(()=>{class t{constructor(){let t=window.jvbStore.register("favourites",{storeName:"favourites",keyPath:"id",endpoint:"favourites",headers:{"X-Action-Nonce":window.auth.getNonce("favourites")},indexes:[{name:"content",keyPath:"content"},{name:"listId",keyPath:"listId"}],TTL:36e4,showLoading:!1,filters:{user:window.auth.getUser(),content:"all",order:"desc",orderby:"date",page:1,all:!0}});this.store=t.favourites,this.store.subscribe(((t,e)=>{t}))}toggleFavourite(t){if(!window.auth.getUser())return void(window.location.href=jvbSettings.redirect+"&action=register&type=favourites");t.classList.toggle("favourited");const e=t.classList.contains("favourited")?"add":"remove",o=t.classList.contains("favourited")?`Added ${t.dataset.type} to favourites.`:`Removed ${t.dataset.type} from favourites.`;window.jvbA11y.announce(o),t.innerHTML=jvbSettings.icons[t.classList.contains("favourited")?"heart-filled":"heart"],window.debouncer.schedule(`favourite-${t.dataset.id}`,this.postFavourite({user:window.auth.getUser(),target_id:t.dataset.id,action:e,type:t.dataset.type}),100)}async postFavourite(t){await window.auth.fetch(`${jvbSettings.api}favourites`,{method:"POST",headers:{"X-Action-Nonce":window.auth.getNonce("favourites")},body:t}),"add"===t.action?await this.store.setItem(button.dataset.id,{target_id:button.dataset.id,action,type:button.dataset.type}).then((()=>{})):await this.store.delete(button.dataset.id).then((()=>{}))}isFavourited(t,e){const o=`${this.userId}_${t}_${e}`;return void 0!==this.store.get(o)}}document.addEventListener("DOMContentLoaded",(function(){window.jvbFavourites=!1,window.auth.subscribe((e=>{"auth-loaded"===e&&(window.jvbFavourites=new t)}))})),window.toggleFavourite=function(t){window.jvbFavourites()?window.jvbFavourites.toggleFavourite(t):console.log("No Favourites Loaded")},window.isFavourited=function(t,e){if(window.jvbFavourites())return window.jvbFavourites.isFavourited(t,e);console.log("No Favourites Loaded")}})();
inc/managers/CRUDManager.php
@@ -64,12 +64,10 @@
        // Fields and sections
        $this->skeleton->setFields($this->registrar->getFields());
        jvbDump($this->registrar->getSections());
        $sections = $this->registrar->getSections();
        if (count($sections) > 1) {
            foreach ($sections as $config) {
                jvbDump($config);
                $this->skeleton->addSection($config['id'], $config);
                $this->skeleton->addSection($config['slug'], $config);
            }
        }
@@ -189,7 +187,7 @@
    protected function addDateRanges():array
    {
        return $this->cache->remember(
        return $this->cache->user()->remember(
            'dateRanges',
            function() {
                $postType = jvbCheckBase($this->content);
inc/managers/CustomTable.php
@@ -319,16 +319,16 @@
    }
    /**
     * Set LIMIT
     * Set pagination
     *
     * @param int $limit Number of records
     * @param int $offset Optional offset
     * @param int $perPage Number of records per page
     * @param int $page Page number (1-based)
     * @return self
     */
    public function limit(int $limit, int $offset = 0): self
    public function limit(int $perPage, int $page = 1): self
    {
        $this->builder['limit'] = $limit;
        $this->builder['offset'] = $offset;
        $this->builder['per_page'] = $perPage;
        $this->builder['page'] = $page;
        return $this;
    }
@@ -601,12 +601,12 @@
     *     'where' => ['user_id' => 1],
     *     'orderby' => 'date_added',
     *     'order' => 'DESC',
     *     'limit' => 20
     *     'per_page' => 20
     * ]);
     */
    public function getMany(array $args = [], string $output = OBJECT): array
    public function getMany(array $args = [], bool $itemsOnly = true, string $output = OBJECT): array
    {
        return $this->cache->remember(
        $items = $this->cache->remember(
            $this->cache->generateKey(array_merge($args, ['output' => $output])),
            function () use ($args, $output) {
                $query = "SELECT * FROM {$this->fullTableName}";
@@ -628,10 +628,19 @@
                }
                // LIMIT
                if (!empty($args['limit'])) {
                    $limit = absint($args['limit']);
                    $offset = !empty($args['offset']) ? absint($args['offset']) : 0;
                    $query .= " LIMIT {$offset}, {$limit}";
                if (array_key_exists('limit', $args)) {
                    error_log('[CustomTable]::getMany deprecated key \'limit\' - use \'per_page\' instead. '.print_r($args, true));
                    $args['per_page'] = $args['limit'];
                }
                if (array_key_exists('offset', $args)) {
                    error_log('[CustomTable]::getMany deprecated key \'offset\' - use \'page\' instead. '.print_r($args, true));
                    $args['page'] = $args['offset'] / $args['limit'];
                }
                if (!empty($args['per_page'])) {
                    $perPage = absint($args['per_page']);
                    $page    = !empty($args['page']) ? absint($args['page']) : 1;
                    $offset  = ($page - 1) * $perPage;
                    $query  .= " LIMIT {$offset}, {$perPage}";
                }
                if (empty($values)) {
@@ -642,8 +651,20 @@
            }
        );
        if ($itemsOnly) {
            return $items;
        }
        $page = max(1, $args['page'] ?? 1);
        $perPage = $args['per_page']??false;
        $total = $this->count($args['where']);
        return [
            'items'     => $items,
            'total'     => $total,
            'has_more'  => $perPage && ($page * $perPage) < $total,
        ];
    }
    /**
     * Get a specific column value from all matches
     * @param string $column
@@ -669,7 +690,7 @@
                    $args['order'] = $order;
                }
                if ($limit) {
                    $args['limit'] = $limit;
                    $args['per_page'] = $limit;
                }
                return array_column($this->getMany($args), $column);
inc/managers/FavouritesManager.php
@@ -461,6 +461,115 @@
                'status'    => $accept ? 'accepted' : 'rejected',
            ], $args);
        }
        public function getFavourites(array $args = []):array
        {
            return $this->favourites->getMany($args, false);
        }
        public function getLists(array $args = []):array
        {
            return $this->lists->getMany($args, false);
        }
            public function getAvailableLists(array $args = [], bool $include_shared = true):array
            {
                $lists = [];
                $owned = $this->lists->getMany($args, false);
                foreach ($owned['items'] as &$list) {
                    $list['item_count'] = $this->listItems->count(['list_id' => $list['id']]);
                }
                $lists['owned'] = $owned;
                if ($include_shared) {
                    $args['where']['status'] = 'accepted';
                    $sharedLists = $this->listShares->getMany($args);
                    $shared = [];
                    foreach ($sharedLists as $share) {
                        $sharedList = $this->lists->get(['id' => $share->list_id]);
                        if ($sharedList) {
                            $sharedList['owner_name'] = jvbGetUsername($sharedList['user_id']);
                            $sharedList['item_count'] = $this->listItems->count(['list_id' => $sharedList['id']]);
                            $sharedList['permission_type'] = $sharedList->permission_type;
                            $shared[] = $sharedList;
                        }
                    }
                    $lists['shared'] = $shared;
                }
                return $lists;
            }
            public function userOwnsList(int $list_id, int $user_id):bool
            {
                return $this->lists->count(['id' => $list_id, 'user_id' => $user_id]) > 0;
            }
            public function getListDetails(int $list_id, int $user_id, int $page = 1):array
            {
                $list = JVB()->favourites()->getLists(['id' => $list_id, 'user_id' => $user_id]);
                if (!$list) {
                    return [];
                }
                $items = $this->listItems->getMany([
                    'where'     => ['list_id' => $list_id],
                    'order_by'  => 'added_at',
                    'order'     => 'DESC',
                    'per_page'  => 25,
                    'page'      => $page,
                ]);
                $formatted = [];
                foreach($items as $item) {
                    //See if we can get the actual favourite record first
                    if ($item->favourite_id) {
                        $fav = $this->favourites->get(['id' => $item->favourite_id]);
                        if ($fav) {
                            $formatted[] = $fav;
                            continue;
                        }
                    }
                    //Create a dummy favourite object otherwise
                    $formatted[] = (object) [
                        'type'      => $item->item_type,
                        'target_id' => $item->item_id,
                        'created_at'=> $item->added_at
                    ];
                }
                $sharedWith = [];
                $is_owner = $this->userOwnsList($list_id, $user_id);
                if ($is_owner) {
                    $shares = $this->listShares->getMany([
                        'where' => ['list_id' => $list_id],
                        'order_by' => 'created_at',
                        'order' => 'DESC'
                    ]);
                    foreach ($shares as $share_item) {
                        $user = [
                            'email'     => $share_item->email,
                            'status'    => $share_item->status,
                            'date_added'=> $share_item->created_at,
                        ];
                        if ($share_item->status === 'accepted' && $share_item->user_id) {
                            $user['name'] = jvbGetUsername($share_item->user_id);
                            $user['permission_type'] = $share_item->permission_type;
                        }
                        $sharedWith[] = $user;
                    }
                }
                return [
                    'id'    => (int)$list['id'],
                    'name'  => $list['name'],
                    'description'   => $list['description']??'',
                    'created_at'    => $list['created_at'],
                    'is_owner'      => $is_owner,
                    'items'         => $formatted,
                    'shared_users'  => $sharedWith
                ];
            }
    /***************************************************************
     * UTILITY METHODS
     **************************************************************/
inc/registrar/Fields.php
@@ -97,6 +97,12 @@
            ];
        }
        $fields = [
            'post_status'   => [
                'type'      => 'radio',
                'label'     => 'Status',
                'default'   => 'draft',
                'options'   => $statuses
            ],
            'post_thumbnail'    => [
                'type'  => 'upload',
                'subtype'   => 'image',
@@ -127,12 +133,6 @@
                'label' => 'TLDR',
                'maxLength' => 158,
            ],
            'post_status'   => [
                'type'      => 'radio',
                'label'     => 'Status',
                'default'   => 'draft',
                'options'   => $statuses
            ]
        ];
        foreach ($fields as $name => $config) {
inc/registrar/Registrar.php
@@ -724,9 +724,10 @@
    public function getSections():array
    {
        $allSections = array_map(function($section) {
            return $section->getConfig;
            return $section->getConfig();
        }, $this->sections);
        if (!empty($this->sectionOrder)) {
            $allSections['order'] = $this->sectionOrder;
        }
@@ -759,13 +760,11 @@
            foreach ($sections as $s) {
                $section = new Section($s, $this);
                $section->setTitle(ucwords(implode(' ', explode('-', $s))));
                $sectionFields = array_map(function ($f) {
                    return $f['name'];
                }, array_filter($fields, function ($f) use ($s) {
                $sectionFields = array_filter($fields, function ($f) use ($s) {
                    $tmp = array_key_exists('section', $f) && !is_null($f['section']) ? $f['section'] : 'main';
                    return $s === $tmp;
                }));
                $section->setFields($sectionFields);
                });
                $section->setFields(array_keys($sectionFields));
                $this->sections[$s] = $section;
            }
        }
inc/rest/routes/FavouritesRoutes.php
@@ -16,9 +16,6 @@
    exit;
}
/**
 * TODO: Extract business logic into a Favourites.php manager class
 */
class FavouritesRoutes extends Rest
{
    protected array $valid_types;
@@ -73,7 +70,6 @@
            ->post([$this, 'handleFavourite'])
            ->args([
                'user' => 'integer|required',
                'id' => 'string|required',
                'action' => 'string|required|enum:add,remove,toggle,batch,note',
                'type' => 'string',
                'target_id' => 'integer',
@@ -130,149 +126,50 @@
        if ($cache_check) {
            return $cache_check;
        }
        if (count($args) === 1 || ($request->get_param('include_all') === true)) {
            $result = $this->getAllFavourites($user_id);
        } else {
            $result = $this->cache->remember($key, function() use ($args) {
                return $this->getFilteredFavourites($args);
            });
        }
        $result = JVB()->favourites()->getFavourites($args);
        $result['items'] = $this->formatItems($result['items']);
        return $this->addCacheHeaders(Response::success($result));
    }
    /**
     * Get filtered favourites using CustomTable fluent interface
     */
    protected function getFilteredFavourites(array $args): array
    {
        try {
            // Build base query
            $query = $this->favourites->where(['user_id' => $args['user']]);
            // Add type filter if specified
            if (!empty($args['content']) && $args['content'] !== 'all') {
                $query = $this->favourites->where([
                    'user_id' => $args['user'],
                    'type' => BASE . $args['content']
                ]);
            }
            // Apply ordering and pagination
            $orderby = in_array($args['orderby'] ?? 'date_added', ['date_added', 'type'])
                ? $args['orderby']
                : 'date_added';
            $order = in_array(strtoupper($args['order'] ?? 'DESC'), ['ASC', 'DESC'])
                ? strtoupper($args['order'])
                : 'DESC';
            $favourites = $query
                ->orderBy($orderby, $order)
                ->limit(100, ($args['page'] - 1) * 100)
                ->getResults();
            // Get total count
            $count_query = $this->favourites->where(['user_id' => $args['user']]);
            if (!empty($args['content']) && $args['content'] !== 'all') {
                $count_query->where(['type' => BASE . $args['content']]);
            }
            $total_items = $count_query->countResults();
            return [
                'items' => $this->formatItems($favourites),
                'has_more' => ($args['page'] * 100) < $total_items,
                'total' => $total_items,
                'success' => true,
            ];
        } catch (Exception $e) {
            $this->logError('getFilteredFavourites', [
                'error' => $e->getMessage(),
                'args' => $args
            ]);
            return [
                'success' => false,
                'items' => [],
                'total' => 0,
                'has_more' => false
            ];
        }
    }
    /**
     * Get all user's favourites organized by content type
     */
    protected function getAllFavourites(int $user_id): array
    {
        return $this->cache->remember($user_id, function() use ($user_id) {
            try {
                $favourites = $this->favourites
                    ->where(['user_id' => $user_id])
                    ->getResults();
                $by_type = [];
                foreach ($favourites as $fav) {
                    $type = str_replace(BASE, '', $fav->type);
                    if (!isset($by_type[$type])) {
                        $by_type[$type] = [];
                    }
                    $by_type[$type][] = (int)$fav->target_id;
                }
                return [
                    'success' => true,
                    'items' => $by_type,
                    'has_more' => false,
                ];
            } catch (Exception $e) {
                $this->logError('getAllFavourites', [
                    'error' => $e->getMessage(),
                    'user_id' => $user_id
                ]);
                return [
                    'success' => false,
                    'items' => [],
                ];
            }
        });
    }
    /**
     * Handle favourite operations
     */
    public function handleFavourite(WP_REST_Request $request): WP_REST_Response
    {
        $user_id = absint($request->get_param('user'));
        $operation_id = sanitize_text_field($request->get_param('id'));
        $action = sanitize_text_field($request->get_param('action'));
        $params = $request->get_params();
        $user_id = absint($params['user']??0);
        if (!$this->userCheck($user_id)) {
            return $this->unauthorized();
        }
        $action = strtolower(sanitize_text_field($params['action']));
        $action = in_array($action, ['add', 'remove']) ? $action : false;
        if (!$action) {
            return $this->error('Invalid favourite action');
        }
        $target_id = absint($params['target_id']??0);
        if ($target_id === 0) {
            return $this->error('Invalid target id');
        }
        $data = [
            'action' => $action,
            'type' => sanitize_text_field($request->get_param('type') ?? ''),
            'target_id' => absint($request->get_param('target_id') ?? 0),
            'items' => $request->get_param('items') ?? [],
            'notes' => sanitize_textarea_field($request->get_param('notes') ?? ''),
        ];
        $type = sanitize_text_field($params['type']??'');
        if (empty($type)) {
            return $this->error('No type provided');
        }
        JVB()->queue()->queueOperation(
            'favourite_' . $action,
        $result = JVB()->favourites()->toggleFavourite(
            $action === 'add',
            $user_id,
            $data,
            [
                'operation_id' => $operation_id,
                'priority' => 'high',
            ]
            $target_id,
            $type
        );
        return $this->queued($operation_id);
        if ($result) {
            return $this->success();
        }
        return $this->error('Something went wrong');
    }
    /**
@@ -280,18 +177,21 @@
     */
    public function getLists(WP_REST_Request $request): WP_REST_Response
    {
        $user_id = absint($request->get_param('user'));
        $params = $request->get_params();
        $user_id = absint($params['user']);
        if (!$this->userCheck($user_id)) {
            return $this->unauthorized();
        }
        $params = ['user' => $user_id];
        if ($request->get_param('id')) {
            $params['list'] = sanitize_text_field($request->get_param('id'));
        $args = $this->buildParams($request);
        $args['per_page'] = 20;
        $listId = $request->get_param('id');
        if (!empty($listId)) {
            $args['where']['id'] = sanitize_text_field($listId);
        }
        $key = $this->listsCache->generateKey($params);
        $key = $this->listsCache->generateKey($args);
        // Check cache headers
        $cache_check = $this->checkHeaders($request, $key);
@@ -299,94 +199,18 @@
            return $cache_check;
        }
        $list_id = $request->get_param('id');
        $response = $list_id
            ? $this->getListDetails($list_id, $user_id)
            : $this->getAvailableLists($user_id);
        $includeShares = !empty($request->get_param('include_shares'));
        $response = !empty($listId)
            ? JVB()->favourites()->getListDetails($listId, $user_id)
            : JVB()->favourites()->getAvailableLists($args, $includeShares);
        return $this->addCacheHeaders(Response::success($response));
    }
    /**
     * Get lists available to a user using CustomTable
     */
    protected function getAvailableLists(int $user_id, bool $include_shared = true): array
    {
        if (!$this->checkUser($user_id)) {
            return [];
        }
        $cache = $include_shared ? $this->sharedListsCache : $this->listsCache;
        return $cache->remember($user_id, function() use ($user_id, $include_shared) {
            try {
                // Get owned lists
                $owned = $this->lists
                    ->where(['user_id' => $user_id])
                    ->orderBy('created_at', 'DESC')
                    ->getResults(ARRAY_A);
                // Add item counts
                foreach ($owned as &$list) {
                    $list['item_count'] = $this->listItems
                        ->where(['list_id' => $list['id']])
                        ->countResults();
                    $list['is_owner'] = true;
                    $list['is_shared'] = false;
                }
                if (!$include_shared) {
                    return [
                        'success' => true,
                        'lists' => $owned
                    ];
                }
                // Get shared lists
                $shares = $this->listShares
                    ->where(['user_id' => $user_id, 'status' => 'accepted'])
                    ->getResults();
                $shared_lists = [];
                foreach ($shares as $share) {
                    $list = $this->lists
                        ->where(['id' => $share->list_id])
                        ->first(ARRAY_A);
                    if ($list) {
                        $owner = get_userdata($list['user_id']);
                        $list['owner_name'] = $owner ? $owner->display_name : 'Unknown';
                        $list['item_count'] = $this->listItems
                            ->where(['list_id' => $list['id']])
                            ->countResults();
                        $list['permission_type'] = $share->permission_type;
                        $list['is_owner'] = false;
                        $list['is_shared'] = true;
                        $shared_lists[] = $list;
                    }
                }
                return [
                    'success' => true,
                    'lists' => [
                        'owned' => $owned,
                        'shared' => $shared_lists
                    ]
                ];
            } catch (Exception $e) {
                $this->logError('getAvailableLists', [
                    'error' => $e->getMessage(),
                    'user_id' => $user_id
                ]);
                return [];
            }
        });
    }
    /**
     * TODO: Done until here
     * Get favourite counts by type
     */
    public function getFavouriteCounts(WP_REST_Request $request): WP_REST_Response
@@ -840,18 +664,22 @@
    protected function buildParams(WP_REST_Request $request): array
    {
        $data = $request->get_params();
        $args = ['user' => absint($data['user'])];
        if (!array_key_exists('page', $data)) {
            return $args;
        $where = ['user_id' => absint($data['user'])];
        if (!empty($data['content']) && $data['content'] !== 'all') {
            $where['type'] = BASE . $data['content'];
        }
        $args = array_merge($args, [
            'page' => max(1, absint($data['page'] ?? 1)),
            'content' => Registrar::getInstance($data['content']) ? $data['content'] : 'all',
        ]);
        $page    = max(1, absint($data['page'] ?? 1));
        $perPage = 250;
        return $this->applyOrderFilters($args, $data);
        return [
            'where'     => $where,
            'orderby'   => sanitize_text_field($data['orderby'] ?? 'created_at'),
            'order'     => sanitize_text_field($data['order'] ?? 'DESC'),
            'per_page'  => $perPage,
            'page'      => $page
        ];
    }
@@ -1034,40 +862,35 @@
     */
    protected function getListDetails(int $list_id, int $user_id): array
    {
        // Check access - either owner or has share
        $is_owner = JVB()->favourites()->userOwnsList($list_id, $user_id);
        $is_shared = JVB()->favourites()->userCanViewList($list_id, $user_id);
        if (!$is_owner && !$is_shared) {
            return [
                'success'   => false,
                'message'   => 'You do not have access to this list.'
            ];
        }
        $list = JVB()->favourites()->getListDetails($list_id, $user_id);
        if (empty($list)) {
            return [
                'success' => false,
                'message' => 'List not found'
            ];
        }
        $key = "list_{$list_id}_user_{$user_id}";
        return $this->listsCache->remember($key, function () use ($list_id, $user_id) {
            try {
                // Check access - either owner or has share
                $is_owner = $this->lists->where([
                    'id' => $list_id,
                    'user_id' => $user_id
                ])->existsInQuery();
                $share = null;
                if (!$is_owner) {
                    $share = $this->listShares->where([
                        'list_id' => $list_id,
                        'user_id' => $user_id,
                        'status' => 'accepted'
                    ])->first();
                }
                if (!$is_owner && !$share) {
                    return [
                        'success' => false,
                        'message' => 'You do not have access to this list'
                    ];
                }
                // Get list details
                $list = $this->lists->where(['id' => $list_id])->first(ARRAY_A);
                if (!$list) {
                    return [
                        'success' => false,
                        'message' => 'List not found'
                    ];
                }
                // Get list items
                $items = $this->listItems
inc/ui/CRUDSkeleton.php
@@ -1579,7 +1579,7 @@
            <input type="hidden" name="content" value="<?=$this->dataType?>" />
            <div class="fields">
                <?php
                if (!empty($this->statuses)) {
                if (empty($this->sections) && !empty($this->statuses)) {
                    echo Form::render('post_status', '', $this->getStatusFieldConfig('edit-'));
                }
@@ -1593,8 +1593,9 @@
                                'icon'  => $config['icon']
                            ];
                        }
                        $tabs[$slug] = array_merge([
                            'title' => $config['label'],
                            'title' => $config['title'],
                            'content' => '',
                            'description' => $config['description']??'',
                        ], $section);
@@ -1615,7 +1616,7 @@
                    foreach ($first as $f) {
                        if (array_key_exists($f, $fields)) {
                            if ($tabs) {
                                $tabs['basic']['content'] .= Form::render($f, '', $fields[$f]);
                                $tabs['main']['content'] .= Form::render($f, '', $fields[$f]);
                            } else {
                                echo Form::render($f, '', $fields[$f]);
                            }
@@ -1660,12 +1661,13 @@
                    $fields = $this->nonTimelineFields;
                }
                foreach ($fields as $n => $config) {
                    if (in_array($config['type'], ['taxonomy', 'selector'])) {
                        $config = array_merge($config, $this->taxConfig($config['taxonomy'], $config['label']));
                    }
                    if ($tabs) {
                        $section = (array_key_exists('section', $config)) ? $config['section'] : 'basic';
                        $section = (array_key_exists('section', $config)) && !empty($config['section']) ? $config['section'] : 'main';
                        $tabs[$section]['content'] .= Form::render($n, '', $config);
                    } else {
                        echo Form::render($n, '', $config);